Spring Boot 3 中实现基于 JWT 与 Spring Security 6 的统一认证授权实战
很多团队在升级到 Spring Boot 3 之后,安全这一块最容易“卡壳”。原因不复杂:Spring Security 6 的配置方式变了,以前那套 WebSecurityConfigurerAdapter 已经退出历史舞台;而 JWT 方案虽然常见,但一旦要做“统一认证授权”,就不只是登录发个 token 那么简单,还会牵扯到:
- 登录接口怎么放行
- token 怎么解析、校验、续期
- 角色和权限怎么映射到接口
- 认证失败和授权失败怎么统一返回
- 无状态场景下,怎么保证系统足够安全、性能也不差
这篇文章我会带你从一个能跑起来的 Spring Boot 3 + Spring Security 6 + JWT 项目出发,把整个链路串起来。文章重点不是堆概念,而是尽量用“实战走一遍”的方式,让你知道每一层为什么这么写。
背景与问题
在传统 Session 模式下,后端通常会把用户登录态保存在服务器内存或 Redis 中。它的优点是简单直观,但在微服务、前后端分离和多实例部署下,问题会越来越明显:
-
服务端要保存状态
- 横向扩容时要考虑 Session 共享
- 网关层、鉴权层都要配合
-
前后端分离场景不够自然
- Web 页面还能靠 Cookie
- App、小程序、第三方调用往往更适合 Bearer Token
-
权限控制容易碎片化
- 登录是登录,接口鉴权是接口鉴权
- 错误返回格式也经常不统一
JWT 的价值就在这里:
认证信息随 token 一起传递,服务端按签名校验,不依赖 Session。
但我要先提醒一句:JWT 不是银弹。如果你需要强制下线、即时撤销、复杂会话管理,单纯无状态 JWT 也会遇到边界。这篇文章先聚焦于一个中型项目里非常常见的方案:登录签发 JWT,后续请求基于 Spring Security 做统一认证与授权。
前置知识与环境准备
技术栈
- JDK 17
- Spring Boot 3.x
- Spring Security 6.x
- Maven
- jjwt 0.11.5
本文实现的目标
我们要做出下面这条链路:
POST /api/auth/login:用户名密码登录,返回 JWT- 访问受保护接口时,前端在请求头带上:
Authorization: Bearer xxxxx.yyyyy.zzzzz
- Spring Security 自动完成:
- 解析 JWT
- 验证签名和过期时间
- 从 token 中提取用户名、角色、权限
- 放入当前安全上下文
- 业务接口按角色/权限控制访问
- 未登录、token 无效、权限不足时,返回统一 JSON
核心原理
如果把整个过程讲得最直白一点,可以理解为:
- 用户用用户名密码请求登录接口
- 服务端校验成功后,生成一个签名过的 JWT
- 客户端保存这个 JWT
- 后续每次请求都带上 JWT
- 服务端通过过滤器解析 JWT,恢复出用户身份
- Spring Security 根据用户权限决定是否允许访问接口
整体流程图
flowchart TD
A[客户端登录<br/>POST /api/auth/login] --> B[认证控制器]
B --> C[AuthenticationManager 校验用户名密码]
C -->|成功| D[JWT 工具生成 Token]
D --> E[返回 accessToken]
F[客户端访问业务接口<br/>携带 Authorization Bearer Token] --> G[JWT 认证过滤器]
G --> H{Token 是否合法}
H -- 否 --> I[返回 401 未认证]
H -- 是 --> J[构造 Authentication]
J --> K[放入 SecurityContext]
K --> L[Spring Security 授权判断]
L -->|通过| M[进入 Controller]
L -->|拒绝| N[返回 403 无权限]
Spring Security 6 的几个关键变化
升级到 Spring Security 6 后,很多人第一反应是“以前配置怎么不能用了”。核心变化主要有两点:
1)不再推荐继承 WebSecurityConfigurerAdapter
现在的主流写法是直接声明 SecurityFilterChain Bean:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http.build();
}
这比以前更清晰:
你不是去“继承一个大类再重写”,而是在配置一个明确的过滤器链。
2)授权写法变为 requestMatchers
比如:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
这套 DSL 更贴近“按 URL 规则声明权限”。
JWT 在这里到底承载了什么
通常我们会把这些信息放入 JWT:
sub:用户标识,一般是用户名roles:角色列表permissions:权限列表iat:签发时间exp:过期时间
注意一点:
JWT 里放的是“足够用于鉴权的信息”,不是把整个用户对象全塞进去。
我见过有人把手机号、邮箱、部门、甚至头像 URL 全放进 token,结果 token 体积越来越大,请求头也越来越臃肿,没必要。
认证与授权的边界
这两个词很容易混:
- 认证 Authentication:你是谁?
- 授权 Authorization:你能干什么?
在本文方案中:
- 登录时做的是认证
- 请求带 JWT 访问接口时,先恢复认证身份
- 再由 Spring Security 判断角色/权限是否满足要求
类关系与职责图
classDiagram
class AuthController {
+login(LoginRequest)
}
class JwtAuthenticationFilter {
+doFilterInternal(...)
}
class JwtTokenProvider {
+generateToken(UserDetails)
+parseClaims(String)
+validateToken(String)
}
class CustomUserDetailsService {
+loadUserByUsername(String)
}
class SecurityConfig {
+securityFilterChain(HttpSecurity)
+authenticationManager(...)
+passwordEncoder()
}
class UserPrincipal {
+getAuthorities()
+getUsername()
+getPassword()
}
AuthController --> JwtTokenProvider
AuthController --> SecurityConfig
JwtAuthenticationFilter --> JwtTokenProvider
JwtAuthenticationFilter --> CustomUserDetailsService
CustomUserDetailsService --> UserPrincipal
SecurityConfig --> JwtAuthenticationFilter
实战代码(可运行)
下面给出一个最小可运行示例。为了聚焦 JWT 和 Security,本例使用内存用户,不接数据库。等你跑通之后,再把 UserDetailsService 换成查库版本就行。
1. Maven 依赖
<!-- pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>jwt-security-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>jwt-security-demo</name>
<properties>
<java.version>17</java.version>
<spring-boot.version>3.3.0</spring-boot.version>
<jjwt.version>0.11.5</jjwt.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<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>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
2. 配置文件
# src/main/resources/application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expiration: 3600000
这里的 secret 只是示例,生产环境必须使用更长、更随机的密钥,并通过环境变量或密钥管理服务注入。
3. 启动类
// src/main/java/com/example/demo/JwtSecurityDemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JwtSecurityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(JwtSecurityDemoApplication.class, args);
}
}
4. 请求与响应模型
// src/main/java/com/example/demo/model/LoginRequest.java
package com.example.demo.model;
import jakarta.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank
private String username;
@NotBlank
private String password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
// src/main/java/com/example/demo/model/LoginResponse.java
package com.example.demo.model;
public class LoginResponse {
private String tokenType = "Bearer";
private String accessToken;
public LoginResponse(String accessToken) {
this.accessToken = accessToken;
}
public String getTokenType() {
return tokenType;
}
public String getAccessToken() {
return accessToken;
}
}
// src/main/java/com/example/demo/model/ApiResponse.java
package com.example.demo.model;
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public ApiResponse() {
}
public ApiResponse(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(200, "success", data);
}
public static <T> ApiResponse<T> fail(int code, String message) {
return new ApiResponse<>(code, message, null);
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
public T getData() {
return data;
}
}
5. 自定义用户服务
这里先用内存方式模拟两个用户:
admin / 123456user / 123456
其中:
admin拥有ROLE_ADMIN、ROLE_USER和sys:user:listuser只有ROLE_USER
// src/main/java/com/example/demo/security/CustomUserDetailsService.java
package com.example.demo.security;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final PasswordEncoder passwordEncoder;
public CustomUserDetailsService(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if ("admin".equals(username)) {
return User.builder()
.username("admin")
.password(passwordEncoder.encode("123456"))
.authorities(List.of(
new SimpleGrantedAuthority("ROLE_ADMIN"),
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("sys:user:list")
))
.build();
}
if ("user".equals(username)) {
return User.builder()
.username("user")
.password(passwordEncoder.encode("123456"))
.authorities(List.of(
new SimpleGrantedAuthority("ROLE_USER")
))
.build();
}
throw new UsernameNotFoundException("用户不存在");
}
}
这里为了演示简单,每次查询都动态 encode 密码。
真正接数据库时,你应该取数据库中已加密的密码,而不是每次重新生成。
6. JWT 工具类
// src/main/java/com/example/demo/security/JwtTokenProvider.java
package com.example.demo.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long expiration;
public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expiration = expiration;
}
public String generateToken(UserDetails userDetails) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expiration);
List<String> authorities = userDetails.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
return Jwts.builder()
.setSubject(userDetails.getUsername())
.claim("authorities", authorities)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (ExpiredJwtException e) {
return false;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public String getUsername(String token) {
return parseClaims(token).getSubject();
}
@SuppressWarnings("unchecked")
public List<String> getAuthorities(String token) {
Object authorities = parseClaims(token).get("authorities");
if (authorities instanceof List<?>) {
return ((List<?>) authorities).stream()
.map(String::valueOf)
.toList();
}
return Collections.emptyList();
}
}
7. JWT 认证过滤器
这个过滤器是核心之一。它做的事情是:
- 从请求头拿到 Bearer Token
- 校验 token
- 读取用户和权限
- 创建
Authentication - 放到
SecurityContextHolder
// src/main/java/com/example/demo/security/JwtAuthenticationFilter.java
package com.example.demo.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.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
String username = jwtTokenProvider.getUsername(token);
List<SimpleGrantedAuthority> authorities = jwtTokenProvider.getAuthorities(token)
.stream()
.map(SimpleGrantedAuthority::new)
.toList();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
这里我直接从 token 恢复用户信息,没有再次查数据库。
这是 JWT 的常见无状态做法,性能上很轻。
但如果你有“用户被禁用后要立刻失效”的需求,就需要配合 Redis 黑名单或每次查库校验状态。
8. 认证失败与授权失败统一返回
很多项目最开始都忽略这一层,结果就是:
- 未登录时返回一个默认 HTML 页面
- 权限不足时返回另一种结构
- 前端还得写很多兼容代码
我们把它统一掉。
// src/main/java/com/example/demo/security/RestAuthenticationEntryPoint.java
package com.example.demo.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.example.demo.model.ApiResponse;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.*;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(objectMapper.writeValueAsString(
ApiResponse.fail(401, "未认证或Token无效")
));
}
}
// src/main/java/com/example/demo/security/RestAccessDeniedHandler.java
package com.example.demo.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.example.demo.model.ApiResponse;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.*;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(objectMapper.writeValueAsString(
ApiResponse.fail(403, "无权限访问")
));
}
}
9. Security 配置
这是 Spring Security 6 的关键配置。
// src/main/java/com/example/demo/config/SecurityConfig.java
package com.example.demo.config;
import com.example.demo.security.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.*;
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.*;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
private final RestAccessDeniedHandler restAccessDeniedHandler;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
RestAuthenticationEntryPoint restAuthenticationEntryPoint,
RestAccessDeniedHandler restAccessDeniedHandler) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.restAuthenticationEntryPoint = restAuthenticationEntryPoint;
this.restAccessDeniedHandler = restAccessDeniedHandler;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(restAuthenticationEntryPoint)
.accessDeniedHandler(restAccessDeniedHandler)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/error").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
这里几项很关键:
csrf.disable():前后端分离、JWT 无状态场景通常关闭SessionCreationPolicy.STATELESS:明确告诉 Spring Security 不用 SessionaddFilterBefore(...):把 JWT 过滤器放在用户名密码过滤器之前
10. 登录控制器
// src/main/java/com/example/demo/controller/AuthController.java
package com.example.demo.controller;
import com.example.demo.model.*;
import com.example.demo.security.JwtTokenProvider;
import jakarta.validation.Valid;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider jwtTokenProvider) {
this.authenticationManager = authenticationManager;
this.jwtTokenProvider = jwtTokenProvider;
}
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String token = jwtTokenProvider.generateToken(userDetails);
return ApiResponse.ok(new LoginResponse(token));
}
}
11. 业务接口与方法级授权
我建议中型项目里同时用两层控制:
- URL 级别:控制大方向
- 方法级别:控制细粒度权限
// src/main/java/com/example/demo/controller/UserController.java
package com.example.demo.controller;
import com.example.demo.model.ApiResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/me")
public ApiResponse<?> me(Authentication authentication) {
return ApiResponse.ok(Map.of(
"currentUser", authentication.getName(),
"authorities", authentication.getAuthorities()
));
}
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin")
public ApiResponse<?> adminOnly() {
return ApiResponse.ok("只有 ADMIN 可访问");
}
@PreAuthorize("hasAuthority('sys:user:list')")
@GetMapping("/list")
public ApiResponse<?> list() {
return ApiResponse.ok("拥有 sys:user:list 权限即可访问");
}
}
注意这里:
hasRole('ADMIN')实际上匹配的是ROLE_ADMINhasAuthority('sys:user:list')则是精确匹配权限字符串
逐步验证清单
项目启动后,可以按这个顺序验证。
1)登录获取 token
admin 登录
curl -X POST 'http://localhost:8080/api/auth/login' \
-H 'Content-Type: application/json' \
-d '{
"username": "admin",
"password": "123456"
}'
示例响应:
{
"code": 200,
"message": "success",
"data": {
"tokenType": "Bearer",
"accessToken": "eyJhbGciOiJIUzI1NiJ9..."
}
}
2)访问当前用户信息
curl 'http://localhost:8080/api/users/me' \
-H 'Authorization: Bearer 你的token'
3)访问管理员接口
curl 'http://localhost:8080/api/users/admin' \
-H 'Authorization: Bearer 你的token'
- 用
admin登录拿到的 token:应返回成功 - 用
user登录拿到的 token:应返回 403
4)访问权限点接口
curl 'http://localhost:8080/api/users/list' \
-H 'Authorization: Bearer 你的token'
admin可以访问user会返回 403
请求时序图
这张图适合你在脑子里建立“过滤器链”的感觉。
sequenceDiagram
participant C as Client
participant F as JwtAuthenticationFilter
participant S as Spring Security
participant A as AuthController/UserController
C->>A: POST /api/auth/login(username,password)
A->>S: AuthenticationManager.authenticate(...)
S-->>A: Authentication
A-->>C: 返回 JWT
C->>F: GET /api/users/me + Bearer Token
F->>F: 解析并校验 JWT
F->>S: 写入 SecurityContext
S->>A: 执行授权判断
A-->>C: 返回业务数据
常见坑与排查
这一节我尽量写得接地气一点,因为这些坑大多数不是“不会写”,而是“写了但为什么不生效”。
1. 登录接口明明放行了,还是 401
先检查:
- 是否写了
.requestMatchers("/api/auth/**").permitAll() - 登录请求路径是不是实际就是
/api/auth/login - 是否被自定义过滤器误伤
尤其是自定义 JWT 过滤器,不要在没有 token 的情况下主动抛异常。
像本文这种写法:有 token 就校验,没有就直接放过,交给后续授权机制处理,通常更稳。
2. hasRole("ADMIN") 不生效
这是特别高频的问题。
原因
Spring Security 的 hasRole("ADMIN") 底层会匹配:
ROLE_ADMIN
如果你的权限里存的是:
ADMIN
那就会对不上。
解决方式
二选一:
- 角色统一存成
ROLE_ADMIN - 或者不用
hasRole,改用hasAuthority("ADMIN")
我个人建议:
角色走 ROLE_ 前缀,权限走业务字符串。
这样语义最清楚。
3. token 明明没过期,却提示无效
常见排查方向:
jwt.secret是否和签发时一致- 是否把
Bearer前缀也一起传进了解析方法 - token 有没有被前端截断
- 不同环境的时间是否严重漂移
如果是分布式部署,服务器时间误差太大也会出问题。
这个坑我以前在测试环境遇到过,两个节点时间偏了几十秒,正好卡在边界时间上,查了半天。
4. 过滤器执行了,但 Controller 里拿不到认证信息
重点检查:
- 有没有
SecurityContextHolder.getContext().setAuthentication(authentication); - JWT 过滤器有没有真正加入过滤器链
- 加入顺序是否正确,通常放在
UsernamePasswordAuthenticationFilter之前 - 当前请求是否被另一个过滤器清空了上下文
5. 认证成功了,但权限判断还是失败
看这几个点:
- token 里是否真的带了
authorities - 恢复出来的
SimpleGrantedAuthority是否和表达式匹配 - 是
hasRole还是hasAuthority - 方法级注解是否开启:
@EnableMethodSecurity
这类问题最有效的做法不是“猜”,而是打印当前用户权限:
@GetMapping("/me")
public ApiResponse<?> me(Authentication authentication) {
return ApiResponse.ok(authentication.getAuthorities());
}
先看实际值,再看表达式。
安全/性能最佳实践
JWT 方案常被误用成“只要能跑就行”。但真正上线前,有几条建议非常重要。
安全最佳实践
1)密钥要足够强,并且不要硬编码
示例里为了可运行,把密钥写在配置文件里了。生产环境建议:
- 使用环境变量
- 使用 KMS / Vault 等密钥管理工具
- 定期轮换密钥
如果密钥泄漏,攻击者就能伪造合法 token。
2)access token 过期时间不要太长
常见做法:
- access token:15 分钟 ~ 2 小时
- refresh token:更长,但要单独设计
如果你把 access token 配成 7 天,一旦泄漏,风险窗口太大。
3)敏感信息不要放到 JWT 里
JWT 默认只是 Base64Url 编码,不是加密。
所以:
- 不要放身份证号
- 不要放手机号等敏感隐私
- 不要放密码摘要
- 不要放过多业务字段
JWT 适合放“鉴权必须信息”。
4)考虑 token 撤销机制
纯无状态 JWT 的典型短板是:
已经签发的 token,在过期前默认都有效。
如果你的系统有这些要求:
- 强制下线
- 修改密码后旧 token 立即失效
- 用户被封禁后立即失效
建议增加以下机制之一:
- Redis 黑名单
- token version 字段 + 用户版本号校验
- 短生命周期 access token + refresh token
5)接口统一返回,不暴露过多细节
比如 token 校验失败时,不要区分得太细:
- 签名错误
- 格式错误
- 用户不存在
- token 过期
对外可以统一返回“未认证或 token 无效”。
这能减少攻击者试探系统内部细节的机会。
性能最佳实践
1)无状态解析比每次查库更轻
像本文这种做法,请求到来时只解析 token,不查数据库,吞吐通常更好。
尤其在高并发接口中,少一次数据库访问非常划算。
2)但不要把 JWT 做得太大
token 越大:
- 请求头越大
- 网络传输越重
- 反向代理日志和链路排查也更麻烦
通常把角色、权限做成紧凑字段就够了。
3)权限变化频繁的系统,要考虑缓存和一致性
如果你的权限变更非常频繁,比如后台一改角色就希望立刻生效,那么“纯 JWT 内嵌权限”会有一致性滞后问题。
此时可以折中:
- token 里只放用户 ID
- 请求时从缓存读取权限
- 缓存失效时回源数据库
这会牺牲一点性能,但换来更强的一致性控制。
可扩展方向
当你把本文方案跑通后,实际项目里一般会往这几个方向扩展。
1. 接数据库
把 CustomUserDetailsService 改为:
- 根据用户名查用户表
- 查角色表、权限表
- 转换为
GrantedAuthority
2. 增加 refresh token
让登录返回:
accessTokenrefreshToken
当 access token 过期时,用 refresh token 换新 token,提升体验。
3. 接入网关统一鉴权
如果是微服务架构,可以把 JWT 校验前移到网关层,但业务服务内部仍可保留方法级授权,形成双保险。
4. 增加审计日志
记录:
- 登录成功/失败
- token 刷新
- 权限拒绝
- 关键接口访问人
这对排查问题和安全审计都很有帮助。
一个更贴近生产的思路
如果你问我:真实项目里是不是就照这个最小方案原封不动上生产?
我的答案是:通常不会。
更常见的生产组合是:
- access token 短时有效
- refresh token 存 Redis 或数据库
- 用户状态变更时可主动失效
- 权限数据适当缓存
- 网关和服务双层校验
- 异常响应统一规范化
本文的价值是先把主链路打通。
你一旦把这个版本吃透,再往生产级增强,其实就是做“补充机制”,而不是推翻重来。
总结
在 Spring Boot 3 + Spring Security 6 中实现统一认证授权,核心就是抓住这几件事:
- 用
SecurityFilterChain替代老配置方式 - 登录时通过
AuthenticationManager完成认证 - 认证成功后签发 JWT
- 通过自定义过滤器解析 JWT 并写入
SecurityContext - 用 Spring Security 的 URL 规则和方法注解做授权
- 统一处理 401/403 返回
- 在安全性、可撤销性和性能之间做权衡
如果你现在正准备在项目里落地,我建议按这个顺序推进:
- 第一步:先做最小可运行链路,确保登录、鉴权、授权都通
- 第二步:补统一异常、统一响应
- 第三步:接数据库与角色权限模型
- 第四步:根据业务要求决定是否上 refresh token、黑名单、网关鉴权
最后给一个很实用的边界建议:
如果你的系统需要“即时失效”和“强会话控制”,不要迷信纯无状态 JWT;如果你的系统更重视前后端分离和横向扩展,JWT 会非常顺手。
技术方案没有绝对优劣,关键是和业务边界匹配。
如果你已经能把本文的代码跑起来,说明你对 Spring Boot 3 下 JWT + Spring Security 6 的主流程已经掌握得差不多了。接下来要做的,就是把这个最小方案,逐步打磨成适合你业务场景的版本。