SpringSecurity实战教程(九)前后端分离认证实战 - JWT+SpringSecurity无缝整合

1. 前言

又是新的一周,博主继续来给大家更新 Spring Security 实战教程系列啦~ 通过前面的章节教程从认证到授权,相信大家已经基本了解 Spring Security 的工作原理。

但在前后端分离架构成为主流的今天,传统的 Session-Cookie 认证模式面临跨域限制、服务端状态维护等难题。JWT(JSON Web Token)作为无状态令牌方案,凭借其自包含、易扩展的特性,成为现代分布式系统的首选认证方案。

那么本章节,博主就带着大家一起来进行 Spring Security 前后端分离认证实战,手把手教构建安全的 JWT 认证体系!


2. JWT 基本原理

JWT(JSON Web Token) 是一种开放标准(RFC 7519),用于在各方之间以 JSON 对象安全地传输信息。其主要特点包括:

  • 无状态:服务端无需保存会话信息,降低了服务端压力(传统 Session 是保存服务端)
  • 跨域支持:适用于前后端分离应用场景

JWT 通常由三部分组成:Header、Payload 和 Signature。在认证场景中,用户登录后服务器生成一个包含用户信息的 Token,前端将该 Token 存储在本地,并在后续请求中携带到 HTTP Header 中。服务端通过解析和验证 Token,完成用户身份认证。


3. Spring Security 与 JWT 整合思路

整合 JWTSpring Security 的关键在于:

  • 无状态配置:关闭 Spring Security 默认的 Session 管理,采用无状态认证
  • 自定义认证入口:提供一个登录接口,验证用户凭据,生成 JWT
  • JWT 拦截过滤器:在请求到达业务逻辑前,拦截 HTTP 请求,解析和验证 JWT,将用户认证信息写入 SecurityContext

完整流程图如下:
在这里插入图片描述


4. 实战开始

本次演示我们先简单模拟集成,还是在之前的 Maven 项目中新建子模块,命名:jwt-spring-security
Maven 配置文件追加 JWT 库:

1
2
3
4
5
<dependency>  
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.12.6</version>
</dependency>

YML 配置文件中添加密钥以及 Token 有效期:

1
2
3
jwt:  
  secret: "dGhpcyBpcyBhIHNlY3JldCBrZXkgZm9yIG15IGFwcA==" # Base64编码密钥
  expiration: 900000 # 15分钟

4.1 配置 JWT 工具类

如果你的系统使用了 Hutool 工具包,可以直接调用 JWTUtil 来创建以及验证 JWT,具体参考 https://doc.hutool.cn/pages/JWTUtil/

这里我们使用的是 io.jsonwebtoken 来自定义 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
@Component  
public class JwtUtils {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;

    // 生成令牌时设置 subject
    public String generateToken(String username) {
        return Jwts.builder()
                .subject(username) // 关键:设置用户名到 subject
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey())
                .compact();
    }

    // 用户名提取方法
    public String extractUsername(String token) {
        return parseClaims(token).getSubject();
    }

    // 统一的令牌验证方法
    public boolean validateToken(String token) {
        parseClaims(token); // 复用解析逻辑
        return true;
    }

    // 校验 Token 是否过期
    public boolean isTokenExpired(String token) {
        Claims claims = parseClaims(token);
        return claims.getExpiration().before(new Date());
    }

    // 私有方法:统一解析令牌声明
    private Claims parseClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    // 密钥生成方法
    private SecretKey getSigningKey() {
        byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

4.2 JwtAuthFilter:JWT 过滤器

在前面章节我们讲解 Spring Security 底层原理的时候,我们知道 Spring Security 默认 DefaultSecurityFilterChain 启动的时候,会通过多个 Filter 来逐层检查,实际上同理只需要我们自定义 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
@Component  
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtils jwtUtils;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 从请求头获取 Token,约定使用 "Authorization" 且前缀为 "Bearer "
        String token = parseToken(request);
        if (token != null && jwtUtils.validateToken(token)) {

            String username = jwtUtils.extractUsername(token);
            // 如果 token 存在且 SecurityContext 为空,设置用户认证
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                // 这里只是示例,实际应用中应加载用户详情信息
                UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                // 将认证信息放入上下文中
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        filterChain.doFilter(request, response);
    }

    private String parseToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

4.3 安全配置类

Spring Security 配置类主要关闭 session 管理,并追加自定义 JWT Filter,放行 /api/auth/login 接口地址,其余均需要验证 JWT:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration  
// 开启方法级的安全控制
@EnableMethodSecurity
@RequiredArgsConstructor
public class JwtSecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // 禁用 CSRF,因为使用 JWT 方式无需 Session
                .csrf(csrf -> csrf.disable())
                // 设置无状态 Session 管理
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/login").permitAll()
                        .anyRequest().authenticated())
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

