整合SpringBoot

省去建表,数据库读取代码。

程序逻辑

SpringBoot中使用JWT来做接口权限认证,安全框架依旧使用Shiro

  1. 我们POST用户名与密码到/login进行登入,如果成功返回一个加密token,失败的话直接返回401错误。
  2. 之后用户访问每一个需要权限的网址请求必须在header中添加Authorization字段,例如Authorization: tokentoken为密钥。
  3. 后台会进行token的校验,如果不通过直接返回401。

Token加密说明

  • 携带了 username 信息在 token 中。
  • 设定了过期时间。
  • 使用秘钥对 token 进行加密。

Token校验流程

服务端接收到token 之后,会逆向构造过程,decodeJWT 的三个部分,这一步可以得到sign的算法及 payload,结合服务端配置的 secretKey,可以再次进行 Signature 的生成得到新的 ​Signature,与原有的 ​Signature 比对以验证 token 是否有效,完成用户身份的认证。

Maven

添加依赖

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.8.2</version>
</dependency>

<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.1</version>
</dependency>

JWT工具类

我们写一个简单的JWT加密,校验工具,并且使用用户自己的密码充当加密密钥, 这样保证了token 即使被他人截获也无法破解。并且我们在token中附带了username信息,并且设置密钥1天就会过期。

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
public class JWTUtil {

// 过期时间1天
private static final long EXPIRE_TIME = 24 * 60 * 1000;

/**
* 校验token是否正确
*
* @param token 密钥
* @param username 登录名
* @param secret 秘钥
* @return
*/
public static boolean verify(String token, String username, String secret) {
try {
//对秘钥进行加密后再与用户名混淆在一起
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", username)
.build();
DecodedJWT jwt = verifier.verify(token);
return true;
} catch (Exception e) {
return false;
}
}

/**
* 获得token中的信息无需secret解密也能获得
*
* @return token中包含的用户名
*/
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return "";
}
}

/**
* 生成签名
*
* @param username 用户名
* @param secret 用户的密码
* @return 加密的token
*/
private static String sign(String username, String secret) {
// 指定过期时间
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附带username信息
return JWT.create()
.withClaim("username", username)
.withExpiresAt(date)
.sign(algorithm);
}

/**
* 生成前端需要的用户信息,包括:
* 1. token
* 2. userInfo
*
* @param userInfo
* @return
*/
public static Result generateUserInfo(UserInfo userInfo) {
Map<String, Object> responseBean = new HashMap<>(2);
String token = sign(userInfo.getUsername(), userInfo.getPassword());
responseBean.put("token", token);
userInfo.setPassword("");
responseBean.put("userInfo", userInfo);
return ResultFactory.buildSuccessResult(responseBean);
}

}

创建JWTToken替换Shiro原生Token

  1. Shiro 原生的 Token 中存在用户名和密码以及其他信息 [验证码,记住我],因为是前后端分离,服务器无需保存用户状态,所以不需要RememberMe这类功能, 我们简单的实现下AuthenticationToken接口即可
  2. JWTToken 中因为已将用户名和密码通过加密处理整合到一个加密串中,所以只需要一个 token 字段即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class JWTToken implements AuthenticationToken {

private String token;

public JWTToken(String token) {
if(token.contains("Bearer")){
token = token.substring(7, token.length());
}
this.token = token;
}

@Override
public Object getPrincipal() {
return token;
}

@Override
public Object getCredentials() {
return token;
}
}

创建JWTFilter实现前端请求统一拦截及处理

所有的请求都会先经过Filter,所以我们继承官方的BasicHttpAuthenticationFilter,并且重写鉴权的方法, 另外通过重写preHandle,实现跨域访问。

代码的执行流程preHandle->isAccessAllowed->isLoginAttempt->executeLogin

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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
/**
* 创建JWTFilter实现前端请求统一拦截及处理
* 所有的请求都会先经过 Filter,所以我们继承官方的 BasicHttpAuthenticationFilter
* 并且重写鉴权的方法
* 代码的执行流程 preHandle -> isAccessAllowed -> isLoginAttempt -> executeLogin
*
* @author GreenHatHG
**/
public class JWTFilter extends BasicHttpAuthenticationFilter {

/**
* 登录标识
*/
private static String LOGIN_SIGN = "Authorization";

/**
* 检测用户是否登录
* 检测header里面是否包含Authorization字段即可
*
* @param request
* @param response
* @return
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
String authorization = req.getHeader(LOGIN_SIGN);
return authorization != null;
}

/**
* executeLogin() 方法中的 getSubject(request, response).login(token)
* 就是触发 Shiro Realm 自身的登录控制,具体内容需要手动实现
*
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String authorization = httpServletRequest.getHeader(LOGIN_SIGN);
JWTToken token = new JWTToken(authorization);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request, response).login(token);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}


/**
* 一般在isAccessAllowed中执行认证逻辑
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue){
if (isLoginAttempt(request, response)) {
try {
executeLogin(request, response);
} catch (Exception e) {
// 认证出现异常,传递错误信息msg
String msg = e.getMessage();
// Token认证失败直接返回Response信息
this.response401(response, msg);
return false;
}
}
return true;
}

/**
* 这里我们详细说明下为什么重写
* 可以对比父类方法,只是将executeLogin方法调用去除了
* 如果没有去除将会循环调用doGetAuthenticationInfo方法
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
this.sendChallenge(request, response);
return false;
}

/**
* 对跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}

/**
* 无需转发,直接返回Response信息
*/
private void response401(ServletResponse response, String msg) {
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json; charset=utf-8");
try (PrintWriter out = httpServletResponse.getWriter()) {
Result result = ResultFactory.buildUnauthorizedResult("无权访问(Unauthorized):" + msg);
JSONObject jsonObject = JSONUtil.parseObj(result);
out.append(jsonObject.toString());
} catch (IOException e) {
throw new CustomException("直接返回Response信息出现IOException异常:" + e.getMessage());
}
}

}

