Spring Boot 3 中基于 JWT 与 Spring Security 6 的前后端分离认证鉴权实战
前后端分离项目里,认证和鉴权几乎是绕不过去的一环。很多人第一次接触 Spring Security 6 时,最容易卡住的不是“怎么写登录接口”,而是“为什么我明明放行了接口,还是 403”“为什么 JWT 过滤器不生效”“为什么升级到 Spring Boot 3 以后以前的配置全不能用了”。
这篇文章我会从一个中级开发者真正会遇到的问题出发,带你做一套能跑起来的方案:
- 用户登录后签发 JWT
- 前端携带 Token 访问受保护接口
- Spring Security 6 基于 Token 恢复登录态
- 按角色控制接口访问
- 顺手把常见坑和生产实践讲清楚
整篇以 Spring Boot 3 + Spring Security 6 + jjwt 为基础,代码是可运行的,适合作为你项目的起点。
背景与问题
在传统服务端渲染应用里,认证通常依赖 Session。浏览器登录后,服务端把用户状态保存在 Session 中,后续请求靠 Cookie 带回 Session ID。
但到了前后端分离场景,问题就变了:
- 前端可能是 Vue、React、小程序、App,不一定天然依赖 Cookie Session
- 服务端经常要做无状态部署,扩容时不想同步 Session
- 网关、微服务、移动端统一接入时,更适合用可自描述的 Token
这时候,JWT(JSON Web Token)就成了常见选择。
不过,JWT 不是“加上一个依赖就完事”的魔法。实际落地时常见问题很多:
- 登录成功了,但后续请求拿不到用户信息
- 明明前端传了 Authorization 头,后端还是匿名用户
hasRole("ADMIN")一直不生效- Token 过期后返回 500 而不是 401
WebSecurityConfigurerAdapter找不到,老教程根本抄不动
这些问题的根源,基本都在于:没有真正理解 Spring Security 6 的认证链路。
前置知识与环境准备
运行环境
- JDK 17+
- Maven 3.8+
- Spring Boot 3.x
- Spring Security 6.x
本文目标接口
我们要完成这些接口:
POST /api/auth/login:用户名密码登录,返回 JWTGET /api/public/hello:公开接口,无需登录GET /api/user/profile:需要登录GET /api/admin/dashboard:需要ADMIN角色
Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
核心原理
先别急着写代码,先把链路想明白。
1. JWT 在前后端分离中的角色
JWT 本质上是一个字符串,通常由三部分组成:
- Header:声明算法、类型
- Payload:载荷,比如用户名、角色、过期时间
- Signature:签名,用于防篡改
服务端登录成功后签发 JWT,前端后续请求带上:
Authorization: Bearer xxxxx.yyyyy.zzzzz
服务端在过滤器里解析 JWT,如果合法,就把用户信息放进 Spring Security 的上下文中。后续控制器、权限注解、鉴权逻辑都依赖这个上下文。
2. Spring Security 6 的关键变化
以前很多文章写的是继承 WebSecurityConfigurerAdapter。
Spring Security 6 里已经推荐改成 声明式 Bean 配置:
SecurityFilterChainAuthenticationManagerPasswordEncoder- 自定义
OncePerRequestFilter
这一点非常关键。很多升级失败,根本原因就是还在套旧写法。
3. 认证和鉴权不是一回事
这两个概念很容易混:
- 认证 Authentication:你是谁
比如用户名密码登录成功、JWT 被成功解析 - 鉴权 Authorization:你能访问什么
比如只有 ADMIN 才能访问后台管理接口
你可以把它理解成:
认证解决“进门资格”,鉴权解决“进门后能去哪些房间”。
4. 整体请求流程
flowchart TD
A[前端提交用户名密码] --> B[登录接口校验账号密码]
B --> C[服务端生成JWT]
C --> D[前端保存JWT]
D --> E[请求受保护接口时携带Authorization Bearer Token]
E --> F[JWT过滤器解析Token]
F --> G[构建Authentication并放入SecurityContext]
G --> H[Spring Security进行权限判断]
H --> I[返回业务数据或401/403]
5. 认证时序图
sequenceDiagram
participant Client as 前端
participant Auth as /api/auth/login
participant Security as Spring Security
participant JWT as JwtService
Client->>Auth: 提交用户名/密码
Auth->>Security: AuthenticationManager.authenticate(...)
Security-->>Auth: 认证成功
Auth->>JWT: 生成JWT
JWT-->>Auth: token
Auth-->>Client: 返回token
Client->>Security: 带Bearer Token访问受保护接口
Security->>JWT: 解析并校验token
JWT-->>Security: 用户名/角色
Security-->>Client: 放行并返回资源
项目结构建议
为了让代码更清晰,我建议按下面的结构组织:
src/main/java/com/example/jwtsecurity
├── JwtSecurityApplication.java
├── config
│ └── SecurityConfig.java
├── controller
│ ├── AuthController.java
│ ├── PublicController.java
│ ├── UserController.java
│ └── AdminController.java
├── dto
│ ├── LoginRequest.java
│ └── LoginResponse.java
├── security
│ ├── JwtAuthenticationFilter.java
│ ├── JwtService.java
│ └── CustomUserDetailsService.java
└── service
└── DemoUserService.java
实战代码(可运行)
下面是一套精简但完整的实现。
1. 启动类
package com.example.jwtsecurity;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JwtSecurityApplication {
public static void main(String[] args) {
SpringApplication.run(JwtSecurityApplication.class, args);
}
}
2. DTO
LoginRequest
package com.example.jwtsecurity.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginRequest {
@NotBlank
private String username;
@NotBlank
private String password;
}
LoginResponse
package com.example.jwtsecurity.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class LoginResponse {
private String token;
}
3. JWT 服务
这里负责生成和解析 Token。
package com.example.jwtsecurity.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
import java.util.List;
@Service
public class JwtService {
// 这是一个 Base64 编码后的 256-bit 密钥,示例用,生产环境请从配置中心或环境变量读取
private static final String SECRET_KEY = "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=";
private SecretKey getSignKey() {
byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(UserDetails userDetails) {
Instant now = Instant.now();
List<String> roles = userDetails.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.toList();
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("roles", roles)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(60 * 60 * 2)))
.signWith(getSignKey())
.compact();
}
public String extractUsername(String token) {
return parseClaims(token).getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
public boolean isTokenExpired(String token) {
Date expiration = parseClaims(token).getExpiration();
return expiration.before(new Date());
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(getSignKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
}
4. 模拟用户服务
为了方便演示,不接数据库,先用内存用户。
真实项目里你应该从数据库加载用户、角色、状态等信息。
package com.example.jwtsecurity.service;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Set;
@Service
public class DemoUserService {
private final PasswordEncoder passwordEncoder;
public DemoUserService(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
public DemoUser findByUsername(String username) {
Map<String, DemoUser> users = Map.of(
"user", new DemoUser(
1L,
"user",
passwordEncoder.encode("123456"),
Set.of("ROLE_USER")
),
"admin", new DemoUser(
2L,
"admin",
passwordEncoder.encode("123456"),
Set.of("ROLE_ADMIN", "ROLE_USER")
)
);
return users.get(username);
}
public record DemoUser(Long id, String username, String password, Set<String> roles) {}
}
这里我特意保留了
ROLE_前缀,因为 Spring Security 的hasRole("ADMIN")底层会匹配ROLE_ADMIN。这个坑真的太常见了。
5. 自定义 UserDetailsService
package com.example.jwtsecurity.security;
import com.example.jwtsecurity.service.DemoUserService;
import com.example.jwtsecurity.service.DemoUserService.DemoUser;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final DemoUserService demoUserService;
public CustomUserDetailsService(DemoUserService demoUserService) {
this.demoUserService = demoUserService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
DemoUser user = demoUserService.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
return User.builder()
.username(user.username())
.password(user.password())
.authorities(user.roles().stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet()))
.build();
}
}
6. JWT 过滤器
这是整套方案的核心之一。它会在每次请求时检查 Header 中的 Token。
package com.example.jwtsecurity.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final CustomUserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, CustomUserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
String username;
try {
username = jwtService.extractUsername(token);
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
filterChain.doFilter(request, response);
}
}
7. Security 配置
Spring Security 6 推荐用 Bean 方式配置。
package com.example.jwtsecurity.config;
import com.example.jwtsecurity.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
注意:这里开启了
STATELESS,表示不依赖 Session 保存认证状态,完全符合 JWT 场景。
8. 登录接口
package com.example.jwtsecurity.controller;
import com.example.jwtsecurity.dto.LoginRequest;
import com.example.jwtsecurity.dto.LoginResponse;
import com.example.jwtsecurity.security.JwtService;
import jakarta.validation.Valid;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
public AuthController(AuthenticationManager authenticationManager, JwtService jwtService) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
}
@PostMapping("/login")
public LoginResponse login(@Valid @RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
String token = jwtService.generateToken((org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal());
return new LoginResponse(token);
}
}
9. 测试接口
公开接口
package com.example.jwtsecurity.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PublicController {
@GetMapping("/api/public/hello")
public String hello() {
return "public hello";
}
}
用户接口
package com.example.jwtsecurity.controller;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@GetMapping("/api/user/profile")
public String profile(Authentication authentication) {
return "当前登录用户: " + authentication.getName();
}
}
管理员接口
package com.example.jwtsecurity.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AdminController {
@GetMapping("/api/admin/dashboard")
public String dashboard() {
return "admin dashboard";
}
}
逐步验证清单
建议你边写边测,不要等全写完一起跑。认证链路出错时,分段验证最省时间。
第一步:访问公开接口
curl http://localhost:8080/api/public/hello
预期结果:
public hello
第二步:直接访问受保护接口
curl http://localhost:8080/api/user/profile
预期结果:
返回 401 Unauthorized。
第三步:登录获取 Token
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"123456"}'
预期结果:
{"token":"eyJhbGciOiJIUzI1NiJ9..."}
第四步:携带 Token 访问用户接口
curl http://localhost:8080/api/user/profile \
-H "Authorization: Bearer 这里替换成你的token"
预期结果:
当前登录用户: user
第五步:普通用户访问管理员接口
curl http://localhost:8080/api/admin/dashboard \
-H "Authorization: Bearer 这里替换成user的token"
预期结果:
返回 403 Forbidden。
第六步:管理员登录并访问管理员接口
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
然后:
curl http://localhost:8080/api/admin/dashboard \
-H "Authorization: Bearer 这里替换成admin的token"
预期结果:
admin dashboard
权限模型再看一眼
如果你对“角色”和“过滤器”之间的关系还不够直观,可以看下面这张图:
classDiagram
class JwtAuthenticationFilter {
+doFilterInternal()
}
class JwtService {
+generateToken(UserDetails)
+extractUsername(String)
+isTokenValid(String, UserDetails)
}
class SecurityContextHolder {
+getContext()
+setAuthentication()
}
class AuthenticationManager {
+authenticate(Authentication)
}
class UserDetailsService {
<<interface>>
+loadUserByUsername(String)
}
JwtAuthenticationFilter --> JwtService
JwtAuthenticationFilter --> UserDetailsService
JwtAuthenticationFilter --> SecurityContextHolder
AuthController --> AuthenticationManager
常见坑与排查
这一节我会集中讲一些特别高频的问题。很多时候不是代码不会写,而是定位思路不对。
1. 已经放行了登录接口,还是 403
常见原因
- CSRF 没关,而你用的是前后端分离 JSON 请求
- 请求路径写错,比如实际接口是
/api/auth/login,放行写成了/auth/login - 前端发的是
OPTIONS预检请求,但你没处理跨域
排查建议
先看配置里是否有:
.requestMatchers("/api/auth/login", "/api/public/**").permitAll()
再确认:
.csrf(csrf -> csrf.disable())
如果涉及跨域,还要补 cors() 和对应的 CorsConfigurationSource。
2. 带了 Token,还是拿不到登录用户
常见原因
- Header 不是
Authorization - 前缀不是
Bearer - 过滤器没加入链路
- Token 解析异常被你悄悄吞掉了
SecurityContextHolder没有成功设置认证对象
排查建议
重点看这一行有没有:
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
然后在过滤器里临时打印:
System.out.println("Authorization=" + request.getHeader("Authorization"));
以及打印 username、SecurityContextHolder.getContext().getAuthentication()。
我自己排过几次这种问题,最后发现有时不是后端问题,而是前端把 Token 存了,但请求拦截器没带上。
3. hasRole("ADMIN") 不生效
原因
你给的权限是 ADMIN,但 Spring Security 的 hasRole("ADMIN") 会自动补前缀,实际比的是 ROLE_ADMIN。
正确做法
角色建议统一存成:
ROLE_USER
ROLE_ADMIN
如果你不想带 ROLE_ 前缀,也可以改成用 hasAuthority("ADMIN"),但团队里一定要统一。
4. 登录总是失败,明明密码对了
常见原因
- 密码没有用同一个
PasswordEncoder - 数据库存的是明文,但配置用了
BCryptPasswordEncoder - 每次启动都重新 encode,导致结果不同
说明
像 BCrypt 这种算法,同一个明文多次加密结果本来就不同。
所以不要拿“加密后的字符串是否相等”来判断,应该交给 PasswordEncoder.matches()。
5. 返回 401 和 403 分不清
这是非常实际的问题:
- 401 Unauthorized:你还没通过认证,或者 Token 无效/过期
- 403 Forbidden:你已经登录了,但权限不够
如果一个普通用户访问管理员接口,应该是 403,不是 401。
6. JWT 过期后直接 500
原因
你在过滤器或解析代码里没有正确处理异常,比如:
ExpiredJwtExceptionJwtException- 其他运行时异常
建议
最简单的做法是先在过滤器里捕获异常并放行给后续统一处理;更完善的做法是自定义:
AuthenticationEntryPoint处理 401AccessDeniedHandler处理 403
安全/性能最佳实践
JWT 很方便,但不能因为方便就“能跑就行”。下面这些建议,我认为是从 demo 到生产最应该补上的部分。
1. 密钥不要硬编码
本文为了演示,把密钥写在代码里。生产环境必须改成:
- 环境变量
- 配置中心
- KMS / Secret Manager
例如:
jwt:
secret: ${JWT_SECRET}
expiration: 7200
然后用 @ConfigurationProperties 读取。
2. 不要在 JWT 里放敏感信息
JWT 只是可签名,不是默认加密。
不要把这些信息直接放进去:
- 用户密码
- 手机号
- 身份证号
- 银行卡号
- 大量业务数据
通常放这些就够了:
- 用户 ID
- 用户名
- 角色
- 过期时间
- 签发时间
3. Token 过期时间不要太长
过期时间太长,泄露风险会被放大。
常见建议:
- Access Token:15 分钟到 2 小时
- Refresh Token:几天到几周
如果你对安全要求更高,推荐采用:
- 短期 Access Token
- 长期 Refresh Token
- 服务端维护 Refresh Token 状态
本文为了聚焦主线,没有展开 Refresh Token,但生产项目很值得加。
4. 对注销和踢下线要有预案
JWT 的典型问题是:一旦签发,在过期前通常都有效。
这会带来两个现实需求:
- 用户主动注销后,旧 Token 怎么失效?
- 管理员踢用户下线怎么做?
常见方案有:
- 黑名单机制
把已作废 Token 的唯一标识放到 Redis 中 - 版本号机制
用户表维护tokenVersion,JWT 中携带版本,校验时比对 - 缩短 Token 生命周期
结合 Refresh Token 降低风险窗口
如果系统安全要求一般,第三种成本最低。
5. 角色和权限尽量解耦
很多小项目一开始只有 ADMIN 和 USER,直接写死角色判断没问题。
但如果你的权限逐渐复杂,建议从早期就区分:
- 角色 Role:岗位/身份
- 权限 Permission:具体操作点
例如:
ROLE_ADMINsys:user:listsys:user:create
这样后期做菜单权限、按钮权限时不至于全推翻。
6. 给异常返回统一 JSON
前后端分离最怕后端丢回一个默认 HTML 错误页。
所以建议自定义 401/403 返回格式,例如:
{
"code": 401,
"message": "未登录或token已失效"
}
以及:
{
"code": 403,
"message": "没有访问该资源的权限"
}
这样前端才能稳定地做跳转、提示和刷新 Token。
7. 过滤器里尽量只做认证,不做重业务逻辑
JWT 过滤器的职责应该尽量单一:
- 取 Token
- 解析 Token
- 恢复 Authentication
不要在里面塞太多业务判断,比如复杂菜单查询、审计写库、用户状态联表计算。
否则每个请求都会变重,性能和可维护性都会下降。
8. 为接口链路加日志,但别打出完整 Token
日志里建议打印:
- 请求路径
- 用户名
- 认证结果
- 异常类型
不建议打印完整 JWT。实在需要排查,最多打印前几位和后几位。
错误示例:
token=eyJhbGciOiJIUzI1NiJ9......
更好的方式:
tokenPrefix=eyJhbGciOi... , username=admin
可继续扩展的方向
如果你准备把本文方案用于正式项目,下一步通常会演进成下面这些能力:
- 接数据库用户表、角色表、权限表
- 增加 Refresh Token
- 接 Redis 黑名单
- 加统一异常处理
- 支持多端登录策略
- 支持方法级权限控制,如
@PreAuthorize
例如方法级鉴权可以这样写:
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/admin/stats")
public String stats() {
return "stats";
}
一套更接近生产的排查顺序
最后给你一个我自己常用的排查顺序,出问题时很省事:
- 先看请求有没有进接口
- 再看过滤器有没有执行
- 再看 Authorization 头是否正确
- 再看 JWT 是否解析成功
- 再看 SecurityContext 是否放入 Authentication
- 最后看权限表达式是否匹配
很多同学一上来就怀疑 Spring Security 配置,其实最常见的问题是:
- 前端没带 Token
- Token 前缀少空格
- 角色名没加
ROLE_
这几个问题,真的占了大多数。
总结
Spring Boot 3 + Spring Security 6 下做 JWT 认证鉴权,核心其实就三件事:
- 登录时校验用户名密码,并签发 JWT
- 请求时通过过滤器解析 JWT,恢复用户认证信息
- 通过 Spring Security 的权限规则完成接口鉴权
你可以把整套方案记成一句话:
登录接口负责“发证”,JWT 过滤器负责“验票”,Spring Security 负责“决定能去哪”。
如果你是第一次从旧版 Spring Security 升级过来,最重要的心智切换有两个:
- 不再依赖
WebSecurityConfigurerAdapter - 不再依赖 Session 保存登录态,而是显式通过过滤器恢复认证上下文
本文这套代码适合作为入门到项目落地之间的桥梁版本。
如果你的系统已经进入生产阶段,我建议至少再补三项:
- 统一 401/403 JSON 返回
- Refresh Token 机制
- 密钥与 Token 作废策略
做到这一步,你的前后端分离认证体系就不只是“能跑”,而是“基本能上场”。
如果你准备直接实战,建议先按本文代码完整跑通,再逐步替换为数据库用户、Redis、权限表,而不是一开始就把所有复杂度揉在一起。这样成功率最高。