Spring Boot 3 + Spring Security 6 + JWT:若依安全体系全解析
安全框架是后台系统的地基——配通了可以一个月不管,配错了每个请求都有问题。这篇文章从若依的安全体系出发,拆解 JWT 认证的完整链路:从登录到 Token 签发,从校验到刷新,从注解到权限表达式。
一、若依的安全体系概览
若依 Spring Boot 3 版本使用了以下安全组件栈:
| 组件 | 版本 | 职责 |
|---|---|---|
| Spring Boot | 3.x | 基础框架 |
| Spring Security | 6.x | 安全框架核心 |
| JWT (jjwt) | 0.12.x | Token 签发与校验 |
| Spring Security OAuth2 | — | 已被 Spring Security 6 内置替代 |
1.1 请求的完整穿越路径
HTTP 请求
↓
JwtAuthenticationTokenFilter(过滤器:提取 Token,设置 SecurityContext)
↓
SecurityFilterChain(核心过滤链)
↓
@PreAuthorize 注解拦截(方法级权限校验)
↓
Controller 方法
二、JWT 的工作原理
2.1 JWT 结构
Header.Payload.Signature
| 部分 | 内容 | 示例 |
|---|---|---|
| Header | 算法类型 | {"alg": "HS256", "typ": "JWT"} |
| Payload | 用户数据 + 过期时间 | {"sub": "admin", "exp": 1718000000} |
| Signature | HMAC 签名 | 用密钥对 Header+Payload 签名 |
2.2 若依的 JWT 配置
// TokenConfig.java
@Configuration
public class TokenConfig {
// token 配置,从 application.yml 取值
@Value("${token.header:Authorization}")
private String header;
@Value("${token.secret}")
private String secret;
@Value("${token.expireTime:30}")
private int expireTime;
}
# application.yml
token:
header: Authorization
secret: abcdefghijklmnopqrstuvwxyz # 生产环境务必换掉
expireTime: 30 # 30 分钟
三、登录全流程分解
3.1 登录请求
POST /login
Body: { "username": "admin", "password": "admin123" }
3.2 后端处理链路
第一步:SysLoginController.login() 接收请求,调用 SysLoginService.login()。
第二步:SysLoginService 调用 AuthenticationManager.authenticate(),触发 UserDetailsServiceImpl.loadUserByUsername()。
// UserDetailsServiceImpl.java
@Override
public UserDetails loadUserByUsername(String username) {
SysUser user = userService.selectUserByUserName(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
// 关键:把用户权限信息加载到 SecurityContext 中
Set<String> permissions = permissionService.getMenuPermission(user);
return new LoginUser(
user.getUserId(),
user.getDeptId(),
user,
permissions
);
}
第三步:认证成功后,TokenService.createToken() 生成 JWT:
// TokenService.java
public String createToken(LoginUser loginUser) {
String token = Jwts.builder()
.setSubject(loginUser.getUsername())
.claim("userId", loginUser.getUserId())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expireTime * 60 * 1000L))
.signWith(Keys.hmacShaKeyFor(secret.getBytes()))
.compact();
// 存入 Redis,key = userId,value = loginUser 对象
redisTemplate.opsForValue().set(
getTokenKey(loginUser.getUserId()),
loginUser,
expireTime,
TimeUnit.MINUTES
);
return token;
}
关键设计:Token 本身是无状态的,但若依把用户信息存在 Redis 里,实现了有状态管理 + 无状态 Token 的混合方案。这样 Token 不存敏感信息,但可以随时让某个 Token 失效。
3.3 登录后的存储分布
前端 localStorage:
├── accessToken (JWT 字符串,30 分钟有效)
后端 Redis:
├── login_tokens:{userId} → LoginUser 对象
└── 权限数据存在 LoginUser 中
数据库 sys_menu:
├── perms 字段 → @PreAuthorize 用的权限字符串
四、每次请求的 Token 校验流程
4.1 JwtAuthenticationTokenFilter
这是整个安全体系最核心的一段代码:
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) {
// 1. 从请求头获取 Token
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
token = token.substring(7); // 去掉 "Bearer "
// 2. 解析 JWT,拿到 userId
LoginUser loginUser = tokenService.getLoginUser(request);
if (loginUser == null) {
chain.doFilter(request, response);
return;
}
// 3. 校验 Token 是否过期
tokenService.verifyToken(loginUser);
// 4. 将认证信息放入 SecurityContext
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
loginUser, null, loginUser.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
4.2 从 Redis 获取登录用户
// TokenService.java
public LoginUser getLoginUser(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) return null;
token = token.substring(7);
// 从 JWT 中解析 userId,不需要查数据库
Claims claims = Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
.build()
.parseSignedClaims(token)
.getPayload();
String userId = claims.get("userId", String.class);
// 从 Redis 获取完整的用户信息
return redisTemplate.opsForValue().get(getTokenKey(userId));
}
五、方法级权限控制:@PreAuthorize
5.1 权限表达式的语法
// 字符串匹配:需要有特定权限
@PreAuthorize("@ss.hasPermi('system:user:list')")
// 多种权限任选其一
@PreAuthorize("@ss.hasAnyPermi('system:user:list', 'system:user:query')")
// 角色控制
@PreAuthorize("@ss.hasRole('admin')")
5.2 若依的 PermissionService(@ss)
// PermissionService.java
@Service("ss")
public class PermissionService {
// 验证是否有某个权限
public boolean hasPermi(String permission) {
// 超级管理员直接放行
if (isAdmin()) return true;
// 从 SecurityContextHolder 获取当前用户
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 检查用户权限列表中是否包含该权限字符串
return loginUser.getPermissions().contains(permission);
}
// 判断是否超级管理员
private boolean isAdmin() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return authentication.getAuthorities().stream()
.anyMatch(granted -> granted.getAuthority().equals("ROLE_ADMIN"));
}
}
5.3 SecurityConfig 配置
@Configuration
@EnableMethodSecurity(prePostEnabled = true) // 开启 @PreAuthorize
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 关闭 CSRF(前后端分离,不需要)
.csrf(csrf -> csrf.disable())
// 配置哪些路径不需要认证
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/captchaImage").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)
// 添加 JWT 过滤器
.addFilterBefore(
jwtAuthenticationTokenFilter,
UsernamePasswordAuthenticationFilter.class
)
// 异常处理
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, e) -> {
// 未登录(Token 无效/过期)
response.setStatus(401);
response.getWriter().write("未登录或登录已过期");
})
.accessDeniedHandler((request, response, e) -> {
// 无权限
response.setStatus(403);
response.getWriter().write("无访问权限");
})
);
return http.build();
}
}
六、三个常见的安全问题
6.1 Token 过期后用户无感刷新
这是前端要做的事(Axios 拦截器 + 双 Token 机制,见独立开发笔记 3-4),但后端需要配合提供一个 /refresh 接口:
@PostMapping("/refresh")
public AjaxResult refreshToken(HttpServletRequest request) {
LoginUser loginUser = tokenService.getLoginUser(request);
if (loginUser == null) {
return error("登录已过期,请重新登录");
}
// 生成新 Token,更新 Redis
String newToken = tokenService.createToken(loginUser);
return success(Map.of("token", newToken));
}
6.2 密码加密:BCryptPasswordEncoder
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
BCrypt 三层保护:
-
随机盐——每次加密结果不同
-
多次哈希——默认 10 次迭代
-
自带盐值——盐值嵌入在哈希结果中,不需要单独存
// 注册时加密
String encoded = passwordEncoder.encode("123456");
// 结果:$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
// 登录时校验
boolean match = passwordEncoder.matches("123456", encoded); // true
6.3 防止 Token 被盗用
-
HTTPS 必须——HTTP 传输的 Token 等于明文
-
短有效期 accessToken——30 分钟为佳
-
Redis 黑名单机制——用户退出登录时,把该 Token 加入 Redis 黑名单,Filter 先检查黑名单
七、总结
| 环节 | 技术 | 关键点 |
|---|---|---|
| 认证 | Spring Security 6 | UserDetailsService 加载用户 |
| Token 签发 | JJWT | HMAC SHA-256 签名 |
| Token 存储 | Redis | login_tokens:{userId} 键 |
| 请求校验 | JwtAuthenticationTokenFilter | OncePerRequestFilter |
| 权限控制 | @PreAuthorize | @EnableMethodSecurity 必须开启 |
| 密码加密 | BCrypt | 自带随机盐 |
三个最重要的认知:
-
Token 不存敏感信息,用户数据存在 Redis 里
-
前端权限只是 UI 控制,真正的安全在
@PreAuthorize -
关闭 CSRF 不影响安全,JWT 的 Authorization 头不会自动携带 Cookie
如果你也在独立开发产品,或者对制造业数字化感兴趣,欢迎关注这个公众号。我会持续分享从代码到产品的全过程——包括成功的经验,也包括踩过的坑。
一个人的产品之路,不孤单。👇
原创作者 MqCode(全栈开发者,印刷包装行业 MES+CRM 系统独立开发),欢迎自由转发。
更多推荐



所有评论(0)