Spring Boot 中基于 Spring Security 与 JWT 的权限认证实战:从登录鉴权到接口级访问控制
很多团队做 Spring Boot 项目时,认证授权这块一开始都很“朴素”:
- 登录接口校验用户名密码
- 成功后把用户信息放 Session
- Controller 里手动判断角色
- 接口越来越多后,权限逻辑散落一地
项目小的时候还能忍,项目一旦拆前后端、接移动端、接第三方,问题就开始集中爆发:
- Session 不方便做分布式扩展
- 接口权限控制容易失控
- 登录态难以跨服务传递
- 鉴权逻辑分散,维护成本高
这篇文章我就带你完整走一遍:在 Spring Boot 里用 Spring Security + JWT 实现一套比较主流的权限认证方案,从登录签发 Token,到请求鉴权,再到接口级访问控制。文章会尽量从“能跑起来、能定位问题、能落地上线”的角度来讲,而不是只贴几段配置。
一、背景与问题
在前后端分离架构里,服务端通常不再依赖传统 Session,而是采用 无状态认证:
- 用户登录,提交用户名密码
- 服务端校验成功后,签发 JWT
- 前端保存 JWT
- 后续请求在 Header 中携带 Token
- 服务端解析 JWT,恢复用户身份和权限
- Spring Security 根据当前认证信息完成接口访问控制
这种方式的好处很直接:
- 天然适合前后端分离
- 对分布式和微服务更友好
- 服务端不必维护 Session 状态
- 认证与授权逻辑更集中
但它也不是“配上就完事”:
- Token 放哪里更安全?
- JWT 里该存什么,不该存什么?
- Spring Security 的过滤器链怎么接?
- 角色和权限到底应该怎么建模?
- 401 和 403 的区别怎么处理?
- Token 过期、刷新、踢下线怎么做?
这些正是实战里最常见的问题。
二、前置知识与环境准备
1. 技术栈
本文示例基于:
- JDK 17
- Spring Boot 3.x
- Spring Security 6.x
- jjwt 0.11.x
2. 我们要实现的目标
最终效果如下:
/auth/login:匿名可访问,登录后返回 JWT/api/user/profile:登录用户可访问/api/admin/dashboard:只有ADMIN角色可访问- 请求未登录返回
401 - 已登录但权限不足返回
403
3. 示例角色设计
为了讲清楚,我们先用最小模型:
- 用户
alice:角色USER - 用户
admin:角色ADMIN
实际项目中一般会拆成:
- 用户(User)
- 角色(Role)
- 权限(Permission)
- 用户角色关联
- 角色权限关联
本文先聚焦“能跑通主链路”。
三、核心原理
先不要急着写代码,先把链路想清楚。
1. 认证与授权分别是什么
- 认证 Authentication:你是谁?
- 授权 Authorization:你能访问什么?
在 Spring Security 里:
- 登录时校验用户名密码,属于认证
- 访问接口时根据角色/权限判断,属于授权
2. JWT 在链路中的位置
JWT 本质上是一个签名令牌,通常包含:
- 用户标识
- 用户名
- 角色/权限
- 过期时间
服务端收到请求后:
- 从 Header 里取出 Token
- 校验签名与过期时间
- 解析出用户身份和权限
- 封装成
Authentication - 放入
SecurityContext
后续 Spring Security 才知道“当前是谁”。
3. 整体请求流程图
flowchart TD
A[客户端登录 /auth/login] --> B[AuthenticationManager 校验用户名密码]
B --> C{校验成功?}
C -- 否 --> D[返回 401]
C -- 是 --> E[生成 JWT]
E --> F[客户端保存 Token]
F --> G[客户端访问受保护接口]
G --> H[JWT 过滤器解析 Authorization Header]
H --> I{Token 合法?}
I -- 否 --> J[返回 401]
I -- 是 --> K[构造 Authentication 放入 SecurityContext]
K --> L[Spring Security 做权限判断]
L --> M{有权限?}
M -- 否 --> N[返回 403]
M -- 是 --> O[执行 Controller]
4. Spring Security 关键组件
这几个类你需要有基本概念:
UserDetailsService:根据用户名加载用户PasswordEncoder:密码加密与校验AuthenticationManager:执行登录认证SecurityFilterChain:安全过滤器配置OncePerRequestFilter:自定义 JWT 解析过滤器SecurityContextHolder:保存当前请求的认证信息
5. 时序图:一次完整的鉴权过程
sequenceDiagram
participant C as Client
participant A as AuthController
participant AM as AuthenticationManager
participant U as UserDetailsService
participant J as JwtService
participant F as JwtAuthenticationFilter
participant S as SecurityContext
participant API as Protected API
C->>A: POST /auth/login 用户名密码
A->>AM: authenticate()
AM->>U: loadUserByUsername()
U-->>AM: UserDetails
AM-->>A: 认证成功
A->>J: generateToken()
J-->>A: JWT
A-->>C: 返回 Token
C->>F: GET /api/user/profile + Authorization Bearer JWT
F->>J: validateToken()
J-->>F: Token 有效
F->>S: 设置 Authentication
F->>API: 放行请求
API-->>C: 返回业务数据
四、实战代码(可运行)
下面给出一个最小可运行版本。为了让重点更聚焦,用户数据先放内存里,实际项目中再替换为数据库查询。
1. Maven 依赖
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>spring-security-jwt-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<properties>
<java.version>17</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-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>
2. application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expiration: 3600000
这里的
secret只是示例。生产环境一定要更长、更随机,并通过配置中心或环境变量管理。
3. 启动类
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SecurityJwtDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityJwtDemoApplication.class, args);
}
}
4. DTO 定义
LoginRequest.java
package com.example.demo.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;
}
}
LoginResponse.java
package com.example.demo.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;
}
}
5. JWT 工具类
JwtService.java
package com.example.demo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
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.util.Date;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
public String generateToken(UserDetails userDetails) {
List<String> roles = userDetails.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.toList();
return Jwts.builder()
.setClaims(Map.of("roles", roles))
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSignKey(), SignatureAlgorithm.HS256)
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
public Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSignKey())
.build()
.parseClaimsJws(token)
.getBody();
}
public <T> T extractClaim(String token, Function<Claims, T> resolver) {
Claims claims = extractAllClaims(token);
return resolver.apply(claims);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private SecretKey getSignKey() {
byte[] keyBytes = Decoders.BASE64.decode(
java.util.Base64.getEncoder().encodeToString(secret.getBytes())
);
return Keys.hmacShaKeyFor(keyBytes);
}
}
6. 用户加载服务
CustomUserDetailsService.java
package com.example.demo.security;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if ("alice".equals(username)) {
return User.withUsername("alice")
.password("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy") // password
.authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
.build();
}
if ("admin".equals(username)) {
return User.withUsername("admin")
.password("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy") // password
.authorities(List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))
.build();
}
throw new UsernameNotFoundException("用户不存在");
}
}
这里两个用户的密码都是
password。为了演示方便,直接写死了 bcrypt 密文。
7. JWT 过滤器
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.lang.NonNull;
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(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String username;
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
try {
username = jwtService.extractUsername(jwt);
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
8. 未认证与未授权处理器
JwtAuthenticationEntryPoint.java
package com.example.demo.security;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未认证或Token无效\"}");
}
}
JwtAccessDeniedHandler.java
package com.example.demo.security;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"message\":\"权限不足\"}");
}
}
9. Security 配置
SecurityConfig.java
package com.example.demo.config;
import com.example.demo.security.CustomUserDetailsService;
import com.example.demo.security.JwtAccessDeniedHandler;
import com.example.demo.security.JwtAuthenticationEntryPoint;
import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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 JwtAuthenticationEntryPoint authenticationEntryPoint;
private final JwtAccessDeniedHandler accessDeniedHandler;
private final CustomUserDetailsService userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
JwtAuthenticationEntryPoint authenticationEntryPoint,
JwtAccessDeniedHandler accessDeniedHandler,
CustomUserDetailsService userDetailsService) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.authenticationEntryPoint = authenticationEntryPoint;
this.accessDeniedHandler = accessDeniedHandler;
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))
.exceptionHandling(exception -> exception
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider 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();
}
}
10. 登录接口
AuthController.java
package com.example.demo.controller;
import com.example.demo.dto.LoginRequest;
import com.example.demo.dto.LoginResponse;
import com.example.demo.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.security.core.userdetails.UserDetails;
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(@Valid @RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String token = jwtService.generateToken(userDetails);
return new LoginResponse(token);
}
}
11. 受保护接口
UserController.java
package com.example.demo.controller;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/profile")
public String profile(Authentication authentication) {
return "当前登录用户:" + authentication.getName();
}
}
AdminController.java
package com.example.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin")
public class AdminController {
@GetMapping("/dashboard")
public String dashboard() {
return "管理员控制台数据";
}
}
12. 用方法级注解做更细粒度控制
如果你不想把权限规则都堆在 URL 配置里,可以用注解。
ReportController.java
package com.example.demo.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/report")
public class ReportController {
@GetMapping("/list")
@PreAuthorize("hasAnyRole('USER','ADMIN')")
public String list() {
return "报表列表";
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public String delete(@PathVariable Long id) {
return "删除报表:" + id;
}
}
权限模型关系图
classDiagram
class User {
+Long id
+String username
+String password
}
class Role {
+Long id
+String code
}
class Permission {
+Long id
+String code
}
User --> Role : many-to-many
Role --> Permission : many-to-many
五、逐步验证清单
我建议你不要一上来就全功能联调,按下面顺序验证,定位最省时间。
1. 先测试登录
请求:
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password"}'
预期返回:
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"tokenType": "Bearer"
}
2. 用普通用户访问用户接口
curl http://localhost:8080/api/user/profile \
-H "Authorization: Bearer 你的token"
预期返回:
当前登录用户:alice
3. 用普通用户访问管理员接口
curl http://localhost:8080/api/admin/dashboard \
-H "Authorization: Bearer 你的token"
预期返回:
{"code":403,"message":"权限不足"}
4. 不带 Token 访问受保护接口
curl http://localhost:8080/api/user/profile
预期返回:
{"code":401,"message":"未认证或Token无效"}
5. 用管理员账号登录再访问
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"password"}'
拿到 Token 后访问:
curl http://localhost:8080/api/admin/dashboard \
-H "Authorization: Bearer 管理员token"
预期返回:
管理员控制台数据
六、常见坑与排查
这部分很重要。我自己最开始接 Spring Security 时,真正耗时间的不是“不会写”,而是“明明写了却没生效”。
1. 401 和 403 搞混
这是最常见的。
- 401 Unauthorized:你还没通过认证,比如没带 Token、Token 无效、Token 过期
- 403 Forbidden:你已经登录了,但没有访问该资源的权限
排查方法:
- 看
SecurityContext里有没有认证信息 - 看 JWT 过滤器有没有成功设置
Authentication - 看用户 authorities 是否包含目标角色
2. hasRole("ADMIN") 和 ROLE_ADMIN 对不上
Spring Security 默认对 hasRole("ADMIN") 做了前缀处理,实际匹配的是:
ROLE_ADMIN
所以你在 UserDetails 里通常要写:
new SimpleGrantedAuthority("ROLE_ADMIN")
如果你写成:
new SimpleGrantedAuthority("ADMIN")
那大概率就会出现“明明是管理员却被拒绝”的情况。
3. JWT 过滤器位置不对
JWT 过滤器应该放在:
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
如果位置不对,可能会导致:
- 请求还没解析 JWT 就已经走到权限判断
- 结果所有接口都像“未登录”
4. 密码明文与加密不一致
如果你数据库里存的是 bcrypt 密文,那就必须配 BCryptPasswordEncoder。
常见现象:
- 用户名正确,密码也看起来对,但始终登录失败
- 控制台会有
BadCredentialsException
排查建议:
- 确认数据库密码是否是 bcrypt
- 确认登录时传的是明文密码
- 确认
PasswordEncoder与存储方式一致
5. Token 解析失败但接口还返回 403
这个问题经常让人误判。
原因通常是:
- 过滤器里 Token 异常被吞掉
- 没有设置认证信息
- 后续访问受保护接口,Spring Security 触发未认证或权限不足逻辑
建议:
- 在 JWT 过滤器里增加日志
- 明确区分“解析失败”和“权限不足”
- 生产环境不要把异常堆栈直接回给前端,但日志一定要有
6. 开启了 CSRF 导致 POST 请求异常
前后端分离、基于 JWT 的无状态接口,一般会关闭 CSRF:
http.csrf(csrf -> csrf.disable());
如果你没关,某些 POST/PUT/DELETE 请求可能出现莫名其妙的 403。
7. 方法级权限不生效
如果 @PreAuthorize 没生效,先检查是否加了:
@EnableMethodSecurity
这个很小,但很关键。我踩过一次,查了半天 Controller,最后发现只是少了这个注解。
七、安全/性能最佳实践
JWT 很方便,但不是“只要能用就安全”。下面这些建议比较实用。
1. JWT 里不要放敏感信息
不要把这些内容直接塞进 JWT:
- 用户密码
- 手机号、身份证号等隐私信息
- 过多业务数据
JWT 默认只是 Base64Url 编码,不是加密。别人拿到后是能解出来看的。
建议只放:
- 用户 ID 或用户名
- 角色/权限标识
- 签发时间
- 过期时间
- 必要的业务声明
2. 过期时间不要太长
如果 Access Token 有效期太长,一旦泄露,风险窗口就很大。
常见做法:
- Access Token:15 分钟 ~ 2 小时
- Refresh Token:7 天 ~ 30 天
本文示例只演示了 Access Token。生产环境通常还会加一套 Refresh Token 机制。
Token 生命周期示意
stateDiagram-v2
[*] --> Issued : 登录成功签发Token
Issued --> Active : 客户端携带访问
Active --> Expired : 到达过期时间
Active --> Revoked : 主动注销/踢下线
Expired --> [*]
Revoked --> [*]
3. 考虑 Token 吊销策略
JWT 是无状态的,天然不方便“立即失效”。如果你有这些需求:
- 用户退出后 Token 立刻失效
- 修改密码后旧 Token 失效
- 管理员踢人下线
可以考虑:
- Redis 黑名单
- 维护 Token 版本号
- 用户表增加
lastPasswordChangeTime - 短期 Access Token + Refresh Token
如果系统安全要求高,我更建议 短 Access Token + 可控 Refresh Token,这是更常见的折中方案。
4. 用 HTTPS 传输
JWT 一旦被截获,本质上就相当于“别人拿到了你的登录态”。
所以生产环境一定要:
- 全站 HTTPS
- 禁止明文 HTTP 传输 Token
- 反向代理层做好 TLS 配置
5. 前端存储位置要谨慎
这个问题没有绝对标准,但要了解取舍:
localStorage:实现简单,但更容易受 XSS 影响HttpOnly Cookie:更安全,不易被 JS 读取,但要配合 CSRF 防护设计
如果是纯 API + 前后端分离场景,很多团队会先用 Authorization: Bearer Token。但上线前一定要把 XSS 防护补好。
6. 权限信息尽量轻量
有些项目会把几十上百个权限点都塞进 JWT。这样做的问题是:
- Token 变大,请求头开销上升
- 权限调整后旧 Token 不易同步失效
- 网关和服务之间传输成本增加
更稳妥的做法:
- JWT 里只放核心标识
- 细粒度权限可从缓存/数据库动态查询
- 高频接口配合本地缓存或 Redis 缓存
7. 数据库方案落地建议
当你从示例代码切到生产实现时,建议表结构至少包括:
sys_usersys_rolesys_permissionsys_user_rolesys_role_permission
授权加载时:
- 先查用户
- 再查角色
- 再查权限
- 最终统一转换为
GrantedAuthority
比如:
- 角色:
ROLE_ADMIN - 权限:
report:delete
这样 URL 级和方法级控制都比较灵活。
八、从示例到生产:怎么演进
示例代码适合入门和主链路打通,但真实项目还需要继续往前走。
阶段 1:先跑通认证主链路
目标:
- 登录成功拿 Token
- Token 可解析
- 接口权限能区分 USER / ADMIN
阶段 2:接入数据库
替换 CustomUserDetailsService 的内存实现,改成:
- 根据用户名查用户
- 查询角色和权限
- 封装为
UserDetails
阶段 3:增加统一返回与异常处理
建议补上:
- 统一响应结构
- 全局异常处理
- 登录失败提示规范化
- 审计日志
阶段 4:增加 Refresh Token 和退出登录
适用场景:
- 需要较好用户体验
- 需要支持续期
- 需要支持主动下线
阶段 5:网关/微服务统一鉴权
如果是微服务架构,还可以把 JWT 校验前移到:
- Spring Cloud Gateway
- API Gateway
- 统一认证中心
这样业务服务只关注授权,不必每个服务重复写一套。
九、总结
这篇文章我们完整实现了一个 Spring Boot + Spring Security + JWT 的权限认证示例,核心链路包括:
- 用
AuthenticationManager做登录认证 - 用
JwtService签发与解析 Token - 用自定义过滤器把用户信息写入
SecurityContext - 用 URL 规则和
@PreAuthorize实现接口级访问控制 - 区分 401 与 403,提升问题定位效率
如果你准备把它真正用到项目里,我的建议很明确:
- 先做最小闭环:登录、鉴权、角色控制先跑通
- 不要急着把所有权限都塞进 JWT
- Access Token 设置短有效期
- 生产环境必须上 HTTPS
- 需要“踢下线/注销立即失效”时,引入 Redis 黑名单或 Token 版本控制
- 中大型系统尽量采用用户-角色-权限三层模型
最后给一个很实用的边界判断:
- 如果你的系统只是一个简单后台,角色很少,JWT + 角色控制已经够用
- 如果你的系统有复杂菜单、按钮、数据范围权限,就不要只停留在
hasRole(),要继续往权限点和领域授权演进
认证授权这块,最怕的不是“复杂”,而是“半懂不懂还到处散着写”。把链路收拢到 Spring Security 里,你后面维护会轻松很多。