在我现在在做的项目中需要进行身份和权限认证,在网上找了些教程,推荐都是使用JWT来进行身份认证,于是便决定使用此方法来实现。(然而用完了才发现JWT也有缺点)
#
何为JWT
所谓JWT,全称是JSON Web Token。下面是从官网摘抄的定义:
JWT是一个开放的标准(RFC 7519),它定义了一种紧凑和独立的方式,以JSON对象的形式在各方之间安全地传输信息。这种信息可以被验证和信任,因为它是经过数字签名的。JWTs可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公共/私人密钥对进行签名。
按我的理解,JWT其实就是将传统的session认证中的token存储位置从服务器上下发给用户,服务器只需要判断传来的token是否合法而无需存储。这样做的好处就是可以做分布式的服务器,无需考虑用户是在哪一台服务器上登录的。
详细的定义和构成我这里就不展开了,这里主要关注JWT中Payload(载荷)。这是有效信息存放的地方,我们一般关注这里就行了。
下面是官方提供且建议(并不强制)使用的声明:
-
iss: jwt签发者
-
sub: 主题
-
aud: 接收jwt的一方
-
exp: jwt的过期时间,这个过期时间必须要大于签发时间
-
nbf: 生效时间,在此时间之前该jwt都是不可用的.
-
iat: jwt的签发时间
-
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
当然,在载荷中还可以存放自定义的信息,在本项目中使用官方提供的就足以了,故不展开。
#
认证流程
- 用户使用用户名密码来请求服务器
- 服务器进行验证用户的信息
- 服务器通过验证发送给用户一个token
- 客户端存储token,并在每次请求时附送上这个token值
- 服务端验证token值,并返回数据
#
具体实现
#
导入依赖包
第一步当然是导入相关依赖了,使用Maven进行包管理。
1
2
3
4
5
6
7
8
9
10
11
12
|
<!-- shiro -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.8.0</version>
</dependency>
<!-- jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.18.1</version>
</dependency>
|
#
封装JWT工具类
这里主要实现JWT的生成、验证、提取用户名三个功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
|
@Slf4j
public class JwtUtil {
/**
* 服务器私钥
*/
private static final Algorithm ALGORITHM = Algorithm.HMAC256("test");
/**
* 生成JSON Web Token
*
* @param username 用户名
* @param issuer 签发者
* @param subject 面向主体
* @param ttlMillis 生效时长(单位:毫秒)
*/
public static String creatJwt(String username, String issuer, String subject, long ttlMillis) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
JWTCreator.Builder builder = JWT.create()
.withAudience(username)
.withIssuedAt(now)
.withSubject(subject)
.withIssuer(issuer);
if (ttlMillis >= 0) {
long expMillis = nowMillis + ttlMillis;
Date exp = new Date(expMillis);
builder.withExpiresAt(exp);
}
return builder.sign(ALGORITHM);
}
/**
* 生成JSON Web Token
*
* @param username 用户名
* @param issuer 签发者
* @param subject 面向主体
* @param expDate 失效日期
*/
public static String creatJwt(String username, String issuer, String subject, Date expDate) {
Date now = new Date();
JWTCreator.Builder builder = JWT.create()
.withAudience(username)
.withIssuedAt(now)
.withSubject(subject)
.withIssuer(issuer);
if (now.before(expDate)) {
builder.withExpiresAt(expDate);
}
return builder.sign(ALGORITHM);
}
/**
* 获取签发对象
*
* @param token 需要解密的token
* @return 解密后获得的对象,失败返回null
*/
public static String getAudience(String token) {
String audience;
try {
audience = JWT.decode(token).getAudience().get(0);
} catch (JWTDecodeException exception) {
log.error("输入的token无法解析");
return null;
}
return audience;
}
/**
* 验证token是否正确
*
* @param token 需要验证的token
* @return 通过验证返回true,反之抛出异常
*/
public static Boolean verifyToken(String token) throws JWTVerificationException {
JWTVerifier verifier = JWT.require(ALGORITHM).build();
verifier.verify(token);
return true;
}
}
|
#
配置Shiro
这里的ShiroConfig.java
与一般Shiro项目的配置有以下几点不同:
- 禁用Session
- 使用自定义的jwtFilter过滤器,用来拦截并处理携带JWT token的请求
- 使用自定义的Realm认证器,用于验证用户是否存在及查询用户权限
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
@Configuration
public class ShiroConfig {
@Autowired
private MyRealm myRealm;
@Bean
public DefaultWebSecurityManager getDefaultWebSecurityManager() {
DefaultWebSecurityManager manger = new DefaultWebSecurityManager();
manger.setRealm(myRealm);
// 关闭shiro自带的session
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator());
manger.setSubjectDAO(subjectDAO);
return manger;
}
@Bean
public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager manger) {
ShiroFilterFactoryBean bean = new CustomShiroFilterFactoryBean();
bean.setSecurityManager(manger);
Map<String, Filter> filterMap = new HashMap<>();
filterMap.put("jwt", getJwtFilter());
bean.setFilters(filterMap);
Map<String, String> map = new LinkedHashMap<>();
//设置过滤规则,anon表示无需认证,其余的请求都通过自定义的jwt认证器
map.put("/", "anon");
map.put("/swagger-ui/**", "anon");
map.put("/v3/api-docs", "anon");
map.put("/swagger-resources/**", "anon");
map.put("/unauthorized/**", "anon");
map.put("/**", "jwt");
bean.setFilterChainDefinitionMap(map);
bean.setLoginUrl("/login");
// 设置无权限时跳转的 url
bean.setUnauthorizedUrl("/unauthorized/无权限");
return bean;
}
public JwtFilter getJwtFilter() {
return new JwtFilter();
}
/**
* 开启注解代理
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
/**
* 禁用session, 不保存用户登录状态。保证每次请求都重新认证
*/
@Bean
protected SessionStorageEvaluator sessionStorageEvaluator() {
DefaultSessionStorageEvaluator sessionStorageEvaluator = new DefaultSessionStorageEvaluator();
sessionStorageEvaluator.setSessionStorageEnabled(false);
return sessionStorageEvaluator;
}
}
|
#
自定义Token
由于使用了JWT当token,自然要写自定义的Token
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
public class JwtToken implements AuthenticationToken {
private String token;
public JwtToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
|
#
自定义过滤器
由于使用了JWT,所以不能使用shiro中自带的过滤器,而是自定义自己的过滤器 JWTFilter
,JWTFilter
继承了 BasicHttpAuthenticationFilter
,并部分原方法进行了重写
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
//判断请求的请求头是否带上 "Token"
if (isLoginAttempt(request, response)) {
//如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
try {
executeLogin(request, response);
return true;
} catch (Exception e) {
//token 错误
responseError(response, e.getMessage());
}
}
//如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
return true;
}
/**
* 判断用户是否想要登入。
* 检测 header 里面是否包含 Token 字段
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
String token = req.getHeader("Token");
return token != null;
}
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader("Token");
JwtToken jwtToken = new JwtToken(token);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request, response).login(jwtToken);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}
/**
* 将非法请求跳转到 /unauthorized/**
*/
private void responseError(ServletResponse response, String message) {
try {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
//设置编码,否则中文字符在重定向时会变为空字符串
message = URLEncoder.encode(message, "UTF-8");
//允许跨域请求
httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET, POST");
httpServletResponse.setHeader("Access-Control-Allow-Origin", "*");
httpServletResponse.setHeader("Access-Control-Max-Age", "3600");
httpServletResponse.sendRedirect("/unauthorized/" + message);
} catch (IOException e) {
log.error(e.getMessage());
}
}
}
|
该过滤器有这几大步骤:
- 检验请求头是否带有Token
- 如果带有 token,执行 shiro 的 login() 方法,将 token 提交到 Realm 中进行检验;如果没有 token,说明当前状态为游客状态(或者其他一些不需要进行认证的接口)
- 如果在 token 校验的过程中出现错误,如 token 校验失败,那么我会将该请求视为认证不通过,则重定向到
/unauthorized/**
#
自定义Realm
这里主要是进行身份认证和权限认证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
@Component
public class MyRealm extends AuthorizingRealm {
@Autowired
UserService userService;
@Autowired
RoleService roleService;
@Autowired
PermissionService permissionService;
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* 权限认证
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
String userName = (String) principals.iterator().next();
Set<String> roles = roleService.getAllRoleByUserName(userName);
Set<String> permissions = permissionService.getAllPermissionByUserName(userName);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setRoles(roles);
info.setStringPermissions(permissions);
return info;
}
/**
* 身份认证
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
JwtToken jwtToken = (JwtToken) token;
String jwtTokenPrincipal = (String) jwtToken.getPrincipal();
String userName = JwtUtil.getAudience(jwtTokenPrincipal);
if (userName == null) {
throw new AuthenticationException("token认证失败!");
}
try {
JwtUtil.verifyToken(jwtTokenPrincipal);
} catch (JWTDecodeException e) {
throw new AuthenticationException("不是有效的JWT格式");
} catch (SignatureVerificationException e) {
throw new AuthenticationException("无效的签名");
} catch (TokenExpiredException e) {
throw new AuthenticationException("token已过期");
}
User user = userService.getUserByUserName(userName);
if (user == null) {
throw new AuthenticationException("该用户不存在!");
}
return new SimpleAuthenticationInfo(userName, jwtTokenPrincipal, user.getUserId());
}
}
|
这里有个细节,在Controller中要求进行身份认证时,Shiro会自动把上面doGetAuthenticationInfo
方法中返回的Info存起来。这样我们就可以在Controller中调用这里面的值了。
userName
可以通过(String) SecurityUtils.getSubject().getPrincipal()
取出来
userId
可以通过SecurityUtils.getSubject().getPrincipals().getRealmNames().iterator().next()
取出来。当然,这里根据Shiro的设计,SimpleAuthenticationInfo
的第三个构造参数应该填入RealmName
,用来区分用户该使用哪一个Realm进行验证的。但我这个项目就只有一个Realm,所以在这里就偷了个懒,就没按官方标准来了。
#
异常处理
第一个是专门处理身份认证时的异常
1
2
3
4
5
|
@ApiOperation(value = "接收未授权错误", notes = "返回错误信息")
@GetMapping("/unauthorized/{message}")
public Result unauthorized(@PathVariable String message) {
return new Result().setCode(233).setMessage(message);
}
|
第二个则是全局接管Shiro的异常,在其中进行处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
@RestControllerAdvice
public class ExceptionController {
/**
* 捕捉shiro的异常
*/
@ExceptionHandler(ShiroException.class)
public Result handle401(ShiroException e) {
Result result = new Result();
result.setCode(666);
if(e instanceof UnauthenticatedException){
result.setMessage("您没有登录!");
}else if(e instanceof UnauthorizedException){
result.setMessage("您没有权限访问!");
}
else {
result.setMessage(e.toString());
}
return result;
}
}
|
#
解决中文报错
在Shiro1.7版本之后增加了url校验,如果有中文字符就不通过。然而我们项目里返回错误信息就是通过url来实现的。参考这篇文章知道,需要自己重写ShiroFilterFactoryBean
来实现关闭url校验
1
2
3
4
5
6
7
8
9
10
11
12
13
|
public class CustomShiroFilterFactoryBean extends ShiroFilterFactoryBean {
@Override
protected FilterChainManager createFilterChainManager() {
FilterChainManager manager = super.createFilterChainManager();
// URL携带中文400,servletPath中文校验bug
Map<String, Filter> filterMap = manager.getFilters();
Filter invalidRequestFilter = filterMap.get(DefaultFilter.invalidRequest.name());
if (invalidRequestFilter instanceof InvalidRequestFilter) {
((InvalidRequestFilter) invalidRequestFilter).setBlockNonAscii(false);
}
return manager;
}
}
|
#
开始使用
- 身份认证在Controller上添加
@RequiresAuthentication
- 角色认证在Controller上添加
@RequiresRoles("xxx")
- 权限认证在Controller上添加
@RequiresPermissions("xxx")
#
测试
测试接口的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@GetMapping("/test")
@ResponseBody
public Result test() {
return new Result().setCode(200).setMessage("当你看到这段话时,说明服务成功运行了");
}
@GetMapping("/test2")
@ResponseBody
@RequiresPermissions("user:insert")
public Result test2() {
return new Result().setCode(200).setMessage("当你看到这段话时,说明你已经通过了验证");
}
@GetMapping("/test3")
@ResponseBody
@RequiresPermissions("noBody")
public Result test3() {
return new Result().setCode(200).setMessage("当你看到这段话时,说明认证系统出现了问题");
}
|
#
参考文章
#
更新日志
2021.11.7:
- 增加解决跳转时的跨域请求
- 更细粒度的展示JWT错误原因
- 增加
SecurityUtils
的使用
- 增加测试接口的代码