Spring Boot 3 中基于 JWT 与 Spring Security 6 的前后端分离认证授权实战
前后端分离项目里,登录认证几乎是绕不过去的一关。很多人第一次接触 Spring Security 时,最大的感受通常是:概念多、过滤器链长、配置改一点就 403。如果再叠加 JWT,问题会更多——登录成功后怎么发 token、请求来了在哪校验、权限怎么挂钩、接口为什么明明带了 token 还是被拦。
这篇文章我不走“纯概念铺陈”的路子,而是按一个常见的业务路径带你做一遍:
- 用户登录
- 服务端签发 JWT
- 前端带着 JWT 调接口
- Spring Security 解析并完成认证
- 基于角色/权限做授权控制
文章基于:
- Spring Boot 3
- Spring Security 6
- JJWT
- 前后端分离、无状态认证
我会给出一套可运行的代码骨架,并把几个最常踩的坑放在后面集中排查。
背景与问题
在传统服务端渲染应用里,认证往往依赖 Session:
- 用户登录成功
- 服务端把登录态存到 Session
- 浏览器自动带 Cookie
- 服务端根据 Session 识别用户
但到了前后端分离场景,尤其是:
- Web 前端和后端分域部署
- 移动端、小程序、多端接入
- 网关转发、微服务拆分
- 希望服务端尽量无状态
这时 Session 方案会遇到几个问题:
- 跨域与 Cookie 管理复杂
- 服务横向扩容后 Session 共享麻烦
- 多端统一认证成本高
- 网关/服务间透传身份不够自然
JWT 的思路是:把用户身份信息签名后放进 token,客户端保存并在每次请求里携带,服务端只负责校验 token 是否可信。
不过要注意,JWT 解决的是“认证状态传递”问题,不是万能安全药。它带来的收益和代价都很明显:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Session | 服务端可控、便于失效 | 扩容与共享复杂 |
| JWT | 无状态、跨端方便 | 主动失效难、token 泄露风险更高 |
如果你的系统是标准前后端分离接口服务,那么 Spring Security 6 + JWT 依然是很常见、很实用的搭配。
前置知识与环境准备
建议你至少了解这些基础概念:
- Spring Boot 基本项目结构
- Spring MVC 接口开发
- Spring Security 的认证与授权区别
- HTTP Header 的基本使用
- Maven 依赖管理
本文示例环境:
- JDK 17+
- Spring Boot 3.3.x
- Maven 3.9+
- 数据存储先用内存模拟,方便聚焦认证流程
认证与授权:先把概念说透
很多人会把登录、权限、token 混成一团。其实可以拆开看:
- 认证 Authentication:你是谁
比如用户名密码校验通过,确认你是张三。 - 授权 Authorization:你能干什么
比如张三能访问/admin/**吗? - JWT:身份信息的携带方式
它不是权限系统本身,而是承载认证结果的容器。
一句话理解:
登录成功后把“你是谁、你有哪些权限、token 何时过期”打包进 JWT;后续请求中,Spring Security 负责把这个 token 重新还原成当前用户上下文,再决定是否允许访问接口。
核心原理
先看整体请求链路。
flowchart LR
A[前端提交用户名密码] --> B[/auth/login]
B --> C[AuthenticationManager 校验账号密码]
C --> D[生成 JWT]
D --> E[前端保存 Token]
E --> F[访问受保护接口]
F --> G[Authorization: Bearer token]
G --> H[JWT过滤器解析Token]
H --> I[构造 Authentication 放入 SecurityContext]
I --> J[Spring Security 做权限判断]
J --> K[返回业务数据]
这个流程里最关键的点有三个:
1. 登录接口不再依赖默认表单登录
Spring Security 以前常见的用法是表单登录页面,但前后端分离通常不需要这个页面,所以我们会:
- 关闭默认 formLogin
- 提供自己的
/auth/loginJSON 登录接口
2. 每个请求都要经过 JWT 过滤器
JWT 是无状态的,服务端不会记住你是谁。
所以每次请求到来时,都要:
- 从
Authorization头里取 token - 校验签名和过期时间
- 解析用户名、角色
- 构造成
Authentication - 放进
SecurityContextHolder
3. 授权最终还是交给 Spring Security
也就是说:
- JWT 负责“带身份”
- Spring Security 负责“认身份、管权限”
这点非常重要。很多项目把权限全写进自定义拦截器,最后会越来越乱。更推荐的方式是:让 JWT 过滤器只做认证恢复,让权限判断继续走 Security 的标准机制。
Spring Security 6 下的关键变化
如果你看过一些旧教程,可能会发现写法对不上。这不是你记错了,是版本变了。
Spring Security 6 / Spring Boot 3 里几个明显变化:
WebSecurityConfigurerAdapter已废弃,改用SecurityFilterChain- 授权配置从
authorizeRequests()变为authorizeHttpRequests() requestMatchers()替代很多旧匹配写法- 更强调 Lambda 风格配置
所以如果你以前是这么写:
@Override
protected void configure(HttpSecurity http) throws Exception {
// 旧写法
}
那现在要换思路了。
项目结构设计
为了让代码足够清晰,我们拆成下面几个部分:
src/main/java/com/example/jwtdemo
├── JwtDemoApplication.java
├── config
│ ├── SecurityConfig.java
│ └── JwtAuthenticationFilter.java
├── controller
│ ├── AuthController.java
│ └── UserController.java
├── dto
│ ├── LoginRequest.java
│ └── LoginResponse.java
├── security
│ ├── CustomUserDetailsService.java
│ └── JwtService.java
└── store
└── InMemoryUserStore.java
这套结构不复杂,但职责比较明确:
AuthController:登录发 tokenJwtAuthenticationFilter:每次请求解析 tokenJwtService:生成/校验 JWTCustomUserDetailsService:根据用户名加载用户SecurityConfig:统一安全配置
实战代码(可运行)
下面给你一套最小可运行版本。你可以直接照着搭。
第一步:创建 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-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>jwt-demo</name>
<description>Spring Boot 3 JWT Security Demo</description>
<properties>
<java.version>17</java.version>
<spring.boot.version>3.3.4</spring.boot.version>
<jjwt.version>0.12.6</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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
第二步:配置 application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expiration: 3600000
说明:
secret至少要足够长,HS256 下建议 32 字节以上expiration单位毫秒,这里是 1 小时
第三步:启动类
JwtDemoApplication.java
package com.example.jwtdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JwtDemoApplication {
public static void main(String[] args) {
SpringApplication.run(JwtDemoApplication.class, args);
}
}
第四步:定义登录请求与响应 DTO
dto/LoginRequest.java
package com.example.jwtdemo.dto;
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;
}
}
dto/LoginResponse.java
package com.example.jwtdemo.dto;
public class LoginResponse {
private String token;
private String tokenType = "Bearer";
public LoginResponse(String token) {
this.token = token;
}
public String getToken() {
return token;
}
public String getTokenType() {
return tokenType;
}
}
第五步:准备一个内存用户存储
为了先跑通流程,我们不用数据库,先模拟两个用户:
admin / 123456user / 123456
store/InMemoryUserStore.java
package com.example.jwtdemo.store;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.*;
@Component
public class InMemoryUserStore {
private final Map<String, UserRecord> users = new HashMap<>();
public InMemoryUserStore(PasswordEncoder passwordEncoder) {
users.put("admin", new UserRecord(
"admin",
passwordEncoder.encode("123456"),
List.of("ROLE_ADMIN", "ROLE_USER")
));
users.put("user", new UserRecord(
"user",
passwordEncoder.encode("123456"),
List.of("ROLE_USER")
));
}
public Optional<UserRecord> findByUsername(String username) {
return Optional.ofNullable(users.get(username));
}
public static class UserRecord {
private final String username;
private final String password;
private final List<String> roles;
public UserRecord(String username, String password, List<String> roles) {
this.username = username;
this.password = password;
this.roles = roles;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public List<String> getRoles() {
return roles;
}
}
}
第六步:实现 UserDetailsService
security/CustomUserDetailsService.java
package com.example.jwtdemo.security;
import com.example.jwtdemo.store.InMemoryUserStore;
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 InMemoryUserStore userStore;
public CustomUserDetailsService(InMemoryUserStore userStore) {
this.userStore = userStore;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var user = userStore.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(user.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList()))
.build();
}
}
这里我使用了 authorities 而不是 roles("ADMIN"),因为 JWT 里通常会直接保存完整权限标识,后面更灵活。
第七步:实现 JWT 工具类
security/JwtService.java
package com.example.jwtdemo.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.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
private SecretKey getSignKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(UserDetails userDetails) {
List<String> authorities = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
Date now = new Date();
Date expireDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("authorities", authorities)
.issuedAt(now)
.expiration(expireDate)
.signWith(getSignKey())
.compact();
}
public String extractUsername(String token) {
return parseClaims(token).getSubject();
}
public List<String> extractAuthorities(String token) {
Object authorities = parseClaims(token).get("authorities");
if (authorities instanceof List<?> list) {
return list.stream().map(String::valueOf).toList();
}
return List.of();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return parseClaims(token).getExpiration().before(new Date());
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(getSignKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
}
第八步:实现 JWT 过滤器
这是整套方案里最核心的一环。
config/JwtAuthenticationFilter.java
package com.example.jwtdemo.config;
import com.example.jwtdemo.security.JwtService;
import com.example.jwtdemo.security.CustomUserDetailsService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.stream.Collectors;
@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 {
final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String token = authHeader.substring(7);
final String username;
try {
username = jwtService.extractUsername(token);
} catch (Exception ex) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
var userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
var authorities = jwtService.extractAuthorities(token).stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
var authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
authorities
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
这里有个实践细节值得说一下:
- 是否每次都查数据库加载用户?
示例里查了userDetailsService,这样可以确保账号状态变化能被感知。 - 如果你想完全无状态,也可以只信任 token 内的数据,但那样权限变更和封号失效会不及时。
生产里我通常更推荐:
- token 内保留基础身份和权限快照
- 配合短时效 access token
- 必要时结合黑名单或版本号机制
第九步:Spring Security 配置
config/SecurityConfig.java
package com.example.jwtdemo.config;
import com.example.jwtdemo.security.CustomUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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;
private final CustomUserDetailsService userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
CustomUserDetailsService userDetailsService) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login").permitAll()
.requestMatchers(HttpMethod.GET, "/api/user/profile").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
var provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
几个关键配置解释一下:
csrf.disable():前后端分离、JWT header 认证场景中通常关闭STATELESS:禁用 SessionaddFilterBefore(...):让 JWT 过滤器在用户名密码认证过滤器之前执行hasRole("ADMIN"):底层会匹配ROLE_ADMIN
第十步:登录接口
controller/AuthController.java
package com.example.jwtdemo.controller;
import com.example.jwtdemo.dto.LoginRequest;
import com.example.jwtdemo.dto.LoginResponse;
import com.example.jwtdemo.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("/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(@RequestBody @Valid LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
var userDetails = (org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal();
String token = jwtService.generateToken(userDetails);
return new LoginResponse(token);
}
}
这个接口很“前后端分离”:
- 接收 JSON
- 返回 JSON
- 不跳登录页
- 不设置 Session
第十一步:受保护接口与角色接口
controller/UserController.java
package com.example.jwtdemo.controller;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/user/profile")
public Map<String, Object> profile(Authentication authentication) {
return Map.of(
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
@GetMapping("/admin/dashboard")
public Map<String, Object> adminDashboard(Authentication authentication) {
return Map.of(
"message", "欢迎进入管理员面板",
"operator", authentication.getName()
);
}
}
一张时序图:请求到底怎么走
如果你总觉得过滤器链抽象,这张图会更直观。
sequenceDiagram
participant FE as 前端
participant Auth as AuthController
participant AM as AuthenticationManager
participant JWT as JwtService
participant Filter as JwtAuthenticationFilter
participant API as Protected API
FE->>Auth: POST /auth/login 用户名密码
Auth->>AM: authenticate()
AM-->>Auth: 认证成功
Auth->>JWT: generateToken()
JWT-->>FE: 返回 JWT
FE->>Filter: GET /api/user/profile + Bearer Token
Filter->>JWT: 解析并校验 token
JWT-->>Filter: 用户名/权限
Filter->>Filter: 放入 SecurityContext
Filter->>API: 放行请求
API-->>FE: 返回业务数据
运行与验证
启动项目后,按下面顺序验证。
1. 登录获取 token
curl -X POST 'http://localhost:8080/auth/login' \
-H 'Content-Type: application/json' \
-d '{
"username": "admin",
"password": "123456"
}'
预期返回:
{
"token": "eyJhbGciOiJIUzI1NiJ9....",
"tokenType": "Bearer"
}
2. 访问普通用户接口
curl 'http://localhost:8080/api/user/profile' \
-H 'Authorization: Bearer 你的token'
3. 访问管理员接口
curl 'http://localhost:8080/api/admin/dashboard' \
-H 'Authorization: Bearer 你的token'
如果用 user/123456 登录,再访问管理员接口,应该得到 403。
逐步验证清单
这里给一个实战里很好用的排错顺序。我自己调 Spring Security 时,基本都按这个顺序来。
-
/auth/login是否被permitAll() - 用户密码是否经过同一种
PasswordEncoder - JWT 是否真的放在
Authorization: Bearer xxx - 过滤器是否成功注册到链路中
- token 中的权限是否为
ROLE_ADMIN这种格式 -
hasRole("ADMIN")与权限字符串是否匹配 - 是否设置了
SessionCreationPolicy.STATELESS - 请求是否被跨域或预检 OPTIONS 拦截
常见坑与排查
这部分很关键。很多“JWT 不生效”的问题,其实都集中在下面几类。
坑 1:登录成功,但访问接口仍然 403
常见原因:
- token 没带
- token 格式错了,没有
Bearer - JWT 过滤器没执行
- 权限不匹配
比如你 token 里存的是:
["ADMIN"]
而接口配置是:
.requestMatchers("/api/admin/**").hasRole("ADMIN")
这会失败,因为 hasRole("ADMIN") 实际要匹配的是 ROLE_ADMIN。
解决方式二选一:
- token 中存
ROLE_ADMIN - 或者用
hasAuthority("ADMIN")
我个人建议统一走 ROLE_ 前缀,少绕弯。
坑 2:密码明明对,还是 BadCredentials
最常见原因是密码编码器不一致。
比如用户保存时用了 BCrypt:
passwordEncoder.encode("123456")
认证时却用明文比较,肯定失败。
要确保:
- 存储密码时加密
- 登录校验时用同一个
PasswordEncoder
坑 3:接口返回 401 和 403 分不清
这个区别必须知道:
- 401 Unauthorized:你还没通过认证,或者 token 无效
- 403 Forbidden:你已经认证了,但权限不够
简单理解:
- 没登录/假 token:401
- 已登录但不是管理员:403
如果项目里两个状态都返回 403,排查会很痛苦。建议自定义异常处理,明确区分。
坑 4:前端跨域预检请求被拦截
浏览器会先发一个 OPTIONS 预检请求。
如果这个请求没放行,前端可能看到的是跨域报错,而不是认证报错。
可以增加配置:
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/auth/login").permitAll()
.anyRequest().authenticated()
)
如果前后端分域部署,这一步很常见,我当时第一次接 SPA 项目时就在这里卡了半天。
坑 5:token 一改权限,老 token 还有效
这是 JWT 的典型问题。
因为服务端不存状态,所以已签发 token 在过期前通常仍可用。
解决思路:
- access token 设短一点,比如 15~30 分钟
- 配 refresh token 续签
- 引入 token 黑名单
- 用户表里增加
tokenVersion,签发时写入 claim,校验时比对
如果系统对“立即踢人下线”“立刻回收权限”要求很高,单纯 JWT 不够,需要配合服务端状态。
安全/性能最佳实践
教程跑通只是第一步,真正上线时,要把安全和性能一起考虑。
1. Access Token 短时效,必要时配 Refresh Token
推荐思路:
- Access Token:15 分钟 ~ 2 小时
- Refresh Token:7 天 ~ 30 天
这样即便 access token 泄露,风险窗口也有限。
2. 永远不要把敏感数据放进 JWT
不要把下面这些直接塞进 token:
- 明文密码
- 手机号、身份证号等高敏字段
- 完整用户对象
- 内部系统敏感配置
JWT 是签名防篡改,不是默认加密。
能被客户端拿到的东西,都默认可能被看到。
3. 强制 HTTPS
JWT 最怕的不是被“猜”,而是被“偷”。
如果你还在 HTTP 明文传输,再复杂的安全设计都很脆。
4. 密钥管理要正规
不要这样干:
jwt:
secret: mysecret
也不要把生产密钥直接写死在 Git 仓库里。
更推荐:
- 环境变量注入
- 配置中心
- KMS / Secret Manager
- 定期轮换密钥
5. 过滤器里不要做过重逻辑
JWT 过滤器是每个请求都会走的地方。
所以不要在这里:
- 查很多次数据库
- 做复杂权限树计算
- 远程调用用户中心
否则认证链会变成性能瓶颈。
6. 用 Method Security 做细粒度授权
除了 URL 规则,还可以直接在方法上做权限控制。
例如:
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/stats")
public Map<String, Object> stats() {
return Map.of("pv", 1024);
}
这种方式在业务复杂时会更清晰,尤其是同一路径下不同操作权限不同时。
一个更完整的授权视角
项目做大后,认证和授权通常会演化成下面这样:
classDiagram
class User {
+String username
+String password
+boolean enabled
}
class Role {
+String code
}
class Permission {
+String code
}
class JWT {
+sub
+exp
+authorities
}
User --> Role
Role --> Permission
JWT --> User
在简单系统里,你可以直接把角色放进 token。
在复杂系统里,可能会进一步拆成:
- 用户
- 角色
- 权限点
- 数据范围
- 租户信息
这时候要权衡一点:token 放太多内容会臃肿,放太少又会频繁查库。
我的建议是:
- token 放“高频、稳定、必要”的最小信息
- 复杂动态权限留给服务端实时判断
可以继续扩展的能力
本文示例是最小闭环,实际项目一般还会继续加这些内容:
1. 自定义未认证/无权限响应
让 401/403 返回统一 JSON,而不是默认错误页。
2. Refresh Token 机制
避免用户频繁重新登录。
3. 登出与黑名单
JWT 无状态不等于不能登出,只是要引入额外状态管理。
4. 基于数据库的用户、角色、权限模型
将内存用户替换为 MyBatis/JPA 查询。
5. 网关统一鉴权
如果是微服务架构,可以把 JWT 校验下沉到网关,业务服务只消费用户上下文。
常见边界条件
实战里你还要考虑这些边界:
- 移动端长期登录:建议 refresh token 方案
- 权限变更要实时生效:建议短 token + 版本号/黑名单
- 单点登录(SSO):可考虑 OAuth2 / OIDC,而不是完全手写
- 高安全后台:增加二次验证、设备绑定、IP 风险控制
- 多租户系统:token 中最好明确租户标识,并在服务端二次校验
如果只是内部管理后台,这篇方案通常够用。
如果是面向公网、高并发、多终端的大系统,建议在 JWT 之上再补:
- refresh token
- 黑名单
- 审计日志
- 风控策略
- 密钥轮换
总结
我们这篇文章从“前后端分离为什么常用 JWT”出发,完整走了一遍 Spring Boot 3 + Spring Security 6 的认证授权落地流程:
- 自定义
/auth/login登录接口 - 使用
AuthenticationManager校验用户名密码 - 登录成功后签发 JWT
- 通过自定义
JwtAuthenticationFilter在每个请求中解析 token - 将认证信息放入
SecurityContext - 用 Spring Security 的 URL 规则或方法注解完成授权
如果你现在正准备把它用进项目,我给三个可执行建议:
- 先跑通最小闭环:登录、带 token、访问受保护接口、验证角色控制
- 统一权限命名规则:建议角色统一使用
ROLE_前缀,减少 403 迷惑问题 - 别把 JWT 当成“纯无状态银弹”:权限实时失效、强制登出这些需求,往往仍需要服务端状态配合
最后给一个经验判断:
- 中小型前后端分离项目:这套方案很好用
- 需要标准化第三方登录/SSO:优先考虑 OAuth2/OIDC
- 安全要求极高:JWT 只是基础设施,不是全部答案
如果你按本文代码搭起来,至少已经能清楚地回答三个问题:
- token 在哪发?
- token 在哪验?
- 权限到底由谁判断?
这三个点一旦打通,Spring Security 就不再那么“黑盒”了。