4.4 配置测试 Controller

编写一个登陆接口 /api/auth/login 以及一个验证接口 /api/auth/verify

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController  
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final JwtUtils jwtUtils;

    // 简单示例,真实场景中应从数据库加载用户信息并校验密码
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
        // 这里假设用户名为 "admin",密码为 "admin"
        if ("admin".equals(loginRequest.getUsername()) && "admin".equals(loginRequest.getPassword())) {
            String token = jwtUtils.generateToken(loginRequest.getUsername());
            return ResponseEntity.ok(token);
        }
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("用户名或密码错误");
    }

    @GetMapping("/verify")
    public ResponseEntity<?> verify() {
        return ResponseEntity.ok("验证用户访问成功");
    }
}

4.5 运行测试

这里博主使用的 Apifox 测试效果,首先登陆获得 token:
在这里插入图片描述

在接下来的验证接口,配置 Header 设置 Authorization 的 Key,并将登陆获得的 token 设置值:

格式:Bearer + 登陆获得的 Token

在这里插入图片描述

至此我们就完成了最简单的 JWT + Spring Security 整合。


5. 基于 RBAC 角色模型的升级

回忆一下我们之前第五章节中,RBAC 角色模型的表设计:需要通过用户 ID 查询到用户分配的角色 + 角色所配置的菜单资源:
在这里插入图片描述

这里我们就基于 最新 Spring Security 实战教程(五)基于数据库的动态用户认证传统 RBAC 角色模型实战开发 复用代码,集合 JWT 动态从数据库获取用户信息认证、授权。

5.1 复用第五章节 RBAC 角色模型代码

相关 RBAC 角色模型的知识,请小伙伴自行查阅第五章节内容,篇幅有限这里就简单贴出代码即可。

实体类

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
// SysMenu 实体类  
@Data
@TableName("sys_menu")
public class SysMenu {
    @TableId(type = IdType.AUTO)
    private Long menuId;
    private String menuName;
    private String perms;
}

// SysRole 实体类
@Data
@TableName("sys_role")
public class SysRole {
    @TableId(type = IdType.AUTO)
    private Long roleId;
    private String roleName;
    private String roleKey;

    @TableField(exist = false)
    private List<SysMenu> menus;
}

// SysUser 实体类
// 博主为了方便,直接使用数据库映射的 SysUser 对象直接实现 UserDetails,大家在开发过程中建议单独构建实现对象!
@Data
@TableName("sys_user")
public class SysUser implements UserDetails {

    @TableId(type = IdType.AUTO)
    private Long userId;

    @TableField("login_name")
    private String username; // Spring Security 认证使用的字段

    private String password;

    private String status; // 状态(0 正常 1 锁定)

    private String delFlag; // 删除标志(0 代表存在 1 代表删除)

    @TableField(exist = false)
    private List<SysRole> roles;

    // 实现 UserDetails 接口
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 组装 GrantedAuthority 集合,将角色和菜单权限都加入
        Set<GrantedAuthority> authorities = new HashSet<>();
        authorities.addAll(roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRoleKey()))
                .collect(Collectors.toList()));

        authorities.addAll(roles.stream()
                .flatMap(role -> role.getMenus().stream())
                .map(menu -> new SimpleGrantedAuthority(menu.getPerms()))
                .collect(Collectors.toList()));
        return authorities;
    }

    @Override
    public boolean isAccountNonExpired() { return true; }

    @Override
    public boolean isAccountNonLocked() {
        return "0".equals(status);
    }

    @Override
    public boolean isCredentialsNonExpired() { return true; }

    @Override
    public boolean isEnabled() {
        return "0".equals(delFlag);
    }
}

Mapper 配置

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
// MenuMapper 配置  
@Mapper
public interface MenuMapper extends BaseMapper<SysMenu> {

@Select("SELECT DISTINCT m.* FROM sys_menu m " +
"JOIN sys_role_menu rm ON m.menu_id = rm.menu_id " +
"JOIN sys_user_role ur ON rm.role_id = ur.role_id " +
"WHERE ur.user_id = #{userId}")
List<SysMenu> selectByUserId(Long userId);
}

