Spring Boot 3 中基于 JWT 与 Spring Security 6 的前后端分离登录鉴权实战
前后端分离项目里,登录鉴权几乎是每个团队都会自己“再做一遍”的模块。看起来像是老生常谈,但一旦上手到 Spring Boot 3 + Spring Security 6,很多人会发现:配置方式变了、过滤器链写法变了、JWT 接入点也和旧版本不一样了。
这篇文章我不打算只讲概念,而是带你从一个常见场景出发,完整实现一套:
- 用户登录获取 JWT
- 前端携带 Token 访问受保护接口
- Spring Security 6 解析并校验 Token
- 基于角色做接口授权
- 处理常见报错与排查路径
文章以“能跑起来”为目标,同时尽量讲清楚背后的原理和边界。
背景与问题
在传统服务端渲染时代,登录态通常依赖 Session + Cookie。但到了前后端分离架构,常见的情况是:
- 前端是 Vue / React / 小程序 / App
- 后端提供 REST API
- 服务可能部署成多实例
- 接口需要无状态化,方便扩缩容和网关转发
这时候,如果还强依赖 Session,就会遇到几个问题:
- 服务端要保存会话状态
- 多节点部署需要共享 Session
- 跨端访问时,Cookie 管理没那么自然
- 接口化场景下,更适合令牌方式认证
于是 JWT(JSON Web Token)就成了很常见的选择。
但 JWT 也不是“开箱即安全”,很多项目会踩这些坑:
- 只会生成 Token,不会把它正确接到 Spring Security 过滤器链里
- 忘了关闭 CSRF,导致 POST 请求 403
- 角色前缀
ROLE_配错,结果授权一直失败 - Token 过期、签名错误时,返回信息不明确
- 把敏感信息塞进 JWT Payload,误以为“加密”了
所以,真正的实战关键不是“会不会生成 JWT”,而是:如何把 JWT 融入 Spring Security 6 的认证与授权流程。
前置知识与环境准备
技术栈
本文示例使用:
- JDK 17
- Spring Boot 3.x
- Spring Security 6
- Maven
- JWT:
jjwt - 数据层为了聚焦鉴权流程,先用内存用户演示,可运行、易理解
Maven 依赖
先创建一个 Spring Boot 3 项目,加入以下依赖:
<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.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
核心原理
先别急着写代码,先把流程捋顺。只要你理解了这条链路,Spring Security 的配置就不会显得“玄学”。
1. 登录与鉴权分成两件事
- 登录(Authentication):你是谁?
- 授权(Authorization):你能访问什么?
在 JWT 场景下:
- 用户登录时,服务端校验账号密码
- 校验通过后,签发 JWT 给前端
- 前端以后每次请求都带上 JWT
- 服务端解析 JWT,恢复用户身份和权限
- Spring Security 决定该请求是否允许访问
2. 为什么 JWT 适合前后端分离
JWT 的特点是:
- 无状态:服务端不需要保存会话
- 可携带用户标识与权限信息
- 适合跨服务传递身份
但注意一个常被误解的点:
JWT 默认是“签名防篡改”,不是“加密防偷窥”。
也就是说,Payload 里的内容别人是能解码看到的,所以不要把密码、身份证号、银行信息这类敏感数据放进去。
3. Spring Security 6 的关键位置
在 Spring Security 6 里,我们一般会做这些事:
- 配置
SecurityFilterChain - 放行登录接口
- 对其他接口要求认证
- 自定义一个 JWT 过滤器
- 在过滤器里:
- 读请求头
Authorization - 提取 Bearer Token
- 校验签名和过期时间
- 从 Token 中拿到用户名和角色
- 构建
Authentication - 放进
SecurityContextHolder
- 读请求头
这样后面的授权判断就能正常工作了。
整体流程图
先看系统的请求流转。
flowchart TD
A[前端提交用户名密码] --> B[/api/auth/login]
B --> C{账号密码是否正确}
C -- 否 --> D[返回 401]
C -- 是 --> E[生成 JWT]
E --> F[前端保存 Token]
F --> G[请求受保护接口]
G --> H[Authorization: Bearer Token]
H --> I[JWT 过滤器解析 Token]
I --> J{Token 是否有效}
J -- 否 --> K[返回 401]
J -- 是 --> L[写入 SecurityContext]
L --> M[进入控制器]
M --> N{是否有访问权限}
N -- 否 --> O[返回 403]
N -- 是 --> P[返回业务数据]
再看一次更细一点的时序。
sequenceDiagram
participant Client as 前端
participant AuthController as 登录接口
participant AuthManager as AuthenticationManager
participant JwtUtil as JWT工具类
participant JwtFilter as JWT过滤器
participant Api as 业务接口
Client->>AuthController: POST /api/auth/login
AuthController->>AuthManager: 校验用户名密码
AuthManager-->>AuthController: 认证成功
AuthController->>JwtUtil: 生成Token
JwtUtil-->>Client: 返回JWT
Client->>JwtFilter: GET /api/user/profile + Bearer Token
JwtFilter->>JwtUtil: 解析并校验Token
JwtUtil-->>JwtFilter: 用户名、角色
JwtFilter->>Api: 放行并附带认证信息
Api-->>Client: 返回业务数据
实战代码(可运行)
下面给出一套最小可运行版本。为了让流程更清晰,我先用内存用户模拟数据库用户,等你理解之后,再替换成 UserDetailsService + MySQL 非常顺手。
1. 项目结构建议
src/main/java/com/example/demo
├── DemoApplication.java
├── config
│ └── SecurityConfig.java
├── controller
│ ├── AuthController.java
│ └── UserController.java
├── dto
│ ├── LoginRequest.java
│ └── LoginResponse.java
├── security
│ ├── JwtAuthenticationFilter.java
│ └── JwtUtil.java
2. application.yml
JWT 密钥一定要足够长。我建议至少 256 bit 对称密钥。
server:
port: 8080
jwt:
secret: "my-super-secret-key-my-super-secret-key-123456"
expiration: 3600000
3. 启动类
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
4. DTO
LoginRequest.java
package com.example.demo.dto;
import jakarta.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.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 工具类
JwtUtil.java
package com.example.demo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
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.util.Date;
import java.util.List;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
private SecretKey secretKey;
@PostConstruct
public void init() {
byte[] keyBytes = secret.getBytes();
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(UserDetails userDetails) {
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("roles", roles)
.issuedAt(now)
.expiration(expiryDate)
.signWith(secretKey)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
public String extractUsername(String token) {
return parseToken(token).getSubject();
}
public boolean isTokenExpired(String token) {
Date expirationDate = parseToken(token).getExpiration();
return expirationDate.before(new Date());
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
}
这里我没有把密码或其他敏感字段放进 Token,只放了用户名和角色。大部分业务里,这就够用了。
6. JWT 过滤器
JwtAuthenticationFilter.java
package com.example.demo.security;
import io.jsonwebtoken.Claims;
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.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
Claims claims = jwtUtil.parseToken(token);
String username = claims.getSubject();
@SuppressWarnings("unchecked")
List<String> roles = claims.get("roles", List.class);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
List<SimpleGrantedAuthority> authorities = roles == null
? Collections.emptyList()
: roles.stream().map(SimpleGrantedAuthority::new).toList();
User userDetails = new User(username, "", authorities);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}
这段代码的核心作用就一句话:
把 JWT 中的信息恢复成 Spring Security 能识别的
Authentication。
7. Security 配置
SecurityConfig.java
package com.example.demo.config;
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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
return new InMemoryUserDetailsManager(
User.withUsername("admin")
.password(passwordEncoder.encode("123456"))
.roles("ADMIN")
.build(),
User.withUsername("user")
.password(passwordEncoder.encode("123456"))
.roles("USER")
.build()
);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider authenticationProvider(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder
) {
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 SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider(userDetailsService(passwordEncoder()), passwordEncoder()))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
这里有几个关键点:
SessionCreationPolicy.STATELESS:明确告诉系统不用 Sessioncsrf.disable():前后端分离、JWT 场景下通常关闭addFilterBefore(...):让 JWT 过滤器在用户名密码过滤器之前执行hasRole("ADMIN")会匹配权限ROLE_ADMIN
这也是很多人第一个会踩的坑:你以为你配置的是
ADMIN,实际上 Spring Security 底层比对的是ROLE_ADMIN。
8. 登录接口
AuthController.java
package com.example.demo.controller;
import com.example.demo.dto.LoginRequest;
import com.example.demo.dto.LoginResponse;
import com.example.demo.security.JwtUtil;
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.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
public AuthController(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
}
@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 = jwtUtil.generateToken(userDetails);
return new LoginResponse(token);
}
}
9. 受保护接口
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;
import java.util.Map;
@RestController
public class UserController {
@GetMapping("/api/user/profile")
public Map<String, Object> profile(Authentication authentication) {
return Map.of(
"message", "这是用户信息接口",
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
@GetMapping("/api/admin/dashboard")
public Map<String, Object> dashboard(Authentication authentication) {
return Map.of(
"message", "这是管理员面板接口",
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
}
逐步验证清单
到这里代码已经齐了,下面一步一步测。
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 -X GET 'http://localhost:8080/api/user/profile' \
-H 'Authorization: Bearer 你的token'
3. 访问管理员接口
curl -X GET 'http://localhost:8080/api/admin/dashboard' \
-H 'Authorization: Bearer 你的token'
4. 使用普通用户登录再测管理员接口
如果你用 user / 123456 登录,再访问 /api/admin/dashboard,应该得到 403 Forbidden。
这就说明两件事都生效了:
- 认证成功:JWT 能识别用户身份
- 授权生效:角色判断工作正常
用类图理解职责划分
如果你喜欢从结构上理解代码,可以看这个简单类图。
classDiagram
class AuthController {
+login(LoginRequest) LoginResponse
}
class SecurityConfig {
+securityFilterChain(HttpSecurity) SecurityFilterChain
+authenticationManager(AuthenticationConfiguration) AuthenticationManager
+userDetailsService(PasswordEncoder) UserDetailsService
}
class JwtAuthenticationFilter {
-JwtUtil jwtUtil
+doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain)
}
class JwtUtil {
+generateToken(UserDetails) String
+parseToken(String) Claims
+extractUsername(String) String
+isTokenValid(String, UserDetails) boolean
}
AuthController --> JwtUtil
SecurityConfig --> JwtAuthenticationFilter
JwtAuthenticationFilter --> JwtUtil
常见坑与排查
这一节我尽量讲得“接地气”一点。很多问题看上去像配置错了,但其实都有固定排查路径。
1. 登录接口直接 403
现象
请求 /api/auth/login,还没进控制器就返回 403。
常见原因
- 没关闭 CSRF
- 登录接口没有
permitAll()
排查方法
检查 SecurityFilterChain:
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.anyRequest().authenticated()
)
如果是前后端分离 + JWT,绝大多数情况下应该关闭 CSRF。
2. Token 带了,但接口还是 401
现象
前端明明带了 JWT,请求受保护接口还是未认证。
常见原因
Authorization请求头没传- 前缀不是
Bearer - JWT 过滤器没有加入过滤器链
- Token 已过期
- 签名密钥不一致
建议排查顺序
- 浏览器开发者工具确认请求头是否真的带上了
- 确认格式是否为:
Authorization: Bearer xxxxx - 确认是否有:
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - 打印过滤器日志,看是否进入过滤器
- 检查 Token 是否过期、是否来自当前环境
我当时第一次接 Spring Security 6 时,就是把过滤器写好了,但忘了加到过滤器链里,结果看了半天代码都没发现问题。
3. 明明有管理员角色,却总是 403
现象
认证成功,但访问管理员接口总被拒绝。
根因
多半是 ROLE_ 前缀问题。
比如:
- 你配置
hasRole("ADMIN") - 但 Token 中存的是
ADMIN - Spring 期望的权限实际上是
ROLE_ADMIN
解决方式
要么统一使用 roles("ADMIN"),让 Spring 自动补前缀;
要么统一使用 hasAuthority("ROLE_ADMIN") 并手动存完整权限名。
本文里采用的是 Spring 默认习惯:
- 用户构建时用
roles("ADMIN") - Token 中保存的是
ROLE_ADMIN - 授权时用
hasRole("ADMIN")
这一套最省心。
4. 密钥长度报错
现象
启动时报错,大意类似:
The specified key byte array is 该算法不够长
原因
HMAC SHA 算法对密钥长度有要求,太短会报错。
解决
把 jwt.secret 设置得足够长,至少 32 字节以上更稳妥。
5. 过滤器吞掉异常,调试困难
在示例里,我是这样写的:
} catch (Exception ex) {
SecurityContextHolder.clearContext();
}
这在最小演示里没问题,但正式项目里建议:
- 记录日志
- 区分异常类型(过期、签名错误、格式错误)
- 返回更明确的错误码
否则线上一旦“全是 401”,你会很难快速判断到底是过期还是签名问题。
安全/性能最佳实践
JWT 很方便,但不能因为方便就放松安全要求。这部分是实战里真正影响上线质量的地方。
1. Access Token 要短时效
很多项目喜欢把 Token 设成 7 天、30 天,图省事。我的建议是:
- Access Token:15 分钟 ~ 2 小时
- Refresh Token:单独设计、长期一些
本文为了演示配成了 1 小时,但生产上最好结合业务风险来定。
2. 不要把敏感信息放进 JWT
不要放这些:
- 明文密码
- 手机号完整信息
- 身份证号
- 银行卡号
- 详细权限树数据
JWT 适合放“身份标识 + 少量授权信息”,不要变成“小型用户档案”。
3. 生产环境密钥不要写死在代码里
示例里为了演示放在配置文件里,但正式环境建议:
- 环境变量
- KMS / 密钥管理服务
- 配置中心加密存储
至少不要把生产密钥直接提交到 Git 仓库。
4. 建议引入 Refresh Token 机制
单 JWT 方案有个痛点:
- Token 太短,用户体验差
- Token 太长,风险增大
常见折中方案:
- 登录返回 Access Token + Refresh Token
- Access Token 短期有效
- Refresh Token 用于续签
- Refresh Token 可落库并支持吊销
如果你的系统有“退出登录后立刻失效”的强需求,只靠无状态 JWT 往往不够,通常还要配合黑名单或 Refresh Token 存储。
5. 网关和服务间认证要区分场景
如果你是微服务架构,不要把“浏览器访问后端接口”和“服务间调用”混成一种认证方式。
常见拆分方式:
- 用户访问:JWT
- 服务间调用:内部 Token、OAuth2、mTLS 等
否则后面权限边界会越来越乱。
6. 控制 JWT 体积
JWT 每次请求都会带上,所以别塞太多内容。否则会带来:
- 请求头膨胀
- 网络开销增大
- 网关限制触发
- 性能浪费
通常保存:
sub:用户名 / 用户 IDroles:角色列表iat/exp:签发和过期时间
就已经够大多数接口用了。
7. 给未认证与未授权返回统一 JSON
默认的 Spring Security 报错返回,对前端并不总是友好。建议自定义:
AuthenticationEntryPoint:处理 401AccessDeniedHandler:处理 403
这样前端更容易统一处理登录失效、权限不足等场景。
例如:
http.exceptionHandling(exception -> exception
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未登录或Token无效\"}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"message\":\"无权限访问\"}");
})
);
这一点在前后端联调时特别重要。
从内存用户切到数据库用户,怎么改?
很多人学完示例后,会卡在这一步:“能跑了,但我项目里是 MySQL 用户表,怎么接?”
核心只需要替换一层:UserDetailsService。
大致思路是:
- 用户登录时,
AuthenticationManager调用你的UserDetailsService - 你去数据库查用户
- 把数据库用户封装成
UserDetails - 返回给 Spring Security
伪代码如下:
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(user.getRoleCode())
.disabled(!user.getEnabled())
.build();
}
}
然后在配置类里把内存用户替换掉即可。
注意两点:
- 数据库里的密码必须是加密后的,比如 BCrypt
- 权限字段要和你的授权表达式保持一致
边界条件:什么时候不建议自己手写 JWT?
这篇文章讲的是“自己搭一套轻量 JWT 鉴权”。但也要说清楚边界:
如果你的项目具备这些特点:
- 单点登录(SSO)
- 第三方登录集成较多
- 多客户端统一身份中心
- 多服务复杂权限模型
- 需要 OAuth2 / OpenID Connect 标准化能力
那么建议优先考虑:
- Spring Authorization Server
- Keycloak
- Auth0 / 企业统一身份平台
自己手写 JWT 方案适合:
- 中小型业务系统
- 单体或轻量微服务
- 权限模型相对清晰
- 团队想快速落地前后端分离鉴权
如果上来就是复杂 IAM 需求,手写方案后期维护成本会越来越高。
总结
我们这次完整走了一遍 Spring Boot 3 + Spring Security 6 + JWT 的前后端分离登录鉴权流程,核心脉络其实就三步:
- 登录时校验用户名密码
- 认证成功后签发 JWT
- 后续请求通过过滤器解析 JWT 并恢复认证信息
你真正需要记住的不是某个 API 名字,而是这几个关键点:
- Spring Security 6 的入口是
SecurityFilterChain - JWT 的落点是自定义过滤器
- 无状态认证要配置
SessionCreationPolicy.STATELESS - 前后端分离通常要关闭 CSRF
- 角色权限最容易踩
ROLE_前缀坑
如果你准备把本文方案用于实际项目,我的建议是:
- 先按本文最小示例跑通
- 再替换成数据库用户
- 然后补上统一异常处理
- 最后根据业务引入 Refresh Token、登出失效、黑名单机制
这样推进最稳,也最不容易“越改越乱”。
如果只想一句话概括这套方案的落地原则,那就是:
让登录简单,让鉴权清晰,让 Token 足够轻,别把 JWT 用成万能状态仓库。
祝你这次别再被 401 和 403 折腾半天。