目录

一、引入依赖及原理简述
二、多用户维护配置
三、基础自定义配置
四、前后端分离及登录结果管理
五、角色权限管理基础


从此处开始,为新的原创内容,相关数据结构代码换了一套新的,与之前的代码关系不大了。
建议新建一个项目,将配置文件复制过来,然后按照步骤走。

六、RBAC 结构实现
七、自定义响应式登录与 JWT 配置 <— 你在这里 ( •̀ ω •́ )y
八、集成 Redis

Spring Security(七)自定义响应式登录与 JWT 配置

博主前言:本以为这个就是代替传统 jwt 的插件,没想到复杂程度如此之高。Spring Security 本身是个高度自定义化的组件,必须花时间重点学习一下。以下为个人配置学习的流程,从零到权限管理、redis嵌入等步骤。
本文基于尚硅谷的 Spring Security 教程学习,文章与原教程有不小出入,仅供参考。
B站视频链接:尚硅谷Java项目SpringSecurity+OAuth2权限管理实战教程

Spring Security 自带的登录接口是基于表单形式的,而对于前后端分离项目,更多运用响应式的json形式。若想改为json,或者做更复杂的修改(例如双端双接口登录等),就需要自定义登录接口了。

[!WARNING]

既然用到了json传递数据,这里就不再赘述【序列化与反序列化】的问题。


一、前置准备工作

[!IMPORTANT]

数据结构请参考上一篇文章:六、RBAC 结构实现

  1. 登录操作对应的DTOVO
1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
public class LoginDTO {
private String account;
private String password;
}

@Data
@AllArgsConstructor
public class LoginVO {
private Integer id;
private String username;
private String token;
}
  1. 引入jwt相关依赖:
1
2
3
4
5
6
7
8
9
10
11
<!-- jwt 相关 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
  1. 配置文件编写jwt的相关信息:
1
2
3
4
jwt:
key: jwt-key # 设置 jwt 签名加密时使用的秘钥
ttl: 86400000 # 设置 jwt 过期时间
header: Authorization # 设置前端传递过来的令牌名称
  1. 编写对应的Properties类,导入配置信息:
1
2
3
4
5
6
7
8
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JWTProperties {
private String key;
private long ttl;
private String header;
}
  1. 准备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
/**
* JWT工具类
*
* @author Amane64
*/
public class JWTUtils {

/**
* 签名算法
*/
private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256;

/**
* 创建JWT令牌
*
* @param claims 载荷
* @param key 密钥
* @param ttl 有效时长
* @return token
*/
public static String createJWT(Map<String, Object> claims, String key, Long ttl) {
return Jwts.builder()
// 签名算法和密钥
.signWith(SIGNATURE_ALGORITHM, key)
// 载荷
.addClaims(claims)
// 过期时间
.setExpiration(new Date(System.currentTimeMillis() + ttl))
.compact();
}

/**
* 解析JWT令牌
*
* @param token JWT令牌
* @param key 密钥
* @return 载荷
*/
public static Claims parseJWT(String token, String key) {
return Jwts.parser()
.setSigningKey(key)
.parseClaimsJws(token)
.getBody();
}
}
  1. Controller自定义登录登出接口:
1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;

@PostMapping("/login")
public Result<LoginVO> login(@RequestBody LoginDTO dto) {
return Result.success(loginService.login(dto.getAccount(), dto.getPassword()));
}
}
  1. 准备LoginService,我们新的登录登出逻辑将在此实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface LoginService {
LoginVO login(String account, String password);
}

@Service
@RequiredArgsConstructor
public class LoginServiceImpl implements LoginService {

@Override
public LoginVO login(String account, String password) {
// todo 登录逻辑,并于 Spring Security 适配
return null;
}
}

二、重写 Spring Security 的登录实现

  1. 修改Config,主要部分为重写基于数据库的身份验证实现和废弃掉旧的验证方式
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
/**
* Spring Security 配置
*
* @author Amane64
*/
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserService userService;
private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
private final SecurityResultHandler securityResultHandler;

/**
* 密码加密器
*
* @return BCrypt
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

/**
* 重写基于数据库的身份验证实现
*/
@Bean
public AuthenticationManager authenticationManager() {
var daoAuthenticationProvider = new DaoAuthenticationProvider();
// 配置获取用户数据的方法
daoAuthenticationProvider.setUserDetailsService(userService);
// 配置密码加密器
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(daoAuthenticationProvider);
}

