第 55 章:Spring Security + JWT
学习目标
- 学会 Spring Security 基础
- 掌握 JWT 签发与验证
- 实现一个登录接口
一、Spring Security 干啥
鉴权 + 授权:你是谁 → 你能干什么。
HTTP 请求 → 过滤器链 → 认证(Authentication) → 授权(Authorization) → Controller二、集成
<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 方案见第七章。
@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 加密
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
String hash = passwordEncoder.encode("123456"); // $2a$10$x...
boolean match = passwordEncoder.matches("123456", hash); // trueBCrypt 特性:
- 每次加密结果不同(盐)
- 慢(故意),抵抗暴力破解
- 内置盐,不用单独存
六、数据库用户
@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
<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 签发 / 验证
@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 实际长这样(三段用 . 拼接):
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIiwidXNlcm5hbWUiOiJUb20ifQ.signature_abc123
↑ ↑ ↑
header(算法) payload(数据) signature(签名)- payload 解出来:
{"sub":"1001","username":"Tom","iat":...,"exp":...} - signature 用密钥算的,内容被改 / 签名不对 / token 过期,
parse全都抛异常。
7.3 JWT 过滤器
@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。
@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 方案:
传统 session JWT 登录 → 服务端存 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?
@Component // ← Spring Boot 自动塞进 Web 链
public class JwtFilter extends OncePerRequestFilter {
// 解析 token,塞 SecurityContext
}这样写问题在哪:
- Web 链先跑 → JwtFilter 跑了 → 解析 token → 塞 SecurityContext ✅
- Security 链开始跑 → Security 不会主动调 JwtFilter(它不在 Security 链里)
- Security 看到 SecurityContext 没"自己人填"的认证(默认不信任 Web 链的过滤器塞的)→ 仍判为未登录 → 401 💥
addFilterBefore 干了啥:把 JwtFilter 正式"收编"为 Security 链的一员,Security 知道"这是自己人",会主动调用它,它塞的认证 Security 才信。
一句话总结:@Component 让过滤器在 Web 链跑,但没被 Security"认领";addFilterBefore 是让 Security 主动调它,把它塞的认证信息当"官方认证"用。两件事,缺一不可。
八、登录接口
@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);
}
}九、获取当前用户
public static String getCurrentUsername() {
return (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}⚠️ 坑 2:
SecurityContext默认存ThreadLocal,异步任务里取不到。要传参或在异步里重新认证。
十、本章小结
| 要点 | 关键 |
|---|---|
| 密码 | BCrypt 加密,自动盐 |
| JWT | 签名验证,无状态 |
| 过滤器 | 自定义 JwtFilter,解析后放行 |
| 上下文 | SecurityContextHolder.getContext() |
| 异步 | 重新认证,不能跨线程 |
动手练习
- 实现登录接口,签发 JWT
- 写一个需要登录的接口,带 token 调通
下一章:第 56 章:RBAC 权限模型 →