实现Realm

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
65
66
67
68
69
70
71
72
73
public class ShiroRealm extends AuthorizingRealm {

private UserRepository userRepository;

@Autowired
public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}

/**
* 重写Realm的supports()方法是通过JWT进行登录判断的关键
* 因为前文中创建了JWTToken用于替换 Shiro 原生 token
* 所以必须在此方法中显式的进行替换,否则在进行判断时会一直失败
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}

/**
* 执行授权逻辑
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String username = JWTUtil.getUsername(principalCollection.toString());
UserInfo userInfo = userRepository.findByUsername(username);
List<String> permissionList = new ArrayList<>();
List<String> roleNameList = new ArrayList<>();
Set<SysRole> roleSet = userInfo.getRoleList();

if (CollectionUtils.isNotEmpty(roleSet)) {
for (SysRole role : roleSet) {
// 添加角色
roleNameList.add(role.getName());
// 根据用户角色查询权限
Set<SysPermission> permissionSet = role.getPermissions();
if (CollectionUtils.isNotEmpty(permissionSet)) {
for (SysPermission permission : permissionSet) {
// 添加权限
permissionList.add(permission.getUrl());
}
}
}
}
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.addStringPermissions(permissionList);
info.addRoles(roleNameList);
return info;
}

/**
* 执行认证逻辑
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
* 登录的合法验证通常包括 token 是否有效 、用户名是否存在 、密码是否正确
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
String userName = JWTUtil.getUsername(token);
String secert = userRepository.getCredentials(userName);

/**
* token为空或者不通过
*/
if (StringUtils.isBlank(token) || !JWTUtil.verify(token, userName, secert)) {
throw new AuthenticationException("token校验不通过");
}

//认证成功,将用户信息封装成SimpleAuthenticationInfo
return new SimpleAuthenticationInfo(token, token, "shiroRealm");
}
}

配置Shiro

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
public class ShiroConfig {

@Primary
@Bean
/**
* 设置过滤器,将自定义的Filter加入
*/
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
//用于定义主Shiro Filter
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
//设置要构造的Shiro Filter使用的SecurityManager实例
//这是必填属性-设置失败将引发初始化异常
shiroFilterFactoryBean.setSecurityManager(securityManager);

// 在 Shiro过滤器链上加入 JWTFilter
LinkedHashMap<String, Filter> filters = new LinkedHashMap<>();
filters.put("jwt", new JWTFilter());
shiroFilterFactoryBean.setFilters(filters);

LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
// 所有请求都要经过 jwt过滤器
filterChainDefinitionMap.put("/**", "jwt");

shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}

@Primary
@Bean
public DefaultWebSecurityManager securityManager(ShiroRealm shiroRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 配置 SecurityManager,并注入 shiroRealm
securityManager.setRealm(shiroRealm());

// 关闭Shiro自带的session
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);

return securityManager;
}

@Primary
@Bean
public ShiroRealm shiroRealm() {
// 配置 Realm
return new ShiroRealm();
}
}

controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@PostMapping("/login")
public Result login(@RequestBody Map<String, String> payload){
String username = payload.get("username");
String password = payload.get("password");
final String errorMessage = "用户名或密码错误";

UserInfo userInfo = userRepository.findByUsername(username);
if(userInfo == null){
return ResultFactory.buildFailResult(errorMessage);
}
if(!Argon2Util.verify(userInfo.getPassword(), password)){
return ResultFactory.buildFailResult(errorMessage);
}
return JWTUtil.generateUserInfo(userInfo);
}

@GetMapping("/123")
//需要登录才能获取
@RequiresAuthentication
public Result test(){
System.out.println(111);
return ResultFactory.buildSuccessResult("成功");
}

参考:

SpringBoot系列 - 集成JWT实现接口权限认证 | 飞污熊博客

Shiro + JWT + Spring Boot Restful 简易教程 - 沧海月明

SpringBoot+Shiro+Vue前后端分离项目通过JWT实现自动登录 | asing1elife’s blog