// RoleMapper 配置
@Mapper
public interface RoleMapper extends BaseMapper<SysRole> {

@Select("SELECT m.* FROM sys_menu m " +
"JOIN sys_role_menu rm ON m.menu_id = rm.menu_id " +
"WHERE rm.role_id = #{roleId}")
List<SysMenu> selectMenusByRoleId(Long roleId);
}

// UserMapper 配置
@Mapper
public interface UserMapper extends BaseMapper<SysUser> {

@Select("SELECT r.* FROM sys_role r " +
"JOIN sys_user_role ur ON r.role_id = ur.role_id " +
"WHERE ur.user_id = #{userId}")
@Results({
@Result(property = "roleId", column = "role_id"),
@Result(property = "menus", column = "role_id",
many = @Many(select = "com.toher.springsecurity.demo.jwt.mapper.MenuMapper.selectByUserId"))
})
List<SysRole> selectRolesByUserId(Long userId);
}

5.2 自定义 UserDetailsService 实现

自定义 UserDetailsService 继承 UserDetailsService,重写 loadUserByUsername 方法,注入 UserMapper 以及 RoleMapper 通过用户名查询数据库数据,同时将用户的角色、菜单资源集合一并赋值:

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
@Service  
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserMapper userMapper;
    private final RoleMapper roleMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        // 1. 查询基础用户信息
        LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(SysUser::getUsername, username);
        SysUser user = userMapper.selectOne(wrapper);

        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        // 2. 加载角色和权限
        List<SysRole> roles = userMapper.selectRolesByUserId(user.getUserId());
        roles.forEach(role ->
                role.setMenus(roleMapper.selectMenusByRoleId(role.getRoleId()))
        );
        user.setRoles(roles);

        // 3. 检查账户状态
        if (!user.isEnabled()) {
            throw new DisabledException("用户已被禁用");
        }
        return user;
    }
}

5.3 改造 JwtAuthFilter

这里我们需要调用 自定义 UserDetailsServiceloadUserByUsername 方法从数据库获取用户信息,其中 user.getAuthorities() 包含了用户角色、资源相关权限信息(具体查阅 SysUser 对象):

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
@Component  
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtils jwtUtils;
    private final UserDetailsServiceImpl userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 从请求头获取 Token,约定使用 "Authorization" 且前缀为 "Bearer "
        String token = parseToken(request);
        if (token != null && jwtUtils.validateToken(token)) {
            String username = jwtUtils.extractUsername(token);
            UserDetails user = userDetailsService.loadUserByUsername(username);
            // 创建认证信息
            UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(user, token, user.getAuthorities());
            authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            // 将认证信息放入上下文中
            SecurityContext context = SecurityContextHolder.createEmptyContext();
            context.setAuthentication(authToken);
            SecurityContextHolder.setContext(context);
        }
        filterChain.doFilter(request, response);
    }

    private String parseToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

5.4 创建 AuthService 处理用户登陆

我们专门创建一个 AuthService 用以处理用户的登陆,用户查询存在则返回 JWT token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service  
@RequiredArgsConstructor
public class AuthService {

    private final AuthenticationManager authenticationManager;
    private final JwtUtils jwtUtils;

    public AuthResponse authenticate(LoginRequest request) {
        // authenticationManager 会调用 userDetailsService.loadUserByUsername 方法
        Authentication authentication = authenticationManager
                .authenticate(new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
        SysUser user = (SysUser) authentication.getPrincipal();

        String token = jwtUtils.generateToken(user.getUsername());
        return new AuthResponse(token, user.getUsername(), user.getAuthorities());
    }
}

另外还有两个 DTO
LoginRequest:用来接收登陆用户名、密码
AuthResponse:用于返回 token、用户的权限信息

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data  
public class LoginRequest {
    private String username;
    private String password;
}

@Data
@AllArgsConstructor
public class AuthResponse {
    private String token;
    private String username;
    private Collection<?> authorities;
}

5.5 调整 JwtSecurityConfig 配置

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
@Configuration  
// 开启方法级的安全控制
@EnableMethodSecurity
@RequiredArgsConstructor
public class JwtSecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // 禁用 CSRF,因为使用 JWT 方式无需 Session
                .csrf(csrf -> csrf.disable())
                // 设置无状态 Session 管理
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/login").permitAll()
                        .requestMatchers("/api/auth/loginByMysql").permitAll()
                        .anyRequest().authenticated())
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(ex -> ex
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint())
                );
        return http.build();
    }

    // 统一认证凭证处理
    @Bean
    public AuthenticationEntryPoint jwtAuthenticationEntryPoint() {
        return (request, response, authException) -> {
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"code\": 401,\"msg\": \"无效的认证凭证\"}");
        };
    }

    // 自定义认证中使用 AuthenticationManager
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }
     
    // 定义密码加密方式
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

