Spring Boot 3 中基于 JWT 与 Spring Security 6 的前后端分离权限认证实战
前后端分离项目里,登录认证几乎是绕不过去的一关。很多同学在 Spring Boot 2 时代用过 WebSecurityConfigurerAdapter,结果一升级到 Spring Boot 3 / Spring Security 6,发现不少写法都“失效”了:配置方式变了、过滤器链变了、权限判断方式也更强调组件化。
这篇文章我会从**“怎么从 0 到 1 搭起来,并且能跑通”的角度,带你做一个基于 JWT + Spring Security 6 的认证授权示例。文章重点不是讲概念大而全,而是讲一个前后端分离项目里真正常用的实现方式**。
背景与问题
在前后端分离场景下,后端一般不再依赖服务端 Session 页面跳转,而更常见的是:
- 用户调用
/login登录 - 服务端校验账号密码
- 登录成功后返回一个 JWT
- 前端把 JWT 存起来
- 后续请求在
Authorization: Bearer <token>中携带 - 后端解析 JWT,识别用户身份与权限
- 决定是否放行请求
这套方案的核心价值有两个:
- 无状态:服务端不用保存登录会话,适合分布式部署
- 适配前后端分离:接口风格自然,前端只需要管理 token
但问题也很集中:
- Spring Security 6 的配置方式和旧版本不同
- JWT 过滤器该挂在哪?
401和403经常搞混- 权限字段是放
roles还是authorities? - token 过期、跨域、异常响应这些细节很容易踩坑
如果这些点你也遇到过,这篇就是给你准备的。
前置知识与环境准备
适合读者
本文默认你已经具备:
- Java 基础
- Spring Boot 基本开发经验
- 知道 REST 接口怎么写
- 了解“登录”和“权限”的基本概念
环境版本
本文示例使用:
- JDK 17
- Spring Boot 3.x
- Spring Security 6.x
- Maven
- jjwt 0.11.5
核心原理
先不急着写代码,我们先把链路捋顺。
1. 整体认证流程
flowchart TD
A[前端提交用户名密码] --> B[/login 接口]
B --> C[AuthenticationManager 校验账号密码]
C -->|成功| D[生成 JWT]
C -->|失败| E[返回 401]
D --> F[前端保存 Token]
F --> G[后续请求携带 Authorization Bearer Token]
G --> H[JWT 过滤器解析 Token]
H --> I[构建 Authentication 放入 SecurityContext]
I --> J[Spring Security 进行权限判断]
J -->|通过| K[访问业务接口]
J -->|拒绝| L[返回 403]
2. Spring Security 6 在这里扮演什么角色?
你可以把它理解成两层:
- 认证(Authentication):你是谁?
- 授权(Authorization):你能访问什么?
在 JWT 方案里:
- 登录时,通过
AuthenticationManager做用户名密码认证 - 登录后,通过自定义 JWT 过滤器从 token 中恢复用户身份
- 最后,框架根据接口权限配置判断能不能访问
3. JWT 为什么适合前后端分离?
JWT 本质上是一个自包含的令牌,里面可以携带:
- 用户名
- 用户 ID
- 权限列表
- 过期时间
服务端不需要再查 Session 就能知道是谁发来的请求。
当然,它也不是银弹:
- token 一旦签发,失效控制不像 Session 那么直接
- 不适合在 token 里塞太多敏感信息
- 要考虑过期、刷新、吊销策略
4. 一次请求经过过滤器链的过程
sequenceDiagram
participant Client as 前端
participant Filter as JwtAuthenticationFilter
participant Security as Spring Security
participant Controller as Controller
Client->>Filter: GET /api/admin/users + Bearer token
Filter->>Filter: 解析 JWT
Filter->>Filter: 校验签名、过期时间
Filter->>Security: 设置 Authentication 到 SecurityContext
Security->>Security: 校验接口权限
alt 有权限
Security->>Controller: 放行请求
Controller-->>Client: 200 OK
else 无权限
Security-->>Client: 403 Forbidden
end
项目结构设计
为了让代码更清晰,我建议按下面方式拆分:
src/main/java/com/example/jwtdemo
├── JwtDemoApplication.java
├── config
│ └── SecurityConfig.java
├── controller
│ ├── AuthController.java
│ └── UserController.java
├── dto
│ ├── LoginRequest.java
│ └── LoginResponse.java
├── security
│ ├── JwtAuthenticationFilter.java
│ ├── JwtTokenProvider.java
│ ├── RestAccessDeniedHandler.java
│ └── RestAuthenticationEntryPoint.java
└── service
└── CustomUserDetailsService.java
实战代码(可运行)
下面给出一个最小可运行版本。为了突出 JWT 与 Security 的主线,示例中的用户数据先写死在内存中。实际项目里你可以替换成数据库查询。
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>jwt-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
<jjwt.version>0.11.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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>
2)配置文件
src/main/resources/application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expiration: 3600000
这里的
secret只是演示,生产环境一定要使用高强度密钥,并通过环境变量或密钥管理服务注入。
3)启动类
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);
}
}
4)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;
}
}
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;
}
}
5)JWT 工具类
JwtTokenProvider.java
package com.example.jwtdemo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long expiration;
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expiration = expiration;
}
public String generateToken(UserDetails userDetails) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
List<String> authorities = userDetails.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
return Jwts.builder()
.setSubject(userDetails.getUsername())
.claim("authorities", authorities)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public String getUsernameFromToken(String token) {
return getClaims(token).getSubject();
}
public List<String> getAuthorities(String token) {
Claims claims = getClaims(token);
return claims.get("authorities", List.class);
}
public boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (Exception ex) {
return false;
}
}
private Claims getClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
6)用户加载逻辑
CustomUserDetailsService.java
这里先使用内存用户模拟。为了更贴近真实项目,我保留了 UserDetailsService 的标准接口,这样以后切换数据库代价很小。
package com.example.jwtdemo.service;
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 User(
"admin",
"$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
List.of(
new SimpleGrantedAuthority("ROLE_ADMIN"),
new SimpleGrantedAuthority("user:read")
)
);
}
if ("user".equals(username)) {
return new User(
"user",
"$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("user:read")
)
);
}
throw new UsernameNotFoundException("用户不存在");
}
}
上面两个用户的密码都是:
password
7)JWT 认证过滤器
JwtAuthenticationFilter.java
这个过滤器负责:
- 从请求头拿到 Bearer Token
- 解析用户名和权限
- 构造
Authentication - 放进
SecurityContextHolder
package com.example.jwtdemo.security;
import com.example.jwtdemo.service.CustomUserDetailsService;
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.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
CustomUserDetailsService userDetailsService) {
this.jwtTokenProvider = jwtTokenProvider;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
String username = jwtTokenProvider.getUsernameFromToken(token);
if (SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
8)认证失败与权限不足响应
RestAuthenticationEntryPoint.java
未登录或 token 非法时返回 401
package com.example.jwtdemo.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 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("{\"code\":401,\"message\":\"未认证或Token无效\"}");
}
}
RestAccessDeniedHandler.java
已登录但权限不足时返回 403
package com.example.jwtdemo.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 RestAccessDeniedHandler 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)Spring Security 6 配置
SecurityConfig.java
这是升级到 Spring Security 6 后最容易改错的部分。重点有三个:
- 使用
SecurityFilterChainBean 配置 - 显式声明
AuthenticationManager - 把 JWT 过滤器放到
UsernamePasswordAuthenticationFilter之前
package com.example.jwtdemo.config;
import com.example.jwtdemo.security.JwtAuthenticationFilter;
import com.example.jwtdemo.security.RestAccessDeniedHandler;
import com.example.jwtdemo.security.RestAuthenticationEntryPoint;
import com.example.jwtdemo.service.CustomUserDetailsService;
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.annotation.web.configurers.AbstractHttpConfigurer;
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;
private final RestAccessDeniedHandler accessDeniedHandler;
private final CustomUserDetailsService userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
RestAuthenticationEntryPoint authenticationEntryPoint,
RestAccessDeniedHandler 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(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exception -> exception
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAuthority("user:read")
.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 configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
10)登录接口与受保护接口
AuthController.java
package com.example.jwtdemo.controller;
import com.example.jwtdemo.dto.LoginRequest;
import com.example.jwtdemo.dto.LoginResponse;
import com.example.jwtdemo.security.JwtTokenProvider;
import jakarta.validation.Valid;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider jwtTokenProvider) {
this.authenticationManager = authenticationManager;
this.jwtTokenProvider = jwtTokenProvider;
}
@PostMapping("/login")
public LoginResponse login(@RequestBody @Valid LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
String token = jwtTokenProvider.generateToken(
(org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal()
);
return new LoginResponse(token);
}
}
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.Map;
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/profile")
public Map<String, Object> profile(Authentication authentication) {
return Map.of(
"message", "当前登录用户信息",
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
@GetMapping("/user/hello")
public Map<String, Object> userHello() {
return Map.of("message", "拥有 user:read 权限即可访问");
}
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/hello")
public Map<String, Object> adminHello() {
return Map.of("message", "只有 ADMIN 可访问");
}
}
运行与验证
启动项目后,可以按下面步骤测试。
1)登录获取 token
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"password"}'
返回示例:
{
"token": "eyJhbGciOiJIUzI1NiJ9....",
"tokenType": "Bearer"
}
2)携带 token 访问个人信息接口
curl http://localhost:8080/api/profile \
-H "Authorization: Bearer 你的token"
3)访问普通权限接口
curl http://localhost:8080/api/user/hello \
-H "Authorization: Bearer 你的token"
4)访问管理员接口
curl http://localhost:8080/api/admin/hello \
-H "Authorization: Bearer 你的token"
如果用 user/password 登录,再访问 /api/admin/hello,应该得到 403。
逐步验证清单
这是我自己排查认证问题时常用的顺序,特别有效。
登录阶段
/login是否放行了?- 用户名密码是否正确?
PasswordEncoder是否和密码加密方式一致?- 是否成功返回 JWT?
携带 token 阶段
- 请求头是否是
Authorization - 前缀是否为
Bearer - token 是否已过期
- JWT 密钥是否一致
权限判断阶段
hasRole("ADMIN")对应的权限是否真的叫ROLE_ADMINhasAuthority("user:read")是否和 token/用户信息一致- 方法级注解是否开启了
@EnableMethodSecurity
常见坑与排查
这一部分非常重要,很多问题不是不会写,而是写完跑不通。
1. 401 和 403 分不清
这是最常见的问题。
401 Unauthorized:你还没有被系统认可,通常是没带 token、token 无效、token 过期403 Forbidden:你已经登录了,但权限不够
很多人看到“访问失败”就一股脑以为是权限问题,实际上可能是过滤器根本没把用户身份放进 SecurityContext。
2. hasRole("ADMIN") 为什么要求权限是 ROLE_ADMIN?
因为 hasRole("ADMIN") 底层会自动补前缀 ROLE_。所以:
.hasRole("ADMIN")
等价于检查:
ROLE_ADMIN
如果你的权限里只有 ADMIN,那就会匹配失败。
建议
- 角色统一用
ROLE_前缀 - 细粒度权限统一用
user:read、user:create这种形式
这样最不容易乱。
3. 登录明明成功,后续接口还是 401
优先检查这几项:
- 前端有没有真的把 token 带上
- 是否写成了
Bearer而不是bearer - 过滤器是不是注册到了正确位置
sessionCreationPolicy是否设置为STATELESS
我之前就踩过一个坑:前端请求封装层里,登录接口和普通接口走了两套拦截器,结果 token 根本没带出去,后端排查半天。
4. 密码正确却提示 Bad Credentials
通常是密码编码器不一致导致的。
例如:
- 数据库里存的是 BCrypt 加密后的密码
- 你却用了明文比较
- 或者用了别的编码器
本文示例用了:
new BCryptPasswordEncoder()
那用户密码就必须是 BCrypt 格式。
如果你要生成一个新的 BCrypt 密码,可以临时写段代码:
package com.example.jwtdemo;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class PasswordTest {
public static void main(String[] args) {
System.out.println(new BCryptPasswordEncoder().encode("password"));
}
}
5. 升级到 Spring Security 6 后旧配置失效
旧版常见写法:
extends WebSecurityConfigurerAdapter
在新版本里已经不推荐这样用了,应该改成基于 SecurityFilterChain 的 Bean 配置。
如果你是老项目升级,优先看以下几点:
- 是否还在继承
WebSecurityConfigurerAdapter - 是否还在用
antMatchers - 是否忘了声明
AuthenticationManager
6. CORS 跨域问题
前后端分离经常前端端口和后端端口不同,这时浏览器会先发预检请求。
如果跨域没处理好,现象常常是:
- 前端提示跨域报错
- 后端你以为是权限问题,其实请求还没真正到业务接口
可以补一个简单的跨域配置:
package com.example.jwtdemo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.*;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
安全/性能最佳实践
JWT 做起来不难,难的是做得“够稳”。
1. 不要把敏感信息直接塞进 JWT
JWT 是可以被解码查看内容的,只要拿到 token,就能看到 payload。
所以不要在里面放:
- 明文密码
- 手机号、身份证号等敏感信息
- 过多业务数据
建议只放最小必要信息:
- 用户唯一标识
- 用户名
- 权限列表
- 过期时间
2. 密钥一定要安全存储
生产环境不要把密钥硬编码在代码仓库里。建议:
- 使用环境变量
- 使用配置中心
- 使用 KMS / Vault 等密钥管理服务
并且要有密钥轮换机制。
3. token 过期时间不要太长
太长会增加泄露风险,太短会影响体验。
一般建议:
- Access Token:15 分钟 ~ 2 小时
- Refresh Token:更长,但必须单独管理
本文示例为了简单只做了 Access Token。真实项目建议补上刷新机制。
4. 重要系统建议做 token 黑名单或版本控制
JWT 的一个天然问题是:一旦签发,在过期前通常都有效。
如果你要支持这些场景:
- 强制下线
- 修改密码后旧 token 失效
- 管理员封禁账号
可以考虑:
- Redis 黑名单
- 用户 tokenVersion 字段
- 登出时记录 token jti
这一步不是所有系统都必须上,但中后台、金融、企业系统通常值得做。
5. 权限不要只靠前端控制
前端按钮隐藏只是“体验优化”,不是安全控制。
真正的权限校验必须在后端完成:
- 路由能否访问由后端接口判定
- 数据能否查询由后端权限控制
- 操作能否执行由后端授权规则决定
这是非常重要的边界。
6. 尽量减少每次请求的数据库查询
JWT 的优势之一就是减少会话查找。
但如果你的过滤器每次都:
- 解析 token
- 再查数据库
- 再拼权限
那性能收益就会被抵消一部分。
常见做法有两种:
- token 中带权限信息,请求时直接恢复
- 配合缓存,用户权限变更后再失效缓存
本文示例为了安全与结构清晰,仍然通过 UserDetailsService 加载用户。生产里你可以根据权限变更频率做取舍。
方案边界与取舍
JWT 不是所有系统都最优,我建议你根据场景选。
适合 JWT 的场景
- 前后端分离
- 微服务网关统一认证
- 多节点部署,不想共享 Session
- APP / 小程序 / SPA 接口认证
不一定适合的场景
- 极强的实时踢下线要求
- 对会话可控性要求很高
- 权限变化非常频繁,且必须立刻生效
这类场景可能要结合:
- Redis 会话
- Refresh Token
- 黑名单
- 网关统一鉴权
权限模型建议
如果你准备把这套方案用到真实项目,权限命名最好尽早定规范。我建议:
角色
表示身份层级,例如:
ROLE_ADMINROLE_USERROLE_MANAGER
权限点
表示操作粒度,例如:
user:readuser:createuser:updateorder:audit
为什么要分开?
因为角色适合做“粗粒度入口控制”,权限点适合做“细粒度操作控制”。
比如:
/api/admin/**只允许管理员进入- 页面里“删除用户”按钮要求
user:delete
这样系统会比较清晰,不容易后期失控。
权限结构示意图
classDiagram
class User {
+Long id
+String username
+String password
}
class Role {
+String code
}
class Permission {
+String code
}
User --> Role : 多对多
Role --> Permission : 多对多
实际项目里,很多团队会采用:
- 用户 -> 角色
- 角色 -> 权限
- 登录时把权限汇总后放入 JWT 或缓存
这是比较经典也容易维护的设计。
总结
到这里,我们已经完整走通了一套基于 Spring Boot 3 + Spring Security 6 + JWT 的前后端分离认证授权方案。核心落地点其实就几件事:
- 用
AuthenticationManager完成登录认证 - 登录成功后签发 JWT
- 用
OncePerRequestFilter在每次请求中解析 token - 将用户身份放入
SecurityContext - 用 URL 规则和方法注解做权限控制
- 明确区分
401与403
如果你准备把它用于真实项目,我建议按这个顺序继续演进:
- 第一步:先把本文最小版本跑通
- 第二步:把内存用户替换为数据库用户
- 第三步:加入角色-权限表模型
- 第四步:补上 Refresh Token
- 第五步:根据业务需要增加黑名单、强制下线、审计日志
最后给你一个很实用的建议:
认证问题排查,永远按“登录成功了吗 -> token 带了吗 -> token 解析成功了吗 -> SecurityContext 里有用户吗 -> 权限字符串匹配了吗”这条链路查。
这样比盲猜快很多,我自己做项目时基本也是这么定位的。
如果你只是想先搭一个稳定的后台接口认证体系,这套方案已经足够作为一个可靠起点。