Skip to content
第 55 章 后端 ⏱ 12 分钟阅读

第 55 章:Spring Security + JWT ​

学习目标 ​

  • 学会 Spring Security 基础
  • 掌握 JWT 签发与验证
  • 实现一个登录接口

一、Spring Security 干啥 ​

鉴权 + 授权:你是谁 → 你能干什么。

HTTP 请求 → 过滤器链 → 认证(Authentication) → 授权(Authorization) → Controller

二、集成 ​

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

加上之后所有接口都要求登录,自动生成密码(看启动日志)。

三、核心概念 ​

概念含义
SecurityContextHolder存当前用户上下文
Authentication认证信息(用户、权限)
UserDetailsService加载用户
PasswordEncoder密码加密
Filter过滤器

四、内存用户(简单场景) ​

💡 这段代码的作用:在内存里手写一条合法账号 admin / 123456,Spring Security 登录时会查这张纸条——账号密码对上就放行,对不上就拒绝。没有 token、没有 JWT,登录后浏览器自动带账号密码(默认 HTTP Basic 方式),JWT 方案见第七章。

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public UserDetailsService users() {                                  // 告诉 Spring:"查账号来这里"
        UserDetails user = User.builder()
            .username("admin")
            .password("{noop}123456")                                     // {noop} = 明文(不加密,演示用)
            .roles("ADMIN")
            .build();
        return new InMemoryUserDetailsManager(user);                      // 把纸条塞内存(重启就没了)
    }
}

⚠️ 坑 1:生产必须用 BCrypt,不要 {noop}。

五、BCrypt 加密 ​

java
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

String hash = passwordEncoder.encode("123456");   // $2a$10$x...
boolean match = passwordEncoder.matches("123456", hash);  // true

BCrypt 特性:

  • 每次加密结果不同(盐)
  • 慢(故意),抵抗暴力破解
  • 内置盐,不用单独存

六、数据库用户 ​

java
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userMapper.selectByUsername(username);              // ① 用用户名查用户
        if (user == null) throw new UsernameNotFoundException("用户不存在");

        // ② 查这个用户的权限列表(角色 + 操作权限),DB 里是字符串数组
        //    例:["USER", "ROLE_ADMIN", "order:create", "order:cancel"]
        List<String> authorities = userMapper.selectAuthorities(user.getId());

        return org.springframework.security.core.userdetails.User.builder()   // ③ 包成 Spring Security 的 User 对象
            .username(user.getUsername())
            .password(user.getPassword())                              //    密码(DB 里存的是 BCrypt 加密后的)
            .authorities(authorities.stream()                          // ④ 把字符串列表包一层
                    .map(SimpleGrantedAuthority::new)                 //    "USER" → SimpleGrantedAuthority("USER")
                    .toList())                                        //    Spring Security 只认这个类型,不认字符串
            .disabled(user.getStatus() == 0)                           // ⑤ status=0 → 禁用,登录直接拒
            .build();
    }
}

💡 为什么 authorities 要包一层? Spring Security 内部判断权限都用 SimpleGrantedAuthority 对象,字符串它不认。包完之后,@PreAuthorize("hasRole('ADMIN')") 这种注解才能识别。

💡 用全限定名 org.springframework.security.core.userdetails.User 是为了避开你自己项目的 User 实体(数据库对应),两个同名类要分清。

七、JWT 登录流程 ​

1. 用户登录 → 验证密码 → 签发 JWT → 返回前端
2. 前端存 localStorage / Cookie
3. 每次请求带 Authorization: Bearer <token>
4. 后端过滤器验证 token,认证通过放行

7.1 引入 jjwt ​

xml
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
</dependency>

7.2 签发 / 验证 ​

java
@Service
public class JwtService {

    // ① 密钥:签发和校验 token 的"私钥",≥32 字节(HS256 要求),生产从配置文件读,绝对不要硬编码
    private final SecretKey key = Keys.hmacShaKeyFor("my-secret-key-must-be-32-bytes-long!".getBytes());

    public String create(Long userId, String username) {
        return Jwts.builder()                                           // ② 拿到 JWT 构建器
            .subject(String.valueOf(userId))                           // ③ sub 字段:主标识(一般放 userId)
            .claim("username", username)                               // ④ 自定义字段:塞业务数据(任意 key/value)
            .issuedAt(new Date())                                      // ⑤ iat 字段:签发时间
            .expiration(new Date(                                      // ⑥ exp 字段:过期时间
                System.currentTimeMillis() + 3600 * 1000))             //    当前时间 + 1 小时
            .signWith(key)                                             // ⑦ 用密钥签名(防伪造)
            .compact();                                                // ⑧ 生成最终 token 字符串
    }

    public Claims parse(String token) {
        return Jwts.parser()                                           // ⑨ 拿到 JWT 解析器
            .verifyWith(key)                                           // ⑩ 用同一密钥验签(签名不对直接抛)
            .build()
            .parseSignedClaims(token)                                  // ⑪ 解析 + 验签 + 检查过期(过期也抛)
            .getPayload();                                             // ⑫ 返回 token 里的业务数据
    }
}

token 实际长这样(三段用 . 拼接):

java
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIiwidXNlcm5hbWUiOiJUb20ifQ.signature_abc123
   ↑                     ↑                                          ↑
header(算法)          payload(数据)                            signature(签名)
  • payload 解出来:{"sub":"1001","username":"Tom","iat":...,"exp":...}
  • signature 用密钥算的,内容被改 / 签名不对 / token 过期,parse 全都抛异常。

7.3 JWT 过滤器 ​

