程技spring securitySpringSecurity实战教程(九)前后端分离认证实战 - JWT+SpringSecurity无缝整合
Rich1. 前言
又是新的一周,博主继续来给大家更新 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 整合思路
整合 JWT 与 Spring 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==" expiration: 900000
|
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; public String generateToken(String username) { return Jwts.builder() .subject(username) .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; } 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 { String token = parseToken(request); if (token != null && jwtUtils.validateToken(token)) { String username = jwtUtils.extractUsername(token); 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(csrf -> csrf.disable()) .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) { 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
| @Data @TableName("sys_menu") public class SysMenu { @TableId(type = IdType.AUTO) private Long menuId; private String menuName; private String perms; }
@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; }
@Data @TableName("sys_user") public class SysUser implements UserDetails { @TableId(type = IdType.AUTO) private Long userId; @TableField("login_name") private String username; private String password; private String status; private String delFlag; @TableField(exist = false) private List<SysRole> roles; @Override public Collection<? extends GrantedAuthority> getAuthorities() { 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
| @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); }
@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); }
@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 { LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysUser::getUsername, username); SysUser user = userMapper.selectOne(wrapper); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } List<SysRole> roles = userMapper.selectRolesByUserId(user.getUserId()); roles.forEach(role -> role.setMenus(roleMapper.selectMenusByRoleId(role.getRoleId())) ); user.setRoles(roles); if (!user.isEnabled()) { throw new DisabledException("用户已被禁用"); } return user; } }
|
5.3 改造 JwtAuthFilter
这里我们需要调用 自定义 UserDetailsService 的 loadUserByUsername 方法从数据库获取用户信息,其中 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 { 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) { 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(csrf -> csrf.disable()) .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\": \"无效的认证凭证\"}"); }; } @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;
public AjaxResult(int code, String msg) { this.code = code; this.msg = msg; }
public static AjaxResult<Void> success() { return AjaxResult.success("操作成功"); }
public static <T> AjaxResult<T> success(T data) { return AjaxResult.success("操作成功", data); }
public static AjaxResult<Void> success(String msg) { return AjaxResult.success(msg, null); }
public static <T> AjaxResult<T> success(String msg, T data) { return new AjaxResult<>(200, msg, data); }
public static AjaxResult<Void> error() { return AjaxResult.error("操作失败"); }
public static AjaxResult<Void> error(String msg) { return AjaxResult.error(msg, null); }
public static <T> AjaxResult<T> error(String msg, T data) { return new AjaxResult<>(500, msg, data); }
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 Security 与 JWT 实现无状态认证。从 JWT 的基本原理出发,逐步构建了登录接口、JWT 生成工具、拦截过滤器及安全配置的初步整合方案,最后再结合之前章节 RBAC 角色模型升级,完整实现了动态数据库用户的认证授权等。通过完整的代码样例,相信小伙伴们都可以快速搭建出一个高效、灵活的认证系统。