Java Web 开发实战:基于 Spring Boot + JWT 的用户认证与权限控制设计与落地
在 Java Web 项目里,用户认证和权限控制几乎是绕不开的基础设施。很多团队一开始会直接“先能用再说”:登录成功后把用户信息放 Session,接口里手写 if-else 判断角色。项目小的时候问题不大,一旦前后端分离、服务拆分、客户端变多,这套方案很快就会显得笨重。
这篇文章我换一个更偏“架构落地”的角度来讲:为什么要用 Spring Boot + JWT、它的边界在哪里、如何设计一套可运行、可维护、可扩展的认证与授权机制。我会带你从背景问题、核心原理、方案取舍,一直走到完整代码实现和常见排查。
背景与问题
先看一个很常见的业务场景:
- 用户通过用户名密码登录
- 登录后访问个人资料接口
- 管理员可以访问后台管理接口
- 普通用户只能访问业务接口
- 前后端分离,前端通过
Authorization: Bearer xxx携带登录态 - 后续可能还会有小程序、移动端、内部调用等接入
如果继续用传统 Session,会遇到这些问题:
-
扩展性差
服务横向扩容后,需要共享 Session,通常得引入 Redis 或粘性会话。 -
前后端分离适配不自然
浏览器 Cookie + Session 当然能做,但对多端接入不够统一。 -
服务间传递用户身份麻烦
微服务或网关场景下,Session 状态同步会越来越复杂。 -
权限逻辑分散
认证、角色判断、资源访问控制容易散落在 Controller 和 Service 中。
所以很多团队会采用:
- Spring Security 负责认证授权框架
- JWT(JSON Web Token) 负责无状态身份凭证
- Spring Boot 负责快速集成与工程化落地
但这里有个很重要的现实问题:JWT 不是银弹。
它解决的是“无状态认证凭证”的问题,不是自动解决所有安全问题。比如注销、踢人下线、权限变更实时生效,这些都需要额外设计。
方案概览:我们到底要落地什么
本文给出的目标架构如下:
- 用户登录时,校验用户名密码
- 校验通过后签发 JWT
- JWT 中保存用户标识、用户名、角色等必要信息
- 请求进入系统时,通过过滤器解析 JWT
- 如果 Token 合法,则把用户身份放入 Spring Security 上下文
- 接口通过角色/权限表达式做访问控制
- 对异常、过期、伪造 Token 做统一返回
认证与授权流程图
flowchart LR
A[用户登录 /auth/login] --> B[校验用户名密码]
B -->|成功| C[签发 JWT]
B -->|失败| D[返回 401]
C --> E[客户端保存 Token]
E --> F[访问受保护接口]
F --> G[JWT 过滤器解析 Token]
G -->|合法| H[写入 SecurityContext]
G -->|非法或过期| I[返回 401]
H --> J[Spring Security 鉴权]
J -->|通过| K[执行 Controller]
J -->|拒绝| L[返回 403]
核心模块拆分
classDiagram
class AuthController {
+login(LoginRequest)
+profile()
+admin()
}
class JwtTokenProvider {
+generateToken(username, roles)
+parseToken(token)
+validateToken(token)
}
class JwtAuthenticationFilter {
+doFilterInternal(req, resp, chain)
}
class CustomUserDetailsService {
+loadUserByUsername(username)
}
class SecurityConfig {
+securityFilterChain(http)
+passwordEncoder()
+authenticationManager(config)
}
class UserRepository {
+findByUsername(username)
}
AuthController --> JwtTokenProvider
JwtAuthenticationFilter --> JwtTokenProvider
JwtAuthenticationFilter --> CustomUserDetailsService
CustomUserDetailsService --> UserRepository
SecurityConfig --> JwtAuthenticationFilter
方案对比与取舍分析
在正式写代码之前,先把常见方案说透,避免“会写但不知道为什么这么写”。
1. Session vs JWT
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Session | 简单、服务端易控制 | 分布式扩展麻烦、跨端不自然 | 单体后台系统 |
| JWT | 无状态、适合前后端分离、多端统一 | 注销难、Token 泄露风险大、过期策略要设计 | API 服务、网关场景 |
2. 只做角色控制 vs 角色 + 权限点
| 方案 | 优点 | 缺点 |
|---|---|---|
| 只做角色(ROLE_ADMIN) | 简单直接 | 粒度粗,后期容易失控 |
| 角色 + 权限点(user:read / user:delete) | 精细化、可扩展 | 设计和维护成本更高 |
在中型项目里,我更建议:
- 初期至少支持角色控制
- 如果后台操作复杂,再逐步演进到“角色 + 权限点”
3. Token 里放多少信息
一个常见误区是把用户完整信息都塞到 JWT 中。
我通常建议只放:
- 用户 ID
- 用户名
- 角色列表
- 签发时间、过期时间
不要放:
- 手机号
- 邮箱
- 敏感业务字段
- 会频繁变化的权限明细
因为 JWT 一旦签发,在过期前通常是不可变的。你放进去的信息越多,越容易出现“Token 里还是旧数据”的问题。
核心原理
JWT 的结构一般是三段:
header.payload.signature
例如:
- Header:声明签名算法,如 HS256
- Payload:用户标识、角色、过期时间等
- Signature:用密钥对前两部分签名,防止篡改
请求链路时序
sequenceDiagram
participant Client as 客户端
participant API as Spring Boot API
participant Filter as JWT过滤器
participant Security as Spring Security
participant Controller as Controller
Client->>API: POST /auth/login 用户名密码
API->>Security: 认证
Security-->>API: 认证成功
API-->>Client: 返回 JWT
Client->>API: GET /api/profile Authorization: Bearer Token
API->>Filter: 进入过滤器
Filter->>Filter: 解析并验证 JWT
Filter->>Security: 设置 Authentication
Security->>Controller: 鉴权通过后放行
Controller-->>Client: 返回业务数据
认证与授权的边界
这是很多初学者容易混淆的地方:
- 认证 Authentication:你是谁
- 授权 Authorization:你能做什么
对应到本文设计:
- 登录校验用户名密码,是认证
@PreAuthorize("hasRole('ADMIN')"),是授权
JWT 更多是为认证结果提供一个“可携带、可验证”的凭证;真正的授权逻辑,还是要靠 Spring Security 和你的角色/权限模型。
实战代码(可运行)
下面给出一个可运行的 Spring Boot 3 + Spring Security 6 示例。为方便演示,我使用内存用户仓库,但代码结构已经按真实项目拆好了,后续替换数据库非常顺手。
项目依赖
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>springboot-jwt-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
<jjwt.version>0.12.5</jjwt.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>
配置文件
src/main/resources/application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expiration: 3600000
这里的 secret 只是演示。真实环境请使用高强度随机密钥,至少 256 bit。
启动类
DemoApplication.java
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);
}
}
用户模型与仓库
AppUser.java
package com.example.demo.user;
import java.util.Set;
public class AppUser {
private Long id;
private String username;
private String password;
private Set<String> roles;
public AppUser(Long id, String username, String password, Set<String> roles) {
this.id = id;
this.username = username;
this.password = password;
this.roles = roles;
}
public Long getId() {
return id;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public Set<String> getRoles() {
return roles;
}
}
UserRepository.java
package com.example.demo.user;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Repository;
import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@Repository
public class UserRepository {
private final PasswordEncoder passwordEncoder;
private final Map<String, AppUser> users = new HashMap<>();
public UserRepository(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
@PostConstruct
public void init() {
users.put("admin", new AppUser(
1L,
"admin",
passwordEncoder.encode("123456"),
Set.of("ADMIN", "USER")
));
users.put("tom", new AppUser(
2L,
"tom",
passwordEncoder.encode("123456"),
Set.of("USER")
));
}
public Optional<AppUser> findByUsername(String username) {
return Optional.ofNullable(users.get(username));
}
}
UserDetailsService
CustomUserDetailsService.java
package com.example.demo.security;
import com.example.demo.user.AppUser;
import com.example.demo.user.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;
@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 {
AppUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
return new User(
user.getUsername(),
user.getPassword(),
user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet())
);
}
}
JWT 工具类
JwtTokenProvider.java
package com.example.demo.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
@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) {
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 String getUsernameFromToken(String token) {
return parseClaims(token).getSubject();
}
public boolean validateToken(String token) {
parseClaims(token);
return true;
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
JWT 过滤器
JwtAuthenticationFilter.java
package com.example.demo.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 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 authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
if (jwtTokenProvider.validateToken(token)
&& SecurityContextHolder.getContext().getAuthentication() == null) {
String username = jwtTokenProvider.getUsernameFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"Token 无效或已过期\"}");
return;
}
}
filterChain.doFilter(request, response);
}
}
安全配置
SecurityConfig.java
package com.example.demo.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.*;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.*;
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 SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未认证或认证已失效\"}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"message\":\"无权限访问\"}");
})
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
DTO
LoginRequest.java
package com.example.demo.auth;
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.auth;
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;
}
}
控制器
AuthController.java
package com.example.demo.auth;
import com.example.demo.security.JwtTokenProvider;
import jakarta.validation.Valid;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider jwtTokenProvider) {
this.authenticationManager = authenticationManager;
this.jwtTokenProvider = jwtTokenProvider;
}
@PostMapping("/auth/login")
public LoginResponse login(@Valid @RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
String token = jwtTokenProvider.generateToken((User) authentication.getPrincipal());
return new LoginResponse(token);
}
@GetMapping("/api/profile")
public Map<String, Object> profile(@AuthenticationPrincipal User user) {
return Map.of(
"username", user.getUsername(),
"authorities", user.getAuthorities()
);
}
@GetMapping("/api/admin")
@org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
public Map<String, Object> admin() {
return Map.of(
"message", "只有 ADMIN 角色可以访问这里"
);
}
}
运行与验证
启动项目后,按下面顺序测试。
1. 登录获取 Token
curl -X POST 'http://localhost:8080/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"123456"}'
返回示例:
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"tokenType": "Bearer"
}
2. 访问个人资料接口
curl 'http://localhost:8080/api/profile' \
-H 'Authorization: Bearer 你的token'
3. 访问管理员接口
curl 'http://localhost:8080/api/admin' \
-H 'Authorization: Bearer 你的token'
如果使用 tom 登录,则访问 /api/admin 会返回 403。
容量与演进思考
如果你的项目只是内部管理后台,上面这套方案已经够用。
但如果进入更真实的线上环境,架构上需要提前考虑下面几点。
1. 单体应用阶段
- JWT 签发与验证都在同一个应用内
- 角色信息放 Token 中
- 接口通过 Spring Security 注解控制
这个阶段成本低、交付快。
2. 多服务阶段
当服务拆分后,会出现新问题:
- 每个服务都要验签吗?
- 角色权限变更如何同步?
- 网关是否做统一认证?
比较常见的设计是:
- 网关统一做 Token 基础校验
- 业务服务做二次鉴权
- 用户核心信息通过请求头向下游透传,但要注意防伪造
- 内网服务间调用建议再加签名或 mTLS,不要只信任头信息
3. 权限模型演进
早期:
- 角色:ADMIN / USER
中期:
- 菜单权限
- 按钮权限
- 资源权限
- 数据权限
这时不建议把所有细权限都直接塞到 JWT。更合理的方式是:
- Token 中只保存用户身份与基础角色
- 权限明细从缓存或权限服务加载
- 做本地短时缓存,兼顾性能与实时性
常见坑与排查
这一节我尽量讲得“像真实开发现场一点”。有些问题不是不会写,而是写完了发现为什么总不对。
1. 明明登录成功了,访问接口还是 401
现象
/auth/login正常返回 Token- 带 Token 访问
/api/profile却返回 401
排查顺序
- 确认请求头是否正确
必须是:
Authorization: Bearer xxxxx
不是:
authorization: xxxxx
token: xxxxx
Bearer: xxxxx
-
看过滤器是否执行
在JwtAuthenticationFilter中打印日志,确认请求是否进入过滤器。 -
看 Token 是否过期
如果机器时间不一致,也可能导致误判过期。 -
看
SecurityContextHolder是否成功写入认证信息
如果没写进去,后面的 Spring Security 仍然认为你未登录。
一个常见原因
Token 解析成功了,但 loadUserByUsername 查不到用户,最后照样失败。
这在“用户被删除但旧 Token 还在”时很常见。
2. 返回 403,不是 401
区别
- 401:你没通过认证
- 403:你已经认证了,但没有权限
典型原因
你登录的是 tom,只有 USER 角色,却访问了:
@PreAuthorize("hasRole('ADMIN')")
这不是认证失败,而是授权失败,所以返回 403 是对的。
3. hasRole('ADMIN') 总是不生效
因为 Spring Security 对角色默认有前缀约定:
hasRole("ADMIN")实际匹配的是ROLE_ADMIN
所以你在 GrantedAuthority 里要写:
new SimpleGrantedAuthority("ROLE_ADMIN")
而不是:
new SimpleGrantedAuthority("ADMIN")
这个坑我自己也踩过,第一次看接口一直 403,会怀疑半天表达式,最后发现是角色前缀没对上。
4. JWT secret 太短,启动或签发时报错
如果使用 HS256,密钥长度不能太短。
像 123456 这种在新版本 JJWT 中通常会直接报错。
建议:
- 用随机生成的 32 字节以上字符串
- 生产环境放到环境变量或密钥管理系统中
- 不要硬编码在代码仓库里
5. 注销登录怎么做?
这是 JWT 方案最经典的问题。
因为 JWT 是无状态的,服务端通常不保存会话,所以“签发出去的 Token”在过期前原则上仍然可用。
常见处理方式有三种:
方案 A:短有效期 Token
- Access Token 15~30 分钟
- 到期后重新刷新
优点:简单
缺点:不能做到即时失效
方案 B:黑名单
- 注销时把 Token 或 jti 放进 Redis 黑名单
- 每次请求校验是否命中黑名单
优点:能主动失效
缺点:重新引入状态管理成本
方案 C:Token 版本号
- 用户表增加
tokenVersion - JWT 里带版本号
- 验证时比对数据库或缓存中的最新版本
优点:适合踢人下线、密码修改后强制失效
缺点:需要额外查询或缓存
在中型业务系统中,我更推荐:
- 短期 Access Token + Refresh Token
- 关键场景叠加 Redis 黑名单或版本号机制
安全/性能最佳实践
这一节是“上线前一定要看”的部分。代码能跑,不代表架构真的稳。
安全最佳实践
1. 永远走 HTTPS
JWT 本身只是签名,不是加密。
如果走 HTTP,Token 被抓包就等于身份被盗用。
2. 密钥不要硬编码
建议使用:
- 环境变量
- Docker Secret
- KMS / Vault 等密钥管理服务
3. Token 不要存敏感信息
JWT 默认可被 Base64 解码看到 Payload。
不要把手机号、身份证号、银行卡号等敏感数据放进去。
4. 设定合理过期时间
建议分层设计:
- Access Token:15 分钟 ~ 2 小时
- Refresh Token:7 天 ~ 30 天
如果 Access Token 设置成 7 天,看起来省事,实际上泄露风险很高。
5. 防重放与设备管理
如果业务敏感,可以在 Token 中加入:
jti唯一 IDdeviceIdclientType
再结合 Redis 做在线会话管理。
6. 密码必须加密存储
一定用:
- BCrypt
- Argon2
- PBKDF2
不要自己手写 MD5、SHA1 这类老方案。
性能最佳实践
1. JWT 验签虽然快,但不是零成本
在高并发下,每次请求都做 Token 解析和签名校验,仍然有 CPU 开销。
如果网关层已经做过基础校验,业务服务可以按场景权衡是否重复做完整校验。
2. 权限不要每次都查数据库
更合理的做法:
- 用户基础身份来自 JWT
- 权限数据放 Redis 或本地缓存
- 设置短 TTL,并在权限变更时主动清理
3. 过滤器里少做重操作
JwtAuthenticationFilter 里最好只做:
- 提取 Token
- 验签
- 构造认证对象
不要在里面堆复杂业务逻辑,否则链路抖动会很明显。
4. 统一异常响应,便于观测
认证失败、授权失败、Token 过期、签名错误,最好统一成结构化响应,并打出可检索日志。
这样线上排障效率会高很多。
可落地的增强方案
如果你准备把这套方案真正用到生产环境,我建议进一步补上下面几块。
1. 引入 Refresh Token
当前示例只有一个 Access Token,适合教学和简单系统。
生产中更推荐双 Token 机制:
- Access Token:短期有效
- Refresh Token:专门换新 Access Token
2. 引入用户状态校验
比如用户是否:
- 被禁用
- 被锁定
- 被删除
- 被强制下线
这些状态最好在认证链路中明确处理,而不是只靠 Token 是否合法。
3. 引入细粒度权限模型
例如:
user:listuser:createuser:delete
然后在接口上写:
@PreAuthorize("hasAuthority('user:delete')")
这样比“全靠角色判断”更适合复杂后台。
4. 接入审计日志
至少记录:
- 登录成功/失败
- 权限拒绝
- Token 刷新
- 注销
- 异常访问来源 IP
当安全事件发生时,这些日志真的很关键。
总结
如果用一句话概括这套设计:
Spring Boot + Spring Security + JWT 的组合,适合构建前后端分离、无状态、易扩展的认证与权限控制体系。
但真正落地时,要把这几个事实想清楚:
- JWT 解决的是身份凭证传递,不是全部安全问题
- 认证和授权必须分层设计
- 角色模型先简单,权限模型逐步演进
- 生产环境一定补上过期、刷新、注销、黑名单或版本控制机制
- 安全性和可运维性,跟代码实现同样重要
如果你现在要在项目里开工,我建议按这个顺序推进:
- 第一步:先落地本文示例的基础认证链路
- 第二步:加上方法级权限控制
- 第三步:补 Refresh Token 和注销机制
- 第四步:把权限数据从硬编码迁移到数据库 + 缓存
- 第五步:接入审计日志和统一监控
这样做的好处是:不是一开始就做得很重,而是让系统能随着业务复杂度自然演进。
如果你的场景是内部中后台、单体或轻量服务,这套方案已经足够实用;
如果你要做高安全、多终端、强实时权限控制系统,那么 JWT 仍然可用,但一定要搭配缓存、黑名单、会话管理和更严格的密钥策略一起上。