Spring Boot 中基于 JWT + Spring Security 的前后端分离认证与权限控制实战
前后端分离项目里,认证和授权几乎是绕不开的基础设施。很多同学一开始会觉得:不就是“登录后返回一个 token”吗?真到项目里,问题马上就来了:
- token 放哪?
- Spring Security 怎么接入?
- 认证和权限控制怎么区分?
- 接口明明登录了,为什么还是 403?
- 退出登录后 token 为什么还能用?
这篇文章我会用一个能跑起来的 Spring Boot 示例,带你完整走一遍:
- 用户登录签发 JWT
- 前端携带 JWT 访问接口
- Spring Security 解析 token 并建立登录态
- 基于角色/权限控制接口访问
- 排查常见 401/403 问题
- 落到实际项目的安全与性能最佳实践
整篇内容偏实战,不追求“把所有概念讲到百科全书级别”,而是尽量像我带你一起搭一遍。
一、背景与问题
在传统服务端渲染应用里,常见做法是:
- 用户登录成功后,服务端把登录状态放进 Session
- 浏览器靠 Cookie 自动携带 SessionId
- 服务端每次请求根据 Session 恢复用户身份
但到了前后端分离场景,这套方式会遇到几个典型问题:
- 前端可能是 Vue/React,也可能是 App、小程序
- 服务端可能是多实例部署,Session 共享成本增加
- 跨域时 Cookie、Session 配置会更复杂
- 微服务之间传递用户身份不够方便
这时候,JWT(JSON Web Token) 就很适合作为一种无状态认证方案:
- 登录成功后,服务端签发 JWT
- 前端保存 JWT
- 后续请求放到
Authorization: Bearer xxx - 服务端校验 JWT,解析出用户信息和权限
JWT 解决的是“你是谁”的问题,而 Spring Security 解决的是“你能访问什么”的问题。两者结合,正好构成前后端分离项目中非常常见的一套安全方案。
二、前置知识与环境准备
2.1 适合谁看
这篇文章默认你已经了解:
- Java 基础语法
- Spring Boot 基础项目结构
- RESTful API 基本概念
- Maven 依赖管理
如果你之前没系统用过 Spring Security,也没关系,我会尽量讲清楚最关键的那条链路。
2.2 环境信息
本文示例环境:
- JDK 8+
- Spring Boot 2.7.x
- Spring Security 5.x
- Maven 3.6+
- JWT 库:
jjwt
三、核心原理
先别急着写代码,我们先把这套方案背后的流程理顺。只要这张图想明白了,后面的配置就不会觉得“玄学”。
3.1 整体认证流程
flowchart TD
A[用户提交用户名密码] --> B[登录接口 /login]
B --> C[AuthenticationManager 校验]
C -->|成功| D[生成 JWT]
C -->|失败| E[返回 401]
D --> F[前端保存 Token]
F --> G[请求受保护接口]
G --> H[Authorization: Bearer Token]
H --> I[JWT 过滤器解析 Token]
I --> J[SecurityContext 写入用户信息]
J --> K[Spring Security 鉴权]
K -->|有权限| L[返回业务数据]
K -->|无权限| M[返回 403]
3.2 认证与授权要分开理解
很多人第一次上手 Spring Security,最容易混淆的就是:
- 认证 Authentication:确认你是谁
- 授权 Authorization:确认你能做什么
举个简单例子:
- 用户
admin登录成功,说明认证通过 - 但他访问
/user/delete是否允许,还要看有没有ROLE_ADMIN或user:delete权限
也就是说:
- JWT 负责承载身份信息
- Spring Security 负责基于身份和权限做访问控制
3.3 JWT 的基本组成
JWT 一般由三部分组成:
- Header
- Payload
- Signature
格式如下:
xxxxx.yyyyy.zzzzz
其中 Payload 里通常放:
- 用户名
- 用户 ID
- 角色/权限
- 过期时间
但这里有个经验提醒:不要把敏感信息直接塞进 JWT Payload,因为它只是 Base64 编码,不是加密。
3.4 Spring Security 在这里扮演什么角色
我们会借助 Spring Security 做三件事:
- 登录时校验用户名密码
- 每次请求时从 JWT 恢复用户身份
- 基于角色/权限控制接口访问
这个过程里最关键的是两个点:
UserDetailsService:负责根据用户名查用户OncePerRequestFilter:负责每次请求解析 JWT
四、项目结构设计
先看一下示例结构,保持简单但完整:
src/main/java/com/example/jwtdemo
├── JwtDemoApplication.java
├── config
│ └── SecurityConfig.java
├── controller
│ ├── AuthController.java
│ └── UserController.java
├── filter
│ └── JwtAuthenticationFilter.java
├── model
│ ├── LoginRequest.java
│ └── LoginResponse.java
├── security
│ ├── CustomUserDetailsService.java
│ └── JwtTokenUtil.java
为了聚焦主题,本文不接数据库,先用内存用户模拟。你把流程跑通后,再替换成 MySQL/JPA/MyBatis 都很顺。
五、实战代码(可运行)
5.1 添加依赖
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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<properties>
<java.version>8</java.version>
</properties>
<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</artifactId>
<version>0.9.1</version>
</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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
5.2 启动类
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);
}
}
5.3 登录请求与响应对象
LoginRequest.java
package com.example.jwtdemo.model;
import javax.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
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;
}
}
LoginResponse.java
package com.example.jwtdemo.model;
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;
}
}
5.4 JWT 工具类
这个类负责:
- 生成 token
- 从 token 解析用户名
- 校验 token 是否有效
JwtTokenUtil.java
package com.example.jwtdemo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtTokenUtil {
private final String secret = "myJwtSecretKey123456";
private final long expiration = 1000 * 60 * 60;
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.claim("roles", userDetails.getAuthorities())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public String getUsernameFromToken(String token) {
return getClaims(token).getSubject();
}
public boolean isTokenExpired(String token) {
Date expirationDate = getClaims(token).getExpiration();
return expirationDate.before(new Date());
}
public boolean validateToken(String token, UserDetails userDetails) {
String username = getUsernameFromToken(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
}
这里为了示例简化了密钥管理,真实项目不要把 secret 硬编码在代码里,后面最佳实践部分会讲。
5.5 自定义用户加载逻辑
在真实项目里,这里通常会查数据库。为了让示例更聚焦,我们先手写两个用户:
admin / 123456,角色ADMINuser / 123456,角色USER
CustomUserDetailsService.java
package com.example.jwtdemo.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.Arrays;
@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 new User(
"admin",
passwordEncoder.encode("123456"),
Arrays.asList(
new SimpleGrantedAuthority("ROLE_ADMIN"),
new SimpleGrantedAuthority("user:read"),
new SimpleGrantedAuthority("user:write")
)
);
}
if ("user".equals(username)) {
return new User(
"user",
passwordEncoder.encode("123456"),
Arrays.asList(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("user:read")
)
);
}
throw new UsernameNotFoundException("用户不存在");
}
}
一个小提醒
这里每次 loadUserByUsername 都 encode("123456"),在示例里可以跑,但生产里通常是把数据库里已经加密过的密码取出来比较。
5.6 JWT 认证过滤器
这是整套方案最关键的一环。每个请求进来后,它会:
- 读取请求头
Authorization - 提取 Bearer Token
- 解析用户名
- 查用户详情
- 校验 token
- 把认证信息放入
SecurityContext
JwtAuthenticationFilter.java
package com.example.jwtdemo.filter;
import com.example.jwtdemo.security.CustomUserDetailsService;
import com.example.jwtdemo.security.JwtTokenUtil;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
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 org.springframework.security.core.userdetails.UserDetails;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenUtil jwtTokenUtil;
private final CustomUserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtTokenUtil jwtTokenUtil,
CustomUserDetailsService userDetailsService) {
this.jwtTokenUtil = jwtTokenUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
String token = null;
String username = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
try {
username = jwtTokenUtil.getUsernameFromToken(token);
} catch (Exception e) {
logger.warn("JWT 解析失败: " + e.getMessage());
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
filterChain.doFilter(request, response);
}
}
5.7 Spring Security 配置
我们要做几件事:
- 放行
/login - 其他接口都要认证
- 使用无状态会话
- 注册 JWT 过滤器
- 开启密码加密器
SecurityConfig.java
package com.example.jwtdemo.config;
import com.example.jwtdemo.filter.JwtAuthenticationFilter;
import com.example.jwtdemo.security.CustomUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomUserDetailsService userDetailsService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(CustomUserDetailsService userDetailsService,
JwtAuthenticationFilter jwtAuthenticationFilter) {
this.userDetailsService = userDetailsService;
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/login").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
5.8 登录接口
登录接口通过 AuthenticationManager 完成用户名密码校验。校验通过后,生成 JWT 返回给前端。
AuthController.java
package com.example.jwtdemo.controller;
import com.example.jwtdemo.model.LoginRequest;
import com.example.jwtdemo.model.LoginResponse;
import com.example.jwtdemo.security.JwtTokenUtil;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@RestController
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenUtil jwtTokenUtil;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenUtil jwtTokenUtil) {
this.authenticationManager = authenticationManager;
this.jwtTokenUtil = jwtTokenUtil;
}
@PostMapping("/login")
public LoginResponse login(@RequestBody @Validated LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
String token = jwtTokenUtil.generateToken(
(org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal()
);
return new LoginResponse(token);
}
}
5.9 业务接口与权限控制
这里我演示两种控制方式:
- 在配置类里通过 URL 做角色限制
- 在方法上用
@PreAuthorize做更细粒度权限控制
UserController.java
package com.example.jwtdemo.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
public class UserController {
@GetMapping("/profile")
public Map<String, Object> profile(Authentication authentication) {
Map<String, Object> result = new HashMap<>();
result.put("username", authentication.getName());
result.put("authorities", authentication.getAuthorities());
return result;
}
@GetMapping("/admin/dashboard")
public String adminDashboard() {
return "管理员面板访问成功";
}
@PreAuthorize("hasAuthority('user:read')")
@GetMapping("/user/list")
public String userList() {
return "用户列表读取成功";
}
@PreAuthorize("hasAuthority('user:write')")
@PostMapping("/user/create")
public String createUser() {
return "用户创建成功";
}
}
六、接口调用验证清单
到这里,项目已经可以跑了。下面我们按顺序验证,建议你别跳步骤。
6.1 启动项目
mvn spring-boot:run
默认端口:
http://localhost:8080
6.2 登录获取 token
使用 admin 登录
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
返回示例:
{
"token": "eyJhbGciOiJIUzUxMiJ9.xxx.yyy",
"tokenType": "Bearer"
}
使用 user 登录
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"123456"}'
6.3 访问个人信息接口
curl http://localhost:8080/profile \
-H "Authorization: Bearer 你的token"
返回示例:
{
"username": "admin",
"authorities": [
{
"authority": "ROLE_ADMIN"
},
{
"authority": "user:read"
},
{
"authority": "user:write"
}
]
}
6.4 访问管理员接口
curl http://localhost:8080/admin/dashboard \
-H "Authorization: Bearer admin的token"
返回:
管理员面板访问成功
如果换成 user 的 token:
curl http://localhost:8080/admin/dashboard \
-H "Authorization: Bearer user的token"
会得到 403 Forbidden。
6.5 验证细粒度权限
user 可读用户列表
curl http://localhost:8080/user/list \
-H "Authorization: Bearer user的token"
user 不可创建用户
curl -X POST http://localhost:8080/user/create \
-H "Authorization: Bearer user的token"
这时会是 403,因为 user 没有 user:write 权限。
七、请求链路解析
很多问题其实都出在“不知道请求到底走到哪一步了”。下面这张时序图,把登录和访问接口的关键链路串一下。
sequenceDiagram
participant Client as 前端/客户端
participant Auth as AuthController
participant AM as AuthenticationManager
participant UDS as UserDetailsService
participant JWT as JwtTokenUtil
participant Filter as JwtAuthenticationFilter
participant SC as SecurityContext
participant API as 业务接口
Client->>Auth: POST /login 用户名密码
Auth->>AM: authenticate()
AM->>UDS: loadUserByUsername()
UDS-->>AM: UserDetails
AM-->>Auth: 认证成功
Auth->>JWT: generateToken()
JWT-->>Client: 返回 JWT
Client->>Filter: 请求 /profile + Bearer Token
Filter->>JWT: 解析 Token
JWT-->>Filter: username
Filter->>UDS: loadUserByUsername()
UDS-->>Filter: UserDetails
Filter->>SC: 设置 Authentication
Filter->>API: 放行请求
API-->>Client: 返回业务数据
八、常见坑与排查
这一部分很重要。很多同学代码看着和教程差不多,但就是不通,往往都卡在这些地方。我自己也踩过不少。
8.1 明明登录成功了,访问接口还是 401
常见原因
- 请求头没带
Authorization - 请求头格式不对,少了
Bearer - 过滤器没有注册到 Spring Security 链
- token 过期了
- token 被截断了
排查方式
先打印请求头:
System.out.println(request.getHeader("Authorization"));
确认是不是这种格式:
Authorization: Bearer eyJhbGciOi...
再看过滤器里是否成功解析出 username。
8.2 登录接口返回 403
这个问题非常常见,通常是因为 /login 没放行。
确认配置里有:
.antMatchers("/login").permitAll()
如果你的前端是跨域请求,还要确认是不是 CORS 预检请求 被拦住了。尤其是浏览器发 OPTIONS 请求时,很容易误判成“登录失败”。
8.3 明明是管理员,却访问 /admin/** 还是 403
这通常和角色前缀有关。
Spring Security 对 hasRole("ADMIN") 的判断,实际上要求权限中存在:
ROLE_ADMIN
如果你写的是:
new SimpleGrantedAuthority("ADMIN")
那 hasRole("ADMIN") 是匹配不上的。
规则记住一句就够了
hasRole("ADMIN")对应权限值ROLE_ADMINhasAuthority("user:write")对应权限值user:write
8.4 token 里存了权限,为什么接口权限没生效
因为这篇示例里,权限判断真正依赖的是 UserDetailsService 查出来的 authorities,不是直接从 token claim 恢复权限对象。
这是一种比较稳妥的做法:
- token 主要承载身份
- 权限以服务端查库结果为准
这样做的好处是,用户权限变更后更容易及时生效。不然 token 一旦签发,里面的权限就“冻结”了,直到过期前都可能继续生效。
8.5 使用 Postman 正常,浏览器前端请求失败
十有八九是跨域问题。
你需要补充 CORS 配置,例如:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and()
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
再提供一个 CORS Bean:
@Bean
public org.springframework.web.cors.CorsConfigurationSource corsConfigurationSource() {
org.springframework.web.cors.CorsConfiguration configuration = new org.springframework.web.cors.CorsConfiguration();
configuration.addAllowedOriginPattern("*");
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.setAllowCredentials(true);
org.springframework.web.cors.UrlBasedCorsConfigurationSource source =
new org.springframework.web.cors.UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
生产环境不要直接
*全放开,最好只允许你的前端域名。
8.6 密码明明对了,登录却一直失败
如果你用了 BCryptPasswordEncoder,要特别注意:
- 数据库存的是加密后的密码
- 登录时传的是明文密码
- Spring Security 会自动做
matches
不要手动把前端传来的密码再次加密后去比,那样大概率永远不相等。
九、安全/性能最佳实践
跑通只是第一步。真正上线时,这部分比“能不能工作”更重要。
9.1 不要把密钥写死在代码里
示例里为了简单,直接写了:
private final String secret = "myJwtSecretKey123456";
真实项目建议:
- 放到环境变量
- 放到配置中心
- 至少放到
application.yml,并通过部署环境覆盖 - 更高要求场景可接 KMS/HSM
例如:
jwt:
secret: ${JWT_SECRET:defaultSecretKey}
expiration: 3600000
9.2 token 过期时间不要太长
很多项目图省事,把 token 设成 7 天、30 天,甚至永久有效。这其实风险很高。
更稳妥的做法:
- Access Token:15 分钟~2 小时
- Refresh Token:更长一些,但要单独管理
如果你只有 JWT 而没有刷新机制,那我建议先把有效期控制在一个合理范围内,而不是无限拉长。
9.3 退出登录要考虑“失效机制”
JWT 是无状态的,这意味着:
- 只要 token 没过期
- 且签名正确
- 服务端就能认它有效
所以“退出登录”不是天然成立的。常见解决方案有:
- 短期 token + 刷新 token
- 服务端维护黑名单
- 用户版本号/密码更新时间校验
我在业务系统里更常用的是:
- Access Token 短时有效
- Refresh Token 持久一点
- 敏感操作再校验一次权限或二次认证
9.4 不要在 JWT 里放敏感信息
不要放:
- 明文手机号
- 身份证号
- 银行卡信息
- 密码
- 详细业务权限快照(如果变化频繁)
JWT 更适合放:
- 用户 ID
- 用户名
- 签发时间
- 过期时间
- 少量稳定 claim
9.5 权限控制优先服务端校验
前端当然也可以做按钮级权限控制,但那只是用户体验优化,不是安全措施。
真正的权限控制必须在服务端完成:
- URL 访问权限
- 方法级权限
- 数据级权限
前端把按钮隐藏了,不代表接口就安全了。这个误区我见过太多次。
9.6 注意过滤器异常处理
如果 JWT 解析抛异常,不要让整个请求直接炸成 500。更好的方式是:
- 记录日志
- 清空上下文
- 继续走后续流程
- 由 Spring Security 统一返回 401
你还可以自定义:
AuthenticationEntryPoint:未认证时返回统一 401 JSONAccessDeniedHandler:无权限时返回统一 403 JSON
这样前端会更好处理。
9.7 权限信息是否放进 JWT,要看业务边界
这里没有绝对标准,但可以这样取舍:
放进 JWT
优点:
- 少一次查库
- 性能更好
缺点:
- 权限变更不容易实时生效
- token 体积变大
每次查服务端
优点:
- 权限变更能及时生效
- 更容易统一控制
缺点:
- 会多一次查询
如果是中后台管理系统,我通常更偏向于服务端实时获取权限,尤其是管理员权限经常调整的场景。
十、一个更贴近真实项目的演进思路
如果你准备把本文示例落到生产项目,可以按这个顺序升级:
flowchart LR
A[内存用户示例] --> B[接入数据库用户表]
B --> C[引入统一异常返回]
C --> D[加入 CORS 与前后端联调]
D --> E[接入 Redis 黑名单或刷新机制]
E --> F[细化角色/菜单/按钮权限]
F --> G[审计日志与风控增强]
这个顺序比较务实。很多项目一开始就想把 RBAC、菜单树、刷新 token、单点登录全部一步到位,结果反而把基础链路搞得很脆。我的建议是:先把认证链路打通,再逐步增强授权模型。
十一、边界条件与方案取舍
虽然 JWT 很常见,但也不是所有场景都必须上它。
适合 JWT 的场景
- 前后端分离
- 多终端接入
- 微服务之间需要传递身份
- 希望减少 Session 依赖
不一定适合 JWT 的场景
- 纯后端渲染的传统 Web 系统
- 强依赖“服务端立即失效”的安全要求
- 认证链路非常简单、规模不大
如果你的系统只是一个内部小工具、单体部署、浏览器访问为主,其实 Session + Spring Security 可能更省心。技术选型不是“越流行越好”,而是“越适合越好”。
十二、总结
我们这篇文章完成了一个完整的实战闭环:
- 用 Spring Security 做用户名密码认证
- 用 JWT 在前后端分离场景中承载身份
- 用过滤器在每次请求中恢复用户登录态
- 用角色与权限实现接口访问控制
- 了解了 401/403 的常见排查思路
- 梳理了上线前必须关注的安全与性能实践
如果你准备真正落地,我建议你按下面这个清单执行:
逐步落地清单
- 先用本文示例跑通登录与鉴权
- 把内存用户替换成数据库用户表
- 给接口统一返回 401/403 JSON 结构
- 配置好 CORS,完成前后端联调
- 缩短 access token 有效期
- 设计 refresh token 或退出失效机制
- 把 secret 从代码中移出
- 对关键接口开启操作审计日志
最后给一个很实用的建议:遇到权限问题,不要一上来怀疑 Spring Security 太复杂,先把链路拆成“请求头 -> 过滤器 -> SecurityContext -> 权限表达式”四步逐一验证。 大多数问题,其实都能在这四步里定位出来。
如果你把这套流程真正理解了,后面不管是接数据库、做 RBAC,还是扩展到微服务网关鉴权,都会顺很多。