Spring Boot 3 实战:基于 Spring Security 与 JWT 的前后端分离鉴权体系搭建与权限控制
前后端分离之后,登录鉴权这件事不再像传统 MVC 那样“放在 Session 里就完事”。接口要无状态、移动端和 Web 端要共用、权限还得细到接口级别,这时候 Spring Security + JWT 基本就是一条很常见也很实用的路线。
这篇文章我不打算只讲概念,而是带你从一个可运行的 Spring Boot 3 项目出发,真正搭一套:
- 登录接口
- JWT 签发与校验
- Spring Security 过滤器链接入
- 基于角色/权限的接口访问控制
- 常见报错与排查方式
- 安全与性能上的实际建议
如果你已经会 Spring Boot,但对 Spring Security 3.x 时代的新写法还不太熟,这篇会比较适合你。
背景与问题
在前后端分离场景里,常见的几个痛点非常典型:
-
接口怎么识别用户身份?
浏览器不再依赖服务端 Session 页面跳转,API 需要自带身份信息。 -
怎么做无状态认证?
服务部署成多实例后,Session 共享会带来额外复杂度。 -
权限控制怎么落地?
比如:- 普通用户可以查看资料
- 管理员可以访问后台接口
- 某些操作需要
user:create、user:delete这样的细粒度权限
-
Spring Boot 3 / Spring Security 6 和旧版本写法差异大
以前那套WebSecurityConfigurerAdapter已经被废弃,很多教程还停留在旧写法,照抄就会踩坑。
所以,这篇文章的目标不是“讲 JWT 是什么”,而是把这些问题串起来,给出一套中级开发者可以直接上手的方案。
前置知识与环境准备
技术栈
- JDK 17
- Spring Boot 3.x
- Spring Security 6
- Maven
- jjwt(JWT 生成与解析)
- Lombok(可选)
本文示例功能
我们实现以下接口:
POST /auth/login:登录,返回 JWTGET /api/profile:已登录用户可访问GET /api/admin/hello:管理员角色可访问GET /api/user/list:拥有user:read权限才可访问
Maven 依赖
<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.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
核心原理
先别急着写代码,先把链路理顺。很多人不是不会写,而是没把整个认证授权过程串起来。
一次 JWT 鉴权请求的完整流程
flowchart TD
A[用户提交用户名密码] --> B[/auth/login]
B --> C[AuthenticationManager 认证]
C --> D[校验用户名/密码]
D --> E[生成 JWT]
E --> F[返回 access token]
F --> G[前端保存 token]
G --> H[请求业务接口时携带 Authorization Bearer token]
H --> I[JWT 过滤器解析 token]
I --> J[SecurityContext 写入认证信息]
J --> K[Spring Security 做权限判断]
K --> L[返回业务数据或 401/403]
这个流程里最关键的是两件事:
- 认证(Authentication):你是谁?
- 授权(Authorization):你能做什么?
JWT 解决的是“无状态地传递身份信息”,Spring Security 负责“在请求生命周期内识别用户,并做权限决策”。
Spring Security 在这里扮演什么角色
你可以把 Spring Security 理解成一个总控系统:
- 接管请求过滤链
- 处理未登录、无权限、认证失败等场景
- 把登录用户信息放进
SecurityContext - 支持 URL 级和方法级权限控制
JWT 里通常放什么
实际项目中,JWT 里一般放这些内容:
sub:用户标识,比如用户名iat:签发时间exp:过期时间- 自定义 claims:
rolespermissions
不过我个人建议:JWT 中只放必要信息。如果你把所有用户详情都塞进去,token 会膨胀,而且权限一旦变更,旧 token 仍可能继续生效直到过期。
认证和授权的关系
sequenceDiagram
participant Client as 前端
participant Filter as JWT过滤器
participant Security as Spring Security
participant Controller as Controller
Client->>Filter: 携带 Bearer Token 请求
Filter->>Filter: 解析并验证 JWT
alt Token 合法
Filter->>Security: 写入 Authentication
Security->>Controller: 放行并检查权限
Controller-->>Client: 返回数据
else Token 无效/过期
Filter-->>Client: 401 Unauthorized
end
认证通过后,Spring Security 才有基础去判断权限。
如果连用户是谁都不知道,那后面的 hasRole()、hasAuthority() 都无从谈起。
项目结构设计
为了让示例清晰,我们先约定一个简单结构:
src/main/java/com/example/securityjwt
├── SecurityJwtApplication.java
├── config
│ └── SecurityConfig.java
├── controller
│ ├── AuthController.java
│ └── DemoController.java
├── dto
│ ├── LoginRequest.java
│ └── LoginResponse.java
├── security
│ ├── JwtAuthenticationFilter.java
│ ├── JwtService.java
│ └── UserDetailsServiceImpl.java
└── service
└── InMemoryUserService.java
为了方便演示,本文先用内存用户,核心逻辑跑通后你再接数据库。
实战代码(可运行)
1)启动类
package com.example.securityjwt;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SecurityJwtApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityJwtApplication.class, args);
}
}
2)登录请求与响应 DTO
LoginRequest.java
package com.example.securityjwt.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.securityjwt.dto;
import java.util.List;
public class LoginResponse {
private String token;
private String username;
private List<String> authorities;
public LoginResponse(String token, String username, List<String> authorities) {
this.token = token;
this.username = username;
this.authorities = authorities;
}
public String getToken() {
return token;
}
public String getUsername() {
return username;
}
public List<String> getAuthorities() {
return authorities;
}
}
3)JWT 工具服务
JwtService.java
package com.example.securityjwt.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.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class JwtService {
// 这里用 Base64 编码后的 256 bit key,生产环境请放到安全配置中心或环境变量
private static final String SECRET_KEY =
"Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyMTIzNDU2Nzg5MDEyMw==";
private static final long EXPIRATION = 1000L * 60 * 60 * 2; // 2小时
public String generateToken(UserDetails userDetails) {
Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
List<String> authorityList = authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
return Jwts.builder()
.setSubject(userDetails.getUsername())
.claim("authorities", authorityList)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public List<String> extractAuthorities(String token) {
Claims claims = extractAllClaims(token);
Object authorities = claims.get("authorities");
if (authorities instanceof List<?>) {
return ((List<?>) authorities).stream()
.map(String::valueOf)
.collect(Collectors.toList());
}
return List.of();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractAllClaims(token).getExpiration().before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private SecretKey getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
return Keys.hmacShaKeyFor(keyBytes);
}
}
4)用户服务:先用内存模拟
这里我建议把“用户数据来源”跟 Spring Security 的 UserDetailsService 分开,这样后面你切数据库时不会全改。
InMemoryUserService.java
package com.example.securityjwt.service;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class InMemoryUserService {
private final Map<String, AppUser> users = new HashMap<>();
public InMemoryUserService(PasswordEncoder passwordEncoder) {
users.put("admin", new AppUser(
"admin",
passwordEncoder.encode("123456"),
List.of("ROLE_ADMIN", "ROLE_USER", "user:read", "user:create", "user:delete")
));
users.put("tom", new AppUser(
"tom",
passwordEncoder.encode("123456"),
List.of("ROLE_USER", "user:read")
));
}
public AppUser findByUsername(String username) {
return users.get(username);
}
public static class AppUser {
private final String username;
private final String password;
private final List<String> authorities;
public AppUser(String username, String password, List<String> authorities) {
this.username = username;
this.password = password;
this.authorities = authorities;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public List<String> getAuthorities() {
return authorities;
}
}
}
5)实现 UserDetailsService
UserDetailsServiceImpl.java
package com.example.securityjwt.security;
import com.example.securityjwt.service.InMemoryUserService;
import com.example.securityjwt.service.InMemoryUserService.AppUser;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final InMemoryUserService inMemoryUserService;
public UserDetailsServiceImpl(InMemoryUserService inMemoryUserService) {
this.inMemoryUserService = inMemoryUserService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
AppUser appUser = inMemoryUserService.findByUsername(username);
if (appUser == null) {
throw new UsernameNotFoundException("用户不存在");
}
return User.builder()
.username(appUser.getUsername())
.password(appUser.getPassword())
.authorities(appUser.getAuthorities().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList()))
.build();
}
}
6)JWT 认证过滤器
这个过滤器的作用是:
- 从请求头拿
Authorization - 提取 Bearer Token
- 解析用户名
- 加载用户
- 校验 token
- 成功后写入
SecurityContext
JwtAuthenticationFilter.java
package com.example.securityjwt.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
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 UserDetailsServiceImpl userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsServiceImpl userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String jwt = authHeader.substring(7);
String username;
try {
username = jwtService.extractUsername(jwt);
} 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(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
7)Spring Security 配置
Spring Boot 3 下最重要的变化之一:使用 SecurityFilterChain Bean,而不是继承 WebSecurityConfigurerAdapter。
SecurityConfig.java
package com.example.securityjwt.config;
import com.example.securityjwt.security.JwtAuthenticationFilter;
import com.example.securityjwt.security.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
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.configuration.EnableWebSecurity;
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
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsServiceImpl userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
UserDetailsServiceImpl userDetailsService) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
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)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login").permitAll()
.requestMatchers(HttpMethod.GET, "/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/user/**").hasAuthority("user:read")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationProvider 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();
}
}
8)登录接口
AuthController.java
package com.example.securityjwt.controller;
import com.example.securityjwt.dto.LoginRequest;
import com.example.securityjwt.dto.LoginResponse;
import com.example.securityjwt.security.JwtService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@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(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
org.springframework.security.core.userdetails.UserDetails userDetails =
(org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal();
String token = jwtService.generateToken(userDetails);
List<String> authorities = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
return new LoginResponse(token, userDetails.getUsername(), authorities);
}
}
9)受保护接口
DemoController.java
这里我同时演示 URL 级权限控制和方法级权限控制。
package com.example.securityjwt.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class DemoController {
@GetMapping("/api/profile")
public Map<String, Object> profile(Authentication authentication) {
return Map.of(
"message", "这是当前登录用户的信息",
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
@GetMapping("/api/admin/hello")
public Map<String, Object> adminHello() {
return Map.of("message", "你好,管理员");
}
@PreAuthorize("hasAuthority('user:read')")
@GetMapping("/api/user/list")
public Map<String, Object> userList() {
return Map.of(
"message", "你拥有 user:read 权限",
"data", java.util.List.of("tom", "jack", "lucy")
);
}
}
10)可选:统一异常返回更友好
默认的 401/403 返回有时候不够前后端协作使用,建议统一成 JSON。
SecurityExceptionHandlers.java
package com.example.securityjwt.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import java.util.Map;
@Configuration
public class SecurityExceptionHandlers {
@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, authException) -> {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
new ObjectMapper().writeValue(response.getWriter(),
Map.of("code", 401, "message", "未登录或 Token 无效"));
};
}
@Bean
public AccessDeniedHandler accessDeniedHandler() {
return (request, response, accessDeniedException) -> {
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
new ObjectMapper().writeValue(response.getWriter(),
Map.of("code", 403, "message", "无权限访问"));
};
}
}
然后把它们接入 SecurityConfig:
package com.example.securityjwt.config;
import com.example.securityjwt.security.JwtAuthenticationFilter;
import com.example.securityjwt.security.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
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.configuration.EnableWebSecurity;
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.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final AccessDeniedHandler accessDeniedHandler;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
UserDetailsServiceImpl userDetailsService,
AuthenticationEntryPoint authenticationEntryPoint,
AccessDeniedHandler accessDeniedHandler) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.userDetailsService = userDetailsService;
this.authenticationEntryPoint = authenticationEntryPoint;
this.accessDeniedHandler = accessDeniedHandler;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login").permitAll()
.requestMatchers(HttpMethod.GET, "/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/user/**").hasAuthority("user:read")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationProvider 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();
}
}
逐步验证清单
代码写完后,不要一把梭直接说“怎么 403 了”。我一般会按这个顺序验证。
第一步:登录获取 Token
请求:
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"tom\",\"password\":\"123456\"}"
响应示例:
{
"token": "eyJhbGciOiJIUzI1NiJ9.xxx.yyy",
"username": "tom",
"authorities": ["ROLE_USER", "user:read"]
}
第二步:访问登录用户接口
curl http://localhost:8080/api/profile \
-H "Authorization: Bearer 你的token"
第三步:验证管理员接口
使用 tom 的 token 访问:
curl http://localhost:8080/api/admin/hello \
-H "Authorization: Bearer 你的token"
预期结果:403
使用 admin 登录后再访问,预期结果:200
第四步:验证权限接口
使用 tom 或 admin 的 token 访问:
curl http://localhost:8080/api/user/list \
-H "Authorization: Bearer 你的token"
预期结果:都能访问,因为两者都有 user:read
权限控制怎么设计更合理
很多团队在这里容易混淆 ROLE_ 和普通权限字符串,我建议分层设计。
角色与权限的建议分工
-
角色(Role):偏粗粒度,面向身份
ROLE_ADMINROLE_USER
-
权限(Authority/Permission):偏细粒度,面向动作
user:readuser:createorder:refund
一个常见关系是:
- 管理员角色 -> 拥有多个权限
- 普通角色 -> 拥有有限权限
为什么 hasRole("ADMIN") 对应的是 ROLE_ADMIN
因为 Spring Security 对 hasRole("ADMIN") 会自动加前缀 ROLE_。
所以你的用户权限里应该存的是:
ROLE_ADMIN
而不是单独的:
ADMIN
这真的是高频坑,我自己第一次切 Spring Security 时也在这里卡过半天。
一张关系图看明白
classDiagram
class User {
+String username
+String password
}
class Role {
+String code
}
class Permission {
+String code
}
User --> Role : 拥有
Role --> Permission : 包含
如果你接数据库,最终一般会把:
- 用户 -> 角色
- 角色 -> 权限
查出来后,统一转成 GrantedAuthority 集合交给 Spring Security。
常见坑与排查
这一部分非常重要。很多“代码看起来没问题”的问题,其实都在细节里。
1)明明登录成功了,请求接口还是 401
常见原因
- 没带
Authorization请求头 - 请求头格式不对,少了
Bearer - token 已过期
- JWT 密钥不一致
- 过滤器没有加入过滤链
排查建议
先打印请求头:
System.out.println(request.getHeader("Authorization"));
确认格式必须像这样:
Authorization: Bearer eyJhbGciOi...
再确认过滤器是否执行到了:
System.out.println("JwtAuthenticationFilter executed");
2)接口返回 403,不是 401
这说明一件事:你已经登录了,但权限不足。
常见原因
hasRole("ADMIN")但用户只有ADMIN,没有ROLE_ADMINhasAuthority("user:read")但 token 里的权限字符串不一致- URL 规则和方法注解同时存在,结果被更严格规则拦了
排查建议
打印当前用户权限:
authentication.getAuthorities()
如果结果不是你预期的那组权限,优先排查:
UserDetailsService是否加载正确- JWT 是否包含你想要的权限
- JWT 过滤器里是否真的把认证信息写进了
SecurityContext
3)密码明明一样,登录总是失败
常见原因
- 你数据库里存的是明文,但配置了
BCryptPasswordEncoder - 你每次启动都重新编码,拿新 hash 去比旧 hash
- 前端传值字段不对
正确理解
BCrypt 每次编码结果都可能不同,但 matches(raw, encoded) 仍然能验证通过。
所以不要自己拿两个编码后的字符串做 equals。
4)跨域请求失败,前端说被 CORS 拦住了
如果你的前端和后端不是同域,JWT 带请求头时很容易碰到 CORS 问题。
可以加一个简单的 CORS 配置:
package com.example.securityjwt.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.cors.CorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("Authorization"));
config.setAllowCredentials(false);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
如果你是纯 JWT + Header 模式,通常不需要 allowCredentials(true)。
5)升级到 Spring Boot 3 后旧教程代码全失效
这个问题太常见了。
典型失效点
WebSecurityConfigurerAdapter不再推荐使用antMatchers()改为requestMatchers()javax.servlet.*变成jakarta.servlet.*
解决思路
看到旧教程时先做三件事:
- 查 Spring Security 版本
- 查 Spring Boot 版本
- 看它是不是基于 Boot 2.x 写的
如果版本代际不一致,照抄大概率会出问题。
安全/性能最佳实践
JWT 很方便,但不是“用了就安全”。下面这些建议我认为比“会写代码”更重要。
1)不要把敏感信息放进 JWT
JWT 的 payload 只是 Base64Url 编码,不是加密。
所以不要放:
- 明文密码
- 手机号全量
- 身份证号
- 银行卡信息
建议只放最小必要信息,比如 sub 和少量权限标识。
2)Access Token 要短有效期
我更推荐:
- Access Token:15 分钟 ~ 2 小时
- Refresh Token:更长,比如 7 天
本文为了聚焦主线,只实现了 access token。
如果是正式系统,建议补上 refresh token 机制,而不是把 access token 过期时间拉很长。
3)密钥一定要妥善管理
不要把密钥硬编码在代码仓库里。生产环境建议放在:
- 环境变量
- Kubernetes Secret
- 配置中心
- 云 KMS / Secret Manager
如果密钥泄露,攻击者可以伪造合法 token,这个风险是致命的。
4)考虑 Token 失效与踢下线问题
JWT 是无状态的,但这也带来一个经典问题:
服务端默认无法“立即废掉”已签发 token。
常见做法有:
- 缩短 access token 生命周期
- 配合 refresh token
- 使用 Redis 做 token 黑名单
- 在 token 中带版本号,用户被禁用后提升版本使旧 token 失效
如果你的业务有强制下线、账号封禁、修改密码后立即失效的需求,这个点必须提前设计。
5)权限不要完全只信任 JWT
理论上可以把权限列表直接放 JWT 里,但生产中要看场景:
- 如果权限变化不频繁,可以接受 token 周期内的延迟生效
- 如果权限变化非常频繁,建议每次请求从缓存/数据库动态获取最新权限,或者做 token 版本校验
这本质上是“性能和一致性”的平衡问题。
6)接口层之外,方法级权限控制更稳
只在 URL 上做权限校验,后期很容易漏。
我更建议:
- 网关/过滤链做基础鉴权
- Controller 或 Service 上做
@PreAuthorize - 关键操作再做业务层校验
这样即便接口路径变化,也不容易出现“URL 改了但权限漏配”的问题。
7)给认证链路加日志,但别打敏感信息
建议记录:
- 登录成功/失败
- token 解析失败次数
- 权限拒绝事件
- 异常 IP / 用户行为
但不要记录:
- 明文密码
- 完整 JWT
- 敏感个人信息
日志里最多打印 token 前几位做定位即可。
可扩展方向:接数据库应该怎么改
如果你要把示例升级成真实项目,通常改这几处就够了:
数据表设计建议
最基础的 RBAC 设计:
sys_usersys_rolesys_permissionsys_user_rolesys_role_permission
后端改造点
InMemoryUserService替换为数据库查询- 登录后从数据库查出角色和权限
- 组装成
GrantedAuthority - JWT 中只保留必要标识
- 高频权限可放 Redis 缓存
一般查询流程
flowchart LR
A[username] --> B[查询用户]
B --> C[查询用户角色]
C --> D[查询角色权限]
D --> E[组装 GrantedAuthority]
E --> F[交给 Spring Security]
如果用户量大、权限关系复杂,这一段建议缓存,不然每次认证都打数据库会比较重。
一个更贴近项目的落地建议
如果你是团队里要真正把方案落地的人,我建议按下面的优先级推进:
第一阶段:先跑通主链路
- 登录成功
- JWT 发放
- 业务接口能识别当前用户
- 401 和 403 区分清楚
第二阶段:补权限模型
- 角色
- 权限点
@PreAuthorize- 管理端菜单权限映射
第三阶段:补生产能力
- refresh token
- Redis 黑名单
- 登录审计日志
- 密钥安全管理
- 限流与暴力破解防护
很多系统之所以后面难维护,不是因为技术选型错,而是第一版把“演示代码”直接当“生产方案”用了。
总结
这篇文章我们用 Spring Boot 3 + Spring Security 6 + JWT 搭了一套前后端分离鉴权体系,核心落地点有这几个:
- 使用
SecurityFilterChain配置 Spring Security - 用
AuthenticationManager完成用户名密码认证 - 用 JWT 保存无状态登录凭证
- 用自定义过滤器在每次请求中恢复用户身份
- 同时支持角色控制和细粒度权限控制
- 区分好 401(未认证)和 403(无权限)
- 提前考虑 token 失效、权限变更和密钥管理
如果你现在只是要做一个中小型后台系统,这套方案已经足够实用。
但边界条件也要明确:
- 权限变更需要立刻生效:仅靠纯 JWT 不够,要加缓存校验或黑名单
- 需要多端长期登录:建议引入 refresh token
- 安全要求高:补上登录限流、设备管理、审计日志和密钥轮换
最后给一个很实用的建议:
先把“认证链路跑通”,再去追求“权限设计优雅”。
因为认证没跑通时,你看到的所有 401/403 都像一团雾;一旦主链路清晰了,后面的角色、权限、菜单、数据范围控制,都会顺很多。