/**
* Spring Security 过滤器链配置
*
* @param http 请求体
* @return 过滤器链
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// 关掉 csrf 用于本地调试(务必在项目正式上线时开启!!!)
.csrf(AbstractHttpConfigurer::disable)
// 准许跨域访问
.cors(Customizer.withDefaults())
// 对来自 http/https 的请求的授权保护方法
.authorizeHttpRequests(authorize -> authorize
// 配置不同终端访问权限
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/user/**").hasRole("USER")
// 放行登录接口
.requestMatchers("/login").permitAll()
// 对所有请求均做授权保护,已认证的会自动授权
.anyRequest().authenticated())
// 禁用表单授权登录
.formLogin(AbstractHttpConfigurer::disable)
// 禁用 HTTP Basic 登录
.httpBasic(AbstractHttpConfigurer::disable)
// 自定义异常处理
.exceptionHandling(exception -> exception
// 登录异常(请求未认证)处理
.authenticationEntryPoint(securityResultHandler)
// 无权限访问处理
.accessDeniedHandler(securityResultHandler)
)
.build();
}
}
  1. 实现新的登录逻辑,并生成jwt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private final JWTProperties jwtProperties;
private final AuthenticationManager authenticationManager;

@Override
public LoginVO login(String account, String password) {
// 验证登录,并存入缓存
var authenticationToken = new UsernamePasswordAuthenticationToken(account, password);
Authentication authentication = authenticationManager.authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);

// 生成 token
var user = (User) authentication.getPrincipal();
var claims = new HashMap<String, Object>();
claims.put("id", user.getId());
claims.put("username", user.getUsername());
var token = JWTUtils.createJWT(claims, jwtProperties.getKey(), jwtProperties.getTtl());

// 封装返回数据
return new LoginVO(user.getId(), user.getUsername(), token);
}

三、添加jwt校验拦截

  1. 创建jwt拦截器:

[!CAUTION]

需要注意的是,为确保权限功能正常运行,将用户信息存入内存时,仍需要获取权限。

请读者按实际需求配置。

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
/**
* JWT 拦截器
*
* @author Amane64
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private final JWTProperties jwtProperties;
private final RoleService roleService;
private final PermissionService permissionService;

@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
// 放行登录接口
if (request.getMethod().equals("POST")
&& request.getRequestURI().equals("/login")) {
filterChain.doFilter(request, response);
return;
}

// 获取并校验 token
String token = request.getHeader(jwtProperties.getHeader());
if (token == null) {
log.warn("请求头未传递 token");
throw new IllegalArgumentException("请求头未传递 token");
}
Claims claims;
try {
claims = JWTUtils.parseJWT(token, jwtProperties.getKey());
} catch (MalformedJwtException e) {
log.warn("token 签名无效");
throw e;
} catch (SignatureException e) {
log.warn("token 签名错误");
throw e;
} catch (UnsupportedJwtException e) {
log.warn("token 算法不一致");
throw e;
} catch (ExpiredJwtException e) {
log.warn("token 过期");
throw e;
} catch (Exception e) {
log.warn("未知 token 拦截错误 {}", e.getMessage());
throw e;
}

var userId = ((Number) claims.get("id")).longValue();
var username = (String) claims.get("username");

// 获取对应角色及权限
var authorityList = new ArrayList<GrantedAuthority>();
List<Role> roleList = roleService.getByUserId(userId);
roleList.forEach(role -> {
// 添加角色类型(security 中的【角色】)
authorityList.add(() -> "ROLE_" + role.getType());

// 获取对应角色权限,并添加权限
List<Permission> permissionList = permissionService.getByRoleId(role.getId());
permissionList.forEach(permission -> authorityList.add(permission::getValue));
});

// 存入 SecurityContextHolder 并放行
var authenticationToken = new UsernamePasswordAuthenticationToken(username, null, authorityList);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
  1. 在Config类中注册jwt拦截器:
1
2
// 注册 jwt 拦截器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
  1. 重写 Security 结果处理类:

[!TIP]

因为弃用了表单登录等一系列功能,结果处理类的部分改造也得以删除。

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
/**
* Security 结果处理器
*
* @author Amane64
*/
@Slf4j
@Component
public class SecurityResultHandler implements AuthenticationEntryPoint, AccessDeniedHandler {

/**
* 请求未认证处理
*
* @param request 请求
* @param response 响应
* @param authException 异常信息
*/
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException {
String resMsg = "登录已过期,请重新登录";
if (authException instanceof BadCredentialsException)
resMsg = authException.getMessage();

// 简单构造一个请求未认证的响应结果 json
var result = Result.error(-1, resMsg);
var resultJSON = JSON.toJSONString(result);

// 返回 json 数据给前端
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().println(resultJSON);
}

/**
* 无权限访问
*
* @param request 请求
* @param response 响应
* @param accessDeniedException 异常信息
*/
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException {
log.warn("用户尝试越权操作:{}", accessDeniedException.getMessage());

// 简单构造一个无权限访问的响应结果 json
var result = Result.error(-1, "该用户无权访问");
var resultJSON = JSON.toJSONString(result);

// 返回 json 数据给前端
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().println(resultJSON);
}
}

至此,自定义响应式登录配置完毕。