安全框架是后台系统的地基——配通了可以一个月不管,配错了每个请求都有问题。这篇文章从若依的安全体系出发,拆解 JWT 认证的完整链路:从登录到 Token 签发,从校验到刷新,从注解到权限表达式。


一、若依的安全体系概览

若依 Spring Boot 3 版本使用了以下安全组件栈:

组件版本职责
Spring Boot3.x基础框架
Spring Security6.x安全框架核心
JWT (jjwt)0.12.xToken 签发与校验
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}
SignatureHMAC 签名用密钥对 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 三层保护:

  1. 随机盐——每次加密结果不同

  2. 多次哈希——默认 10 次迭代

  3. 自带盐值——盐值嵌入在哈希结果中,不需要单独存

// 注册时加密
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 6UserDetailsService 加载用户
Token 签发JJWTHMAC SHA-256 签名
Token 存储Redislogin_tokens:{userId}
请求校验JwtAuthenticationTokenFilterOncePerRequestFilter
权限控制@PreAuthorize@EnableMethodSecurity 必须开启
密码加密BCrypt自带随机盐

三个最重要的认知:

  1. Token 不存敏感信息,用户数据存在 Redis 里

  2. 前端权限只是 UI 控制,真正的安全在 @PreAuthorize

  3. 关闭 CSRF 不影响安全,JWT 的 Authorization 头不会自动携带 Cookie


如果你也在独立开发产品,或者对制造业数字化感兴趣,欢迎关注这个公众号。我会持续分享从代码到产品的全过程——包括成功的经验,也包括踩过的坑。

一个人的产品之路,不孤单。👇

原创作者 MqCode(全栈开发者,印刷包装行业 MES+CRM 系统独立开发),欢迎自由转发。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