java
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtService jwtService;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) {
        String token = req.getHeader("Authorization");
        if (token != null && token.startsWith("Bearer ")) {
            token = token.substring(7);
            try {
                Claims claims = jwtService.parse(token);
                String userId = claims.getSubject();
                String username = claims.get("username", String.class);

                // 构造认证信息,放进 SecurityContext
                //   - parse(token) 已经验签 + 检查过期了,token 合法就是合法用户
                //   - new UsernamePasswordAuthenticationToken(谁, 已认证, 权限列表)
                //       ① username: 从 token 的 sub 读出来的用户
                //       ② null:      "已认证"标记(本来是密码,认证完置 null 表示不再需要密码)
                //       ③ List.of(): 权限列表(简化版空;真实项目从 DB 查角色塞进来)
                //   - setAuthentication: 把"已认证"信息塞进 Spring Security 上下文,后续检查就放行
                UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(username, null, List.of());
                SecurityContextHolder.getContext().setAuthentication(auth);

            } catch (JwtException e) {
                log.warn("JWT 解析失败: {}", e.getMessage());
                // 不抛,让下游 handler 处理
            }
        }
        chain.doFilter(req, resp);
    }
}

7.4 配置 ​

💡 这段配置干啥? JwtFilter 写完不会自己跑,得手动挂进 Spring Security 的过滤器链。整段核心就是最后那行 addFilterBefore。

java
@Configuration
@EnableWebSecurity                                          // ① 开启 Spring Security
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtFilter jwtFilter;                       // ② 注入上节写的过滤器

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(c -> c.disable())                          // ③ JWT 前后端分离,不需要 CSRF 防御
            .sessionManagement(s -> s
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))   // ④ 不创建 session(JWT 自身已无状态)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/login", "/api/auth/register")
                    .permitAll()                             // ⑤ 登录/注册放行
                .anyRequest().authenticated()                // ⑥ 其他接口全都要认证(token)
            )
            .addFilterBefore(jwtFilter,
                UsernamePasswordAuthenticationFilter.class) // ⑦ 关键!把 JwtFilter 挂在自带认证过滤器之前
            .build();
    }
}

为什么 ④ 要 STATELESS——对比传统 session 方案:

传统 sessionJWT
登录 → 服务端存 session登录 → 服务端只签 token
浏览器带 cookie前端带 token
服务端查内存里的 session(有状态)服务端解析 token 自己验(无状态)

JWT 方案服务端不存任何登录态,所以告诉 Spring Security 别创建 session,免得既浪费内存又和 JWT 冲突(双重身份验证容易乱)。

为啥 ⑦ 要插在前面:

请求进来
  ↓
JwtFilter(我们写的)              ← 解析 token + setAuthentication(认证信息)
  ↓
UsernamePasswordAuthenticationFilter(Spring Security 自带)
  ↓
Spring Security 看到 SecurityContext 已有认证 → 直接放行

顺序反了的后果:Spring Security 自带的过滤器先跑,发现没认证直接 401,根本到不了 JwtFilter。所以必须让 JwtFilter 先执行,把认证信息准备好。

7.5 既然 @Component 就能跑,为啥还手动 addFilterBefore? ​

💡 看着重复,实际是两套链——不要被这两个机制搞混。

两套过滤器链的层次:

浏览器请求
  ↓
[Web 过滤器链]           ← Spring Boot 自动注册,@Component 的 OncePerRequestFilter 落在这里
  ↓
[Security 过滤器链]      ← Spring Security 自己维护,addFilterBefore 落在这里
  ↓
DispatcherServlet → Controller
层面怎么注册谁管
Web 链Spring Boot 自动(@Component 即可)Web 容器
Security 链addFilterBefore / addFilterAfter 手动塞Spring Security

两者独立、不互通——自动注册只会把过滤器塞进 Web 链,不会自动进 Security 链。

为啥必须 addFilterBefore?

java
@Component   // ← Spring Boot 自动塞进 Web 链
public class JwtFilter extends OncePerRequestFilter {
    // 解析 token,塞 SecurityContext
}

这样写问题在哪:

  1. Web 链先跑 → JwtFilter 跑了 → 解析 token → 塞 SecurityContext ✅
  2. Security 链开始跑 → Security 不会主动调 JwtFilter(它不在 Security 链里)
  3. Security 看到 SecurityContext 没"自己人填"的认证(默认不信任 Web 链的过滤器塞的)→ 仍判为未登录 → 401 💥

addFilterBefore 干了啥:把 JwtFilter 正式"收编"为 Security 链的一员,Security 知道"这是自己人",会主动调用它,它塞的认证 Security 才信。

一句话总结:@Component 让过滤器在 Web 链跑,但没被 Security"认领";addFilterBefore 是让 Security 主动调它,把它塞的认证信息当"官方认证"用。两件事,缺一不可。

八、登录接口 ​

java
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final UserDetailsServiceImpl userDetailsService;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;

    @PostMapping("/login")
    public Result<String> login(@RequestBody LoginDTO dto) {
        UserDetails user = userDetailsService.loadUserByUsername(dto.getUsername());
        if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
            throw new BusinessException(ErrorCode.USER_PASSWORD_ERROR);
        }
        String token = jwtService.create(123L, user.getUsername());
        return Result.ok(token);
    }
}

九、获取当前用户 ​

java
public static String getCurrentUsername() {
    return (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}

⚠️ 坑 2:SecurityContext 默认存 ThreadLocal,异步任务里取不到。要传参或在异步里重新认证。

十、本章小结 ​

要点关键
密码BCrypt 加密,自动盐
JWT签名验证,无状态
过滤器自定义 JwtFilter,解析后放行
上下文SecurityContextHolder.getContext()
异步重新认证,不能跨线程

动手练习 ​

  1. 实现登录接口,签发 JWT
  2. 写一个需要登录的接口,带 token 调通

下一章:第 56 章:RBAC 权限模型 →

本站基于 VitePress 构建 · 由 StackHub 团队维护