5.6 定义接口统一返回 Result + 全局异常处理

之前简单的整合过程中,小伙伴或许发现了接口返回的非 JSON 数据,且 token 过期等没有正确的错误提示,这里我们继续完善一下:

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
@Data  
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class AjaxResult<T> {

    private static final long serialVersionUID = 1L;

    /**
     * 状态码
     */
    private int code;

    /**
     * 返回内容
     */
    private String msg;

    /**
     * 数据对象
     */
    private T data;

    /**
     * 初始化一个新创建的 AjaxResult 对象
     *
     * @param code 状态码
     * @param msg  返回内容
     */
    public AjaxResult(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    /**
     * 返回成功消息
     *
     * @return 成功消息
     */
    public static AjaxResult<Void> success() {
        return AjaxResult.success("操作成功");
    }

    /**
     * 返回成功数据
     *
     * @return 成功消息
     */
    public static <T> AjaxResult<T> success(T data) {
        return AjaxResult.success("操作成功", data);
    }

    /**
     * 返回成功消息
     *
     * @param msg 返回内容
     * @return 成功消息
     */
    public static AjaxResult<Void> success(String msg) {
        return AjaxResult.success(msg, null);
    }

    /**
     * 返回成功消息
     *
     * @param msg  返回内容
     * @param data 数据对象
     * @return 成功消息
     */
    public static <T> AjaxResult<T> success(String msg, T data) {
        return new AjaxResult<>(200, msg, data);
    }

    /**
     * 返回错误消息
     *
     * @return
     */
    public static AjaxResult<Void> error() {
        return AjaxResult.error("操作失败");
    }

    /**
     * 返回错误消息
     *
     * @param msg 返回内容
     * @return 警告消息
     */
    public static AjaxResult<Void> error(String msg) {
        return AjaxResult.error(msg, null);
    }

    /**
     * 返回错误消息
     *
     * @param msg  返回内容
     * @param data 数据对象
     * @return 警告消息
     */
    public static <T> AjaxResult<T> error(String msg, T data) {
        return new AjaxResult<>(500, msg, data);
    }

    /**
     * 返回错误消息
     *
     * @param code 状态码
     * @param msg  返回内容
     * @return 警告消息
     */
    public static AjaxResult<Void> error(int code, String msg) {
        return new AjaxResult<>(code, msg, null);
    }
}

定义全局异常处理类

1
2
3
4
5
6
7
8
9
10
@RestControllerAdvice  
public class GlobalExceptionHandler {
     /**
     * 全局异常
     */
    @ExceptionHandler(Exception.class)
    public AjaxResult handleException(Exception e) {
        return AjaxResult.error(e.getMessage());
    }
}

5.7 Controller 中登陆、测试方法

1
2
3
4
5
6
7
8
9
10
11
12
    // 通过数据库用户数据登陆  
    @PostMapping("/loginByMysql")
    public AjaxResult<AuthResponse> loginByMysql(@RequestBody LoginRequest request) {
        return AjaxResult.success(authService.authenticate(request));
    }

    // 验证权限
    @PreAuthorize("hasAuthority('admin:menu:add')")
    @GetMapping("/add")
    public AjaxResult<Void> add() {
        return AjaxResult.success("方法的授权 admin:menu:add,访问 ok");
    }

5.8 运行测试

访问 /loginByMysql 接口查看返回的用户数据,前端可以根据登陆接口返回决定资源权限的配置:

在这里插入图片描述

最后切换用户测试 /add 仅配置了 admin:menu:add 允许访问:

在这里插入图片描述

最后修改 token,或者当 token 过期,继续请求查看是否被 AuthenticationEntryPoint 统一处理:

在这里插入图片描述


6. 结语

至此本章节内容也就结束了,我们通过 Spring SecurityJWT 实现无状态认证。从 JWT 的基本原理出发,逐步构建了登录接口、JWT 生成工具、拦截过滤器及安全配置的初步整合方案,最后再结合之前章节 RBAC 角色模型升级,完整实现了动态数据库用户的认证授权等。通过完整的代码样例,相信小伙伴们都可以快速搭建出一个高效、灵活的认证系统。