Spring Boot 3 中基于 JWT 与 Spring Security 6 的权限认证实战:从登录鉴权到接口级访问控制
很多团队一上来做后端权限系统,第一反应是“先把登录做出来”。结果登录能用,接口却开始混乱:
- 有的接口谁都能调
- 有的接口 token 明明没过期却返回 401
- 角色权限写死在代码里,后面越改越痛苦
- Spring Boot 2 升到 3 之后,之前的
WebSecurityConfigurerAdapter全部失效
这篇文章我想带你完整走一遍:在 Spring Boot 3 + Spring Security 6 中,用 JWT 实现登录认证、请求鉴权,以及接口级访问控制。重点不是“把代码贴出来”,而是让你知道每一层到底在干什么,出了问题从哪里查。
文章会给出一套可运行的最小示例,适合你直接改造成自己的项目。
背景与问题
在传统的 Session 模式里,服务端需要保存登录状态。这个方案本身没问题,但在下面这些场景里会显得笨重:
- 前后端分离
- 多服务部署
- 网关转发
- 移动端 / 小程序 / 第三方调用
- 想让认证信息天然支持“无状态”
于是很多项目会选择 JWT(JSON Web Token):
- 用户登录成功后,服务端签发 token
- 客户端后续请求都带上 token
- 服务端验证 token 是否有效,并从中恢复用户身份与权限
听起来很顺,但真正落地时会遇到两个典型问题:
-
认证和授权混在一起
- 认证:你是谁
- 授权:你能访问什么
-
Spring Security 6 配置方式变了
- 不再用
WebSecurityConfigurerAdapter - 要通过
SecurityFilterChain、Bean 配置和过滤器链来组织
- 不再用
如果你只会“抄一段配置”,后面很容易在 401/403、过滤器顺序、权限表达式这些地方卡住。
前置知识与环境准备
本文示例基于以下环境:
- JDK 17+
- Spring Boot 3.x
- Spring Security 6
- Maven
- JWT 库:
jjwt - 密码加密:
BCryptPasswordEncoder
我们要实现的目标
- 用户访问
/api/auth/login,提交用户名密码 - 后端校验成功后返回 JWT
- 客户端请求受保护接口时在 Header 中带上 token
- Spring Security 解析 token,恢复用户身份
- 根据角色或权限控制接口访问
核心原理
先别急着写代码,先把整个链路捋顺。
1. 认证流程
sequenceDiagram
participant C as Client
participant A as AuthController
participant AM as AuthenticationManager
participant U as UserDetailsService
participant J as JwtService
C->>A: POST /api/auth/login 用户名/密码
A->>AM: authenticate(username, password)
AM->>U: loadUserByUsername()
U-->>AM: UserDetails(含角色权限)
AM-->>A: 认证成功
A->>J: 生成 JWT
J-->>A: token
A-->>C: 返回 token
2. 请求鉴权流程
flowchart LR
A[客户端请求] --> B[JwtAuthenticationFilter]
B --> C{Header中有Bearer Token?}
C -- 否 --> D[匿名访问/进入后续鉴权]
C -- 是 --> E[解析并校验JWT]
E --> F{Token有效?}
F -- 否 --> G[返回401]
F -- 是 --> H[构造Authentication放入SecurityContext]
H --> I[Spring Security执行权限判断]
I --> J{有权限?}
J -- 否 --> K[返回403]
J -- 是 --> L[进入Controller]
3. 认证与授权的边界
这个边界一定要清楚:
-
认证 Authentication
- 校验用户名密码
- 校验 token 是否有效
- 得出“当前用户是谁”
-
授权 Authorization
- 当前用户是否具备
ROLE_ADMIN - 当前用户是否拥有
sys:user:list - 是否允许访问某个 URL 或方法
- 当前用户是否具备
4. JWT 里通常放什么
一般会放这些字段:
sub:用户名或用户唯一标识iat:签发时间exp:过期时间- 自定义 claims:
rolesuserIdpermissions
不过我个人建议:
- 角色可以放
- 细粒度权限是否放 token,要看场景
- 放进去:减少查库,但权限变更不能立刻生效
- 不放:每次查库更实时,但开销更大
教程里我们先放角色,保持实现清晰。
项目结构
一个比较实用的结构如下:
src/main/java/com/example/securitydemo
├── SecurityDemoApplication.java
├── config
│ └── SecurityConfig.java
├── controller
│ ├── AuthController.java
│ └── UserController.java
├── dto
│ ├── LoginRequest.java
│ └── LoginResponse.java
├── entity
│ └── LoginUser.java
├── filter
│ └── JwtAuthenticationFilter.java
├── service
│ ├── CustomUserDetailsService.java
│ └── JwtService.java
└── exception
└── RestAuthenticationEntryPoint.java
实战代码(可运行)
下面给出一个最小可运行版本。为了让重点放在安全链路上,这里先用内存用户模拟用户数据。
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
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>security-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
<jjwt.version>0.12.5</jjwt.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>${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>
</dependencies>
</project>
2. application.yml
JWT 密钥建议放配置中心或环境变量,这里先放配置文件演示。
server:
port: 8080
jwt:
secret: 01234567890123456789012345678901
expiration: 3600000
secret至少要足够长。HS256 下太短会直接报错。
3. 启动类
package com.example.securitydemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SecurityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityDemoApplication.class, args);
}
}
4. DTO
LoginRequest.java
package com.example.securitydemo.dto;
public class LoginRequest {
private String username;
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.securitydemo.dto;
public class LoginResponse {
private String token;
private String tokenType = "Bearer";
public LoginResponse() {
}
public LoginResponse(String token) {
this.token = token;
}
public String getToken() {
return token;
}
public String getTokenType() {
return tokenType;
}
public void setToken(String token) {
this.token = token;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
}
5. 自定义用户对象
这里直接继承 Spring Security 的 User 也可以,但为了说明结构,我单独封装一个用户类。
LoginUser.java
package com.example.securitydemo.entity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
public class LoginUser implements UserDetails {
private final String username;
private final String password;
private final Collection<? extends GrantedAuthority> authorities;
public LoginUser(String username, String password,
Collection<? extends GrantedAuthority> authorities) {
this.username = username;
this.password = password;
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
6. UserDetailsService
我们先用内存模拟两个用户:
admin / 123456,角色ADMINuser / 123456,角色USER
CustomUserDetailsService.java
package com.example.securitydemo.service;
import com.example.securitydemo.entity.LoginUser;
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 ("admin".equals(username)) {
return new LoginUser(
"admin",
"$2a$10$DowJonesIndexf8mQv8nM9uM2KQ4x1vYl6k5YvVxj6QeQ1B1Xx7G", // 123456
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
);
}
if ("user".equals(username)) {
return new LoginUser(
"user",
"$2a$10$DowJonesIndexf8mQv8nM9uM2KQ4x1vYl6k5YvVxj6QeQ1B1Xx7G", // 123456
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
}
throw new UsernameNotFoundException("用户不存在");
}
}
上面的加密串为了演示不够稳妥。为了避免你复制后因为密文不匹配踩坑,我更建议你直接在配置类里打印一个新的 BCrypt 值,或者换成下面这个方式:启动时统一生成测试用户。
更可靠的版本如下。
CustomUserDetailsService.java(建议使用)
package com.example.securitydemo.service;
import com.example.securitydemo.entity.LoginUser;
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 new LoginUser(
"admin",
passwordEncoder.encode("123456"),
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
);
}
if ("user".equals(username)) {
return new LoginUser(
"user",
passwordEncoder.encode("123456"),
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
}
throw new UsernameNotFoundException("用户不存在");
}
}
不过这里又有一个问题:每次查询都会重新 encode,导致密码比对失败。这是我见过很多人第一次写 demo 时都会踩的坑。
所以最终正确写法是:预先保存加密后的密码,而不是每次查询再加密。
正确版 CustomUserDetailsService.java
package com.example.securitydemo.service;
import com.example.securitydemo.entity.LoginUser;
import jakarta.annotation.PostConstruct;
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;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final PasswordEncoder passwordEncoder;
private final Map<String, UserDetails> userStore = new ConcurrentHashMap<>();
public CustomUserDetailsService(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
@PostConstruct
public void init() {
userStore.put("admin", new LoginUser(
"admin",
passwordEncoder.encode("123456"),
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
));
userStore.put("user", new LoginUser(
"user",
passwordEncoder.encode("123456"),
List.of(new SimpleGrantedAuthority("ROLE_USER"))
));
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = userStore.get(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
return user;
}
}
7. JWT 服务
JwtService.java
package com.example.securitydemo.service;
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> roles = 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("roles", roles)
.issuedAt(now)
.expiration(expireDate)
.signWith(getSignKey())
.compact();
}
public String extractUsername(String token) {
return parseClaims(token).getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
public 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();
}
}
8. JWT 过滤器
这个过滤器负责:
- 从请求头取出 token
- 校验 token
- 加载用户信息
- 构造
Authentication - 放入
SecurityContext
JwtAuthenticationFilter.java
package com.example.securitydemo.filter;
import com.example.securitydemo.service.CustomUserDetailsService;
import com.example.securitydemo.service.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final CustomUserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService,
CustomUserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
String token = null;
String username = null;
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
token = authHeader.substring(7);
try {
username = jwtService.extractUsername(token);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\":\"无效或过期的Token\"}");
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
filterChain.doFilter(request, response);
}
}
9. 认证失败处理器
默认返回内容对前后端分离不太友好,我们自定义一下 401 响应。
RestAuthenticationEntryPoint.java
package com.example.securitydemo.exception;
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 RestAuthenticationEntryPoint 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("{\"message\":\"未认证,请先登录\"}");
}
}
10. Security 配置
这是 Spring Security 6 的核心配置点。
SecurityConfig.java
package com.example.securitydemo.config;
import com.example.securitydemo.exception.RestAuthenticationEntryPoint;
import com.example.securitydemo.filter.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final RestAuthenticationEntryPoint authenticationEntryPoint;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
RestAuthenticationEntryPoint authenticationEntryPoint) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.authenticationEntryPoint = authenticationEntryPoint;
}
@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))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
}
11. 登录接口
AuthController.java
package com.example.securitydemo.controller;
import com.example.securitydemo.dto.LoginRequest;
import com.example.securitydemo.dto.LoginResponse;
import com.example.securitydemo.service.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.AuthenticationException;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
public AuthController(AuthenticationManager authenticationManager,
JwtService jwtService) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
}
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest request) {
try {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
String token = jwtService.generateToken(
(org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal()
);
return new LoginResponse(token);
} catch (AuthenticationException e) {
throw new RuntimeException("用户名或密码错误");
}
}
}
生产环境里别直接抛
RuntimeException,最好统一异常处理,这里先聚焦主线。
12. 受保护资源接口
UserController.java
package com.example.securitydemo.controller;
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")
public class UserController {
@GetMapping("/user/profile")
public Map<String, Object> profile(Authentication authentication) {
return Map.of(
"message", "这是用户资料接口",
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
@GetMapping("/admin/dashboard")
public Map<String, Object> admin(Authentication authentication) {
return Map.of(
"message", "这是管理员面板",
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/user/list")
public Map<String, Object> listUsers() {
return Map.of(
"message", "只有 ADMIN 才能看到用户列表"
);
}
}
接口级访问控制怎么理解
上面的控制其实分成了两层。
第一层:URL 级控制
在 SecurityConfig 里:
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
适合控制“这一类接口谁能访问”。
第二层:方法级控制
在 Controller 或 Service 上:
@PreAuthorize("hasRole('ADMIN')")
适合控制“同一类接口里,某个具体操作更严格”。
我自己的建议是:
- URL 级:做大范围拦截
- 方法级:做关键操作兜底
别只依赖前者。因为项目一大,路由规则很容易漏。
一次完整验证
1. 登录获取 token
curl -X POST 'http://localhost:8080/api/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'
4. 用普通用户访问管理员接口
如果你用 user/123456 登录,再访问 /api/admin/dashboard,应该得到 403 Forbidden。
逐步验证清单
如果你想确保每一步都正确,建议按下面的顺序验:
-
/api/auth/login不带 token 也能访问 - 登录成功能拿到 JWT
- 不带 token 访问
/api/user/profile返回 401 - 带合法 token 访问
/api/user/profile返回 200 - 普通用户访问
/api/admin/**返回 403 - 管理员访问
/api/admin/**返回 200 - token 过期后访问返回 401
这个清单很简单,但真能帮你快速定位是“登录没通”、还是“过滤器没生效”、还是“权限规则写错了”。
更深入一点:类之间的关系
classDiagram
class AuthController {
+login(LoginRequest) LoginResponse
}
class JwtService {
+generateToken(UserDetails) String
+extractUsername(String) String
+isTokenValid(String, UserDetails) boolean
}
class JwtAuthenticationFilter {
+doFilterInternal(req, resp, chain)
}
class CustomUserDetailsService {
+loadUserByUsername(String) UserDetails
}
class SecurityConfig {
+securityFilterChain(HttpSecurity) SecurityFilterChain
+passwordEncoder() PasswordEncoder
+authenticationManager(AuthenticationConfiguration) AuthenticationManager
}
AuthController --> JwtService
AuthController --> SecurityConfig
JwtAuthenticationFilter --> JwtService
JwtAuthenticationFilter --> CustomUserDetailsService
SecurityConfig --> JwtAuthenticationFilter
常见坑与排查
这部分非常重要。我自己做这类认证系统时,真正耗时间的往往不是写代码,而是排这些“看起来差不多、实际上差很多”的问题。
1. 登录成功了,但访问接口还是 401
先查这几个点:
检查请求头格式
必须是:
Authorization: Bearer xxxxx
注意:
Bearer后面有空格- Header 名是
Authorization - token 不要多带引号
检查过滤器是否加入链路
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
如果没加,或者顺序不对,Spring Security 根本不会解析你的 token。
检查 SecurityContext 是否成功写入
过滤器里这一段是关键:
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
没写进去,后续就还是匿名用户。
2. 返回 403,而不是 401
很多人会混淆:
401 Unauthorized:你还没被认证403 Forbidden:你已经认证了,但没权限
比如:
- token 缺失 / 无效 / 过期 -> 一般是 401
- 用户是
ROLE_USER,访问ROLE_ADMIN接口 -> 403
如果你明明没登录却拿到 403,通常要查:
- 是否启用了匿名用户
- 是否异常处理配置不完整
- 是否请求已被识别成“已认证但权限不足”
3. hasRole("ADMIN") 和 hasAuthority("ROLE_ADMIN") 搞混
这是 Spring Security 里经典老坑。
写法一
hasRole("ADMIN")
Spring 内部会自动补成:
ROLE_ADMIN
写法二
hasAuthority("ROLE_ADMIN")
这里就要你自己写全。
所以如果你的权限数据存的是:
ADMIN
那 hasRole("ADMIN") 可以,hasAuthority("ADMIN") 才匹配;
如果你存的是:
ROLE_ADMIN
那 hasAuthority("ROLE_ADMIN") 可以,hasRole("ADMIN") 也可以;
但千万别写成:
hasRole("ROLE_ADMIN")
这样会变成匹配 ROLE_ROLE_ADMIN。
4. 密码明明对,还是认证失败
最常见原因有两个:
原因一:每次查询用户时重新 encode
错误示例:
passwordEncoder.encode("123456")
如果每次加载用户都新生成密文,认证时一定失败。因为 BCrypt 每次加密结果都不同。
原因二:数据库里存的是明文,但你启用了 BCrypt
Spring Security 会用 PasswordEncoder.matches(raw, encoded) 比对。
如果库里不是合法密文,会直接失败。
5. token 没过期,但解析报错
常见原因:
- 密钥变了
- token 被截断
- 本地和线上配置不一致
- 使用了过短的 secret
尤其是在多环境部署时,我建议你把 JWT 密钥统一放环境变量,而不是手动写死在配置文件里。
6. 升级到 Spring Boot 3 后老配置不能用了
如果你之前写过这样的代码:
extends WebSecurityConfigurerAdapter
那在 Spring Security 6 里已经不推荐甚至不可用了。要改成:
- 定义
SecurityFilterChain - 定义
AuthenticationManagerBean - 使用 Lambda DSL 配置
HttpSecurity
这不是“语法升级”那么简单,它直接影响你理解整个安全配置的组织方式。
安全/性能最佳实践
JWT 很方便,但也不是“上了就完事”。下面这些建议,基本都能直接用到生产环境。
1. token 过期时间不要过长
如果 access token 给 7 天、30 天,一旦泄露,风险很大。
建议:
- access token:15 分钟 ~ 2 小时
- refresh token:7 天 ~ 30 天
本文示例只做了 access token,生产里通常还会搭配 refresh token 机制。
2. 不要把敏感信息放进 JWT
JWT 虽然签名后不能随便篡改,但默认不是加密的。
也就是说,别人拿到 token 后,完全可以解码出 payload。
所以不要放:
- 明文密码
- 手机号
- 身份证号
- 银行卡号
- 过多内部业务数据
3. 权限变化频繁时,不要完全依赖 token 内权限
如果你把权限全部塞进 token,会有一个天然问题:
用户权限在后台改了,但旧 token 还没过期。
此时权限不会立刻生效。
适合放 token 的内容:
- 用户 ID
- 用户名
- 稳定角色
不太适合长期放 token 的内容:
- 高频变化的菜单权限
- 临时授权状态
- 租户切换上下文
4. 配合黑名单或版本号机制处理强制下线
JWT 是无状态的,这也是它的优点和短板:
- 优点:不用查 session
- 短板:签发出去后,天然不容易“立即作废”
常见做法有:
- Redis 黑名单:记录被注销的 token
- 用户 tokenVersion:数据库里版本号变了,旧 token 失效
- 缩短 access token 生命周期 + refresh token 轮换
5. 统一异常响应
前后端分离项目里,建议统一返回格式,比如:
{
"code": 401,
"message": "未认证,请先登录",
"data": null
}
这样前端可以统一处理跳转登录、刷新 token、弹错误提示。
6. 尽量把权限控制下沉到 Service 层关键方法
Controller 上写权限很直观,但如果某个 Service 方法既被 HTTP 调用,也被内部任务调用,Controller 上的限制就不够了。
更稳妥的做法:
- Controller 做入口控制
- Service 做关键业务控制
例如:
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) {
...
}
7. 过滤器里不要做太重的逻辑
JWT 过滤器应尽量只做几件事:
- 取 token
- 校验 token
- 恢复身份
不要在过滤器里塞:
- 大量数据库查询
- 复杂权限聚合
- 业务逻辑处理
否则会影响每个请求的性能,也会让排查成本非常高。
生产化改造建议
本文用的是内存用户,真正落地时你大概率会这样演进:
从内存用户切到数据库
你可以把 CustomUserDetailsService 改成查库:
- 用户表
sys_user - 角色表
sys_role - 用户角色关系表
sys_user_role - 权限表
sys_permission - 角色权限关系表
sys_role_permission
典型查询流程:
- 根据用户名查用户
- 查用户关联角色
- 查角色关联权限
- 转成
GrantedAuthority
角色和权限都可以映射成 authority:
ROLE_ADMINsys:user:listsys:user:delete
然后你就可以这样写:
@PreAuthorize("hasAuthority('sys:user:delete')")
这会比单纯角色控制更细。
一个更贴近实际的权限设计思路
如果你的系统已经开始变复杂,我建议采用下面这套分层:
-
认证层
- 登录
- token 签发
- token 校验
-
角色层
- ADMIN / USER / AUDITOR 等
-
权限层
order:createorder:queryorder:refund
-
资源层
- URL
- 方法
- 菜单
- 按钮
很多中型系统最后都会走到“角色 + 权限”并存的设计。
角色适合做粗粒度控制,权限适合做细粒度控制。两者不是二选一。
总结
Spring Boot 3 + Spring Security 6 下做 JWT 认证,真正关键的就三件事:
- 登录时完成认证,签发 JWT
- 请求时通过过滤器校验 JWT,恢复用户身份
- 通过 URL 规则和方法注解完成授权控制
你可以把本文的实现记成一个最小闭环:
AuthenticationManager:负责用户名密码认证JwtService:负责生成和解析 tokenJwtAuthenticationFilter:负责请求时恢复身份SecurityFilterChain:负责整体安全策略@PreAuthorize:负责接口级/方法级权限控制
如果你准备把这套方案用到实际项目,我建议按这个顺序落地:
- 先跑通登录与 token 校验
- 再加 URL 级访问控制
- 再加方法级权限注解
- 最后接数据库、Redis、刷新 token、黑名单
边界条件也要明确:
- 小型单体项目:JWT 未必一定优于 Session
- 多服务、前后端分离、移动端场景:JWT 更常见
- 权限变化很频繁:别把所有权限都硬塞进 token
一句话收尾:JWT 只是认证载体,真正决定系统是否可靠的,是你如何组织 Spring Security 的认证链、权限模型和异常处理。
如果你按这篇文章把示例跑通,再去接数据库权限表,理解会稳很多。