Java Web开发实战:基于Spring Boot与JWT实现前后端分离的登录鉴权与权限控制
前后端分离项目里,登录这件事看起来简单,真正落地时却经常“坑味十足”:
- 登录接口能返回 token,但后续接口总是 401
- token 能解析,但权限判断不生效
- 前端退出登录了,后端 token 还有效
- 测试环境好好的,一到线上就因为时区、密钥、跨域、网关转发出问题
这篇文章我不打算只讲概念,而是带你从一个可运行的 Spring Boot 示例出发,完整实现:
- 用户登录
- 服务端签发 JWT
- 请求自动携带 token
- Spring Security 校验身份
- 基于角色/权限做接口访问控制
如果你已经有 Spring Boot 基础,但对 JWT 和权限控制还停留在“会配,不太懂原理”的阶段,这篇文章适合你。
一、背景与问题
在传统服务端渲染时代,登录态通常保存在 Session 中。浏览器第一次登录后,后端把 Session ID 放进 Cookie,后续请求靠 Cookie 找回用户状态。
但到了前后端分离场景,尤其是:
- 前端是 Vue / React
- 后端是 REST API
- 还可能有移动端、小程序、第三方系统接入
- 系统可能被拆成多个服务
这时候基于 Session 的方案会遇到几个问题:
1. 服务端状态难扩展
Session 保存在服务端,多实例部署要共享 Session,否则登录后请求打到另一台机器就失效。
2. 前后端跨域下处理更麻烦
Cookie、跨域、SameSite、CSRF 这些问题会成套出现。
3. 接口认证需要更统一
移动端、Web、网关、微服务之间更适合走一个统一的 Bearer Token 机制。
于是 JWT 成了非常常见的方案。
二、核心原理
先别急着上代码,先把登录鉴权链路想清楚。
2.1 JWT 是什么
JWT(JSON Web Token)本质上是一段字符串,通常由三部分组成:
- Header:声明算法类型
- Payload:声明用户信息、过期时间等
- Signature:签名,防止篡改
格式像这样:
header.payload.signature
JWT 不是加密,默认只是 Base64Url 编码,所以不要把敏感信息直接塞进 payload,比如密码、身份证号、银行卡号。
2.2 一次完整的登录流程
sequenceDiagram
participant U as 用户
participant F as 前端
participant B as Spring Boot后端
participant S as Spring Security
U->>F: 输入用户名密码
F->>B: POST /auth/login
B->>S: 校验用户名密码
S-->>B: 校验通过
B-->>F: 返回 JWT
F->>F: 本地保存 token
F->>B: 请求业务接口并携带 Authorization: Bearer token
B->>S: JWT过滤器解析 token
S-->>B: 设置认证信息到上下文
B-->>F: 返回业务数据
2.3 权限控制是怎么生效的
权限控制通常分成两层:
- 认证 Authentication:你是谁?
- 授权 Authorization:你能做什么?
比如:
/user/profile:只要登录即可访问/admin/**:必须具备ROLE_ADMIN/order/delete:必须具备order:delete权限
在 Spring Security 中,我们通常会把:
- 用户身份放进
Authentication - 角色/权限放进
GrantedAuthority
后续通过注解或配置做控制。
2.4 核心组件关系
flowchart LR
A[前端登录请求] --> B[认证控制器 AuthController]
B --> C[AuthenticationManager]
C --> D[UserDetailsService]
D --> E[加载用户/角色/权限]
C --> F[认证成功]
F --> G[JwtService生成Token]
G --> H[前端保存Token]
H --> I[携带Bearer Token访问接口]
I --> J[JwtAuthenticationFilter]
J --> K[SecurityContextHolder]
K --> L[接口权限判断]
三、前置知识与环境准备
3.1 技术栈
本文示例基于:
- JDK 17
- Spring Boot 3.2.x
- Spring Security 6
- jjwt 0.11.5
- Maven
3.2 示例目标
我们要实现下面这组接口:
POST /auth/login:登录并返回 tokenGET /user/profile:登录用户可访问GET /admin/hello:仅管理员可访问
3.3 项目结构
src/main/java/com/example/jwtdemo
├── JwtDemoApplication.java
├── config
│ └── SecurityConfig.java
├── controller
│ ├── AdminController.java
│ ├── AuthController.java
│ └── UserController.java
├── model
│ ├── LoginRequest.java
│ └── LoginResponse.java
├── security
│ ├── CustomUserDetailsService.java
│ ├── JwtAuthenticationFilter.java
│ └── JwtService.java
四、实战代码(可运行)
下面这套代码可以直接拼成一个最小可运行项目。为了聚焦 JWT 流程,我这里先用内存用户模拟数据库用户;你接入 MySQL 时,我后面会讲改造点。
4.1 Maven 依赖
pom.xml
<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.10</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>
<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>
</project>
4.2 配置文件
src/main/resources/application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expiration: 3600000
spring:
jackson:
serialization:
indent-output: true
这里的 jwt.secret 只是演示用途。线上一定要换成高强度随机密钥,后面会细讲。
4.3 启动类
JwtDemoApplication.java
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.4 登录请求与响应模型
model/LoginRequest.java
package com.example.jwtdemo.model;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
model/LoginResponse.java
package com.example.jwtdemo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class LoginResponse {
private String token;
private String tokenType;
}
4.5 JWT 工具服务
security/JwtService.java
package com.example.jwtdemo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("authorities", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.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 boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
public boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(
java.util.Base64.getEncoder().encodeToString(secret.getBytes())
);
return Keys.hmacShaKeyFor(keyBytes);
}
}
说明:这里为了演示方便,用字符串配置生成密钥。更规范的方式是直接提供 Base64 编码后的强随机密钥。
4.6 自定义用户加载服务
security/CustomUserDetailsService.java
package com.example.jwtdemo.security;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if ("admin".equals(username)) {
return User.withUsername("admin")
.password("$2a$10$N9qo8uLOickgx2ZMRZo5i.u1vY8D3RT5tZ342XQI3/OQvPsvBLn8y")
.authorities(List.of(
new SimpleGrantedAuthority("ROLE_ADMIN"),
new SimpleGrantedAuthority("user:read"),
new SimpleGrantedAuthority("admin:read")
))
.build();
}
if ("user".equals(username)) {
return User.withUsername("user")
.password("$2a$10$N9qo8uLOickgx2ZMRZo5i.u1vY8D3RT5tZ342XQI3/OQvPsvBLn8y")
.authorities(List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("user:read")
))
.build();
}
throw new UsernameNotFoundException("用户不存在");
}
}
上面两个用户的密码都是:
password
4.7 JWT 过滤器
这个过滤器负责:
- 从请求头提取
Authorization - 判断是否是
Bearer token - 解析 token 中的用户名
- 加载用户信息
- 校验 token
- 把认证信息放入 Spring Security 上下文
security/JwtAuthenticationFilter.java
package com.example.jwtdemo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
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
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String username;
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
try {
username = jwtService.extractUsername(jwt);
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
4.8 Spring Security 配置
这是整篇文章最关键的地方。
config/SecurityConfig.java
package com.example.jwtdemo.config;
import com.example.jwtdemo.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
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.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;
import com.example.jwtdemo.security.CustomUserDetailsService;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomUserDetailsService 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("/error").permitAll()
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这里有两个要点:
SessionCreationPolicy.STATELESS:明确告诉 Spring Security,我们不用 SessionaddFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class):JWT 过滤器要在用户名密码认证过滤器之前执行
4.9 登录接口
controller/AuthController.java
package com.example.jwtdemo.controller;
import com.example.jwtdemo.model.LoginRequest;
import com.example.jwtdemo.model.LoginResponse;
import com.example.jwtdemo.security.JwtService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
@PostMapping("/login")
public LoginResponse login(@Valid @RequestBody LoginRequest request) {
try {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String token = jwtService.generateToken(userDetails);
return new LoginResponse(token, "Bearer");
} catch (BadCredentialsException e) {
throw new RuntimeException("用户名或密码错误");
}
}
}
4.10 业务接口与权限注解
controller/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.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class UserController {
@GetMapping("/user/profile")
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
public Map<String, Object> profile(Authentication authentication) {
return Map.of(
"message", "当前用户信息",
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
}
controller/AdminController.java
package com.example.jwtdemo.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class AdminController {
@GetMapping("/admin/hello")
@PreAuthorize("hasRole('ADMIN')")
public Map<String, Object> hello() {
return Map.of(
"message", "只有管理员才能访问这个接口"
);
}
}
五、运行与验证
项目启动后,我们按顺序验证。
5.1 获取 token
使用 user/password 登录:
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"password"}'
返回示例:
{
"token": "eyJhbGciOiJIUzI1NiJ9....",
"tokenType": "Bearer"
}
5.2 访问普通用户接口
curl http://localhost:8080/user/profile \
-H "Authorization: Bearer 你的token"
返回示例:
{
"message": "当前用户信息",
"username": "user",
"authorities": [
{
"authority": "ROLE_USER"
},
{
"authority": "user:read"
}
]
}
5.3 访问管理员接口
如果用 user 的 token:
curl http://localhost:8080/admin/hello \
-H "Authorization: Bearer 你的token"
你会得到 403。
如果用 admin/password 登录,再访问,就会成功。
5.4 权限判断流程图
flowchart TD
A[请求进入接口] --> B{是否携带Bearer Token}
B -- 否 --> C[匿名访问]
B -- 是 --> D[解析JWT]
D --> E{签名/过期校验是否通过}
E -- 否 --> C
E -- 是 --> F[加载用户详情]
F --> G[写入SecurityContext]
G --> H{接口权限是否满足}
H -- 否 --> I[返回403]
H -- 是 --> J[返回业务结果]
六、接入数据库时怎么改
上面的示例为了简单,用户信息写死在 CustomUserDetailsService 里。实际项目一般会从数据库读取。
一个常见做法是设计三张表:
sys_usersys_rolesys_permission
关系通常是:
- 用户和角色:多对多
- 角色和权限:多对多
比如:
classDiagram
class User {
Long id
String username
String password
Integer status
}
class Role {
Long id
String roleCode
String roleName
}
class Permission {
Long id
String permCode
String permName
}
User --> Role : many-to-many
Role --> Permission : many-to-many
然后在 loadUserByUsername 中:
- 根据用户名查用户
- 查用户拥有的角色
- 查角色对应的权限
- 组装为
GrantedAuthority
伪代码大概是这样:
@Override
public UserDetails loadUserByUsername(String username) {
SysUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
List<String> roles = roleRepository.findRoleCodesByUserId(user.getId());
List<String> permissions = permissionRepository.findPermCodesByUserId(user.getId());
List<GrantedAuthority> authorities = new ArrayList<>();
roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
permissions.forEach(perm -> authorities.add(new SimpleGrantedAuthority(perm)));
return User.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(authorities)
.disabled(user.getStatus() != 1)
.build();
}
注意这里角色前缀的处理:
hasRole("ADMIN")对应权限字符串应为ROLE_ADMIN- 如果你只存
ADMIN,那加载时要手动补上ROLE_
这是我见过非常高频的一个坑。
七、常见坑与排查
这一节很重要。很多人其实不是不会写,而是写完后“不知道为什么不生效”。
7.1 明明登录成功,访问接口还是 401
可能原因
1)没带 Authorization 请求头
应该是:
Authorization: Bearer xxxxx
不是:
Authentication: Bearer xxxxx
也不是只传 token 字符串。
2)Bearer 前缀少了空格
必须是:
Bearer 空格 token
3)JWT 过滤器没有加入过滤链
确认你配置了:
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
4)token 已经过期
可以打印过期时间排查:
System.out.println(jwtService.extractExpiration(token));
7.2 明明 token 解析成功,但权限注解不生效
常见原因
1)没开启方法级权限
确认配置类上有:
@EnableMethodSecurity
2)角色前缀写错
如果你用了:
@PreAuthorize("hasRole('ADMIN')")
那么权限列表里必须是:
ROLE_ADMIN
如果你的权限实际是 ADMIN,就会失败。
3)把角色和权限混用了
比如你写的是:
@PreAuthorize("hasAuthority('ADMIN')")
但实际存的是:
ROLE_ADMIN
这也不匹配。
7.3 返回 403,不是 401,到底说明什么
这个问题很经典:
- 401 Unauthorized:通常表示“你还没通过认证”,比如没 token、token 无效
- 403 Forbidden:通常表示“你身份是有效的,但权限不够”
排查思路:
- 先确认
SecurityContext里有没有认证信息 - 再打印当前用户的
authorities - 最后核对注解表达式
可以临时在接口里打印:
@GetMapping("/debug/me")
public Object me(Authentication authentication) {
return authentication;
}
如果这里拿到的是 null,说明认证链路就没走通。
7.4 登录接口总是 403
通常是因为:
- 没放行
/auth/login - CSRF 没关闭,但你又是纯前后端分离 API 场景
当前文中的配置已经处理了:
.requestMatchers("/auth/login").permitAll()
.csrf(csrf -> csrf.disable())
7.5 跨域导致前端看起来像“登录失败”
实际上后端可能已经成功返回 token,但浏览器因为跨域策略拦截了响应。
如果前端和后端域名不同,需要配置 CORS。比如:
@Bean
public org.springframework.web.cors.CorsConfigurationSource corsConfigurationSource() {
var configuration = new org.springframework.web.cors.CorsConfiguration();
configuration.setAllowedOrigins(java.util.List.of("http://localhost:5173"));
configuration.setAllowedMethods(java.util.List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(java.util.List.of("*"));
configuration.setAllowCredentials(true);
var source = new org.springframework.web.cors.UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
如果你线上需要多个来源,别偷懒直接全放开,风险不小。
八、安全/性能最佳实践
JWT 好用,但不是“配上就安全”。这里给你几个真正落地时必须考虑的点。
8.1 不要把敏感信息放进 JWT
JWT payload 虽然有签名,但默认不加密。别人拿到 token 后,可以轻松解码出内容。
不要放:
- 明文密码
- 手机号全量
- 身份证号
- 银行卡号
- 超大对象
建议放:
- 用户 ID
- 用户名
- 角色/权限摘要
- 签发时间
- 过期时间
- token 唯一标识
jti
8.2 token 过期时间别设太长
很多项目为了“省事”,把 token 有效期设成 7 天、30 天,甚至永久有效。这样一旦泄露,风险非常大。
更稳妥的做法:
- Access Token:15 分钟 ~ 2 小时
- Refresh Token:7 天 ~ 30 天
本文为了简化没有展开 Refresh Token,但生产环境强烈建议你采用双 token 机制。
8.3 退出登录不能只靠前端删 token
前端删除本地 token 只能算“用户设备上的退出”,并不能让服务端立即失效。
如果业务对安全要求高,建议做:
- token 黑名单
- refresh token 存库
- 单点登录踢下线
- 修改密码后让旧 token 全部失效
一种常见思路是把 jti 存进 Redis,退出时加入黑名单,过滤器校验时额外检查。
8.4 权限变更后的旧 token 问题
如果用户在 token 生效期间被撤销权限,但旧 token 里还带着历史权限,就会出现“权限延迟生效”。
解决办法通常有三种:
- 缩短 token 生命周期
- 每次请求都查数据库权限
- 引入 token 版本号 / 权限版本号
经验上:
- 权限变更不频繁、接口并发高:优先短 token
- 权限要求极高:每次实时查权限或加 Redis 缓存
8.5 密钥管理要正规
不要把密钥:
- 写死在代码里
- 提交到 Git
- 多环境共用同一份
建议:
- 使用环境变量或配置中心
- 区分 dev/test/prod
- 定期轮换密钥
- 轮换时支持双密钥过渡验证
8.6 注意过滤器里的异常处理
本文示例中,为了让流程清晰,token 解析异常时直接放过请求,让后续流程返回未认证结果。
真实项目里你最好统一返回标准错误码,比如:
{
"code": 40101,
"message": "token无效或已过期"
}
可以通过自定义:
AuthenticationEntryPointAccessDeniedHandler
来统一处理 401/403 响应格式。
8.7 性能上的现实建议
JWT 常被认为“无状态、性能高”,但也要看你怎么用。
如果你每次请求都:
- 解析 token
- 查数据库用户
- 查数据库角色
- 查数据库权限
那它并不会特别轻。
更合理的做法:
- token 中带必要的身份信息
- 用户基础信息放缓存
- 权限信息放 Redis 或本地缓存
- 对高敏感接口再走实时校验
不要一上来就追求“绝对无状态”,那在复杂权限系统里通常不现实。
九、逐步验证清单
如果你准备自己从零搭一遍,我建议按下面顺序验证,别一口气全写完再查 bug。
第一步:只打通登录
/auth/login能返回 token
第二步:只做登录态识别
- 携带 token 调
/user/profile - 能拿到当前用户名称
第三步:再做角色控制
user能访问/user/profileuser不能访问/admin/helloadmin能访问/admin/hello
第四步:补统一异常处理
- token 缺失返回 401
- token 过期返回 401
- 权限不足返回 403
第五步:接数据库与缓存
- 用户表
- 角色表
- 权限表
- Redis 缓存或黑名单机制
这个节奏很重要。很多人是登录、权限、异常处理、数据库、刷新 token 一起上,最后根本分不清是哪一层坏了。
十、适合生产环境的扩展方向
如果你已经把本文示例跑通,接下来可以继续演进:
1. 引入 Refresh Token
减少频繁登录,同时避免 access token 有效期过长。
2. 支持基于菜单/资源的权限模型
比如后端权限码和前端路由按钮联动。
3. 接入 Redis 黑名单
实现退出登录、踢人下线、密码修改后强制失效。
4. 统一认证中心
多个服务共享签发与校验规则,避免每个服务各写一套。
5. 网关层前置校验
如果是微服务架构,可在网关先做 token 校验和基础身份透传。
十一、总结
这篇文章我们做了一件很“实战”的事:
用 Spring Boot + Spring Security + JWT,完整走通了前后端分离项目中最核心的认证授权链路。
你应该掌握了这些关键点:
- JWT 解决的是前后端分离中的无状态认证问题
- Spring Security 负责认证上下文与权限判断
- JWT 过滤器负责把 token 解析后放进
SecurityContext @PreAuthorize可以优雅地做角色/权限控制- 401 和 403 的含义不同,排查方向也不同
- 生产环境里必须考虑 token 过期、退出登录、权限变更、密钥管理等问题
如果你现在要把它真正用到项目里,我的建议很明确:
- 先跑通最小链路:登录、带 token、识别用户、拦管理员接口
- 再接数据库:别一开始就上完整 RBAC
- 最后补安全细节:刷新 token、黑名单、统一异常、密钥轮换
边界条件也要记住:
JWT 不是万能钥匙。
如果你的系统对“立即失效”“精细权限变更”“会话可控性”要求极高,就不要迷信纯 JWT 无状态方案,适当引入 Redis、会话存储或认证中心,反而更稳。
如果你愿意,我个人建议你在自己的项目里先复刻这篇文章的最小 demo,再把用户信息从内存实现替换成数据库查询。走完这一步,你对 Spring Security + JWT 的理解会比只看配置强很多。