背景与问题
中后台系统的登录鉴权,看起来像个“老生常谈”的话题,但真到项目里,问题往往不是“能不能登录”,而是:
- 怎么让前后端分离场景下的认证流程更顺滑?
- 怎么避免把权限判断写得到处都是
if? - JWT 明明很方便,为什么上线后经常遇到“登录状态失效”“权限不生效”“Token 无法撤销”?
- Spring Boot 项目里,认证、授权、接口保护、角色权限,怎样组织才不乱?
我自己做中后台系统时,最容易踩的坑就是:一开始只想着“登录成功返回一个 token”,等菜单权限、按钮权限、角色变更、Token 刷新、接口排查这些需求一起上来,代码就迅速失控。
所以这篇文章不只讲“怎么写”,更重点讲:如何用 Spring Boot + JWT 搭一个结构清晰、方便扩展的鉴权架构。我们会从架构分层、核心原理、可运行代码、排查方式到安全和性能实践,一次串起来。
方案目标与架构思路
在中后台系统里,一个相对稳妥的目标通常是:
- 用户登录后拿到 JWT
- 前端请求时携带 JWT
- 后端统一解析 Token,建立当前用户上下文
- 接口级做角色/权限校验
- 业务侧尽量少关心底层鉴权细节
从架构上看,可以拆成 4 层:
- 认证层:校验用户名密码,签发 JWT
- 令牌层:负责 JWT 生成、解析、校验、过期判断
- 鉴权层:拦截请求,提取用户身份,写入上下文
- 授权层:基于角色、权限点控制接口访问
下面这张图可以先把整体流程看清楚。
flowchart TD
A[前端登录请求 /auth/login] --> B[Spring Boot 登录接口]
B --> C[校验用户名密码]
C --> D[生成 JWT]
D --> E[返回 accessToken]
E --> F[前端存储 Token]
F --> G[访问受保护接口]
G --> H[JWT 过滤器]
H --> I{Token 是否有效}
I -- 否 --> J[返回 401 Unauthorized]
I -- 是 --> K[解析用户ID/角色/权限]
K --> L[写入 SecurityContext]
L --> M[控制器/方法权限校验]
M --> N{是否有权限}
N -- 否 --> O[返回 403 Forbidden]
N -- 是 --> P[返回业务数据]
方案对比与取舍分析
在中后台系统里,常见认证方案主要有三类:
1. Session + Cookie
优点
- 服务端容易控制会话
- 传统 MVC 项目接入简单
缺点
- 前后端分离、多端接入不够灵活
- 分布式部署通常要引入 Session 共享
2. JWT 无状态认证
优点
- 天然适合前后端分离
- 服务端无需保存登录态
- 横向扩展友好
缺点
- Token 撤销困难
- 权限变更不能立即生效,除非增加黑名单/版本控制
- 设计不好容易把敏感信息塞进 Token
3. OAuth2 / OIDC
优点
- 适合统一身份中心、第三方登录、单点登录
- 标准成熟
缺点
- 对很多普通中后台系统来说偏重
- 接入和运维复杂度更高
这篇文章的取舍
这里选的是:Spring Boot + Spring Security + JWT + 基于角色/权限的授权模型。
适用边界:
- 前后端分离的后台管理系统
- 用户规模中小到中等
- 权限模型以 RBAC 为主
- 没有特别复杂的 SSO / 第三方授权要求
如果你的系统有以下特征,就要进一步扩展:
- 需要统一认证中心:考虑 OAuth2 / OIDC
- 需要实时踢人下线:考虑 Redis 黑名单或 Token 版本号
- 需要细粒度数据权限:在 RBAC 之外再叠加数据范围控制
核心原理
这一部分我们只抓最关键的三个概念:认证、授权、JWT 结构。
1. 认证与授权的区别
很多项目里这两个词会混着说,但实现时一定要分开。
- 认证 Authentication:你是谁?
- 授权 Authorization:你能做什么?
举个后台系统的例子:
- 用户输入账号密码登录,这是认证
- 登录成功后,是否能访问“用户管理-删除用户”接口,这是授权
2. JWT 的组成
JWT 一般由三部分组成:
- Header
- Payload
- Signature
格式类似:
header.payload.signature
其中:
Header说明算法类型,比如HS256Payload放声明信息,比如用户 ID、用户名、角色Signature用服务端密钥签名,防止内容被篡改
需要强调一点:JWT 默认不是加密,只是 Base64Url 编码。
所以不要把手机号、身份证号、密码摘要这类敏感数据直接放进去。
3. Spring Security 在这里扮演什么角色
Spring Security 本质上是一个安全框架,它提供:
- 过滤器链
- 认证上下文
SecurityContext - 方法级权限控制
- 统一异常处理入口
我们要做的事情其实很清晰:
- 登录时自己校验用户名密码
- 签发 JWT
- 在请求过滤器里解析 JWT
- 把用户信息塞进
SecurityContext - 用
@PreAuthorize或角色权限表达式保护接口
这张时序图能帮助你把过程串起来。
sequenceDiagram
participant Client as 前端
participant Auth as 登录接口
participant JWT as JWT工具类
participant Filter as JWT过滤器
participant API as 业务接口
Client->>Auth: POST /auth/login 用户名+密码
Auth->>Auth: 校验账号密码
Auth->>JWT: 生成Token
JWT-->>Auth: accessToken
Auth-->>Client: 返回Token
Client->>API: GET /users + Authorization: Bearer xxx
API->>Filter: 进入过滤器链
Filter->>JWT: 解析并校验Token
JWT-->>Filter: 用户ID/角色/权限
Filter->>Filter: 写入SecurityContext
Filter-->>API: 放行请求
API->>API: 执行权限判断
API-->>Client: 返回结果
权限模型设计:别只停留在“有没有登录”
一个能真正支撑中后台的权限模型,通常要考虑三层:
- 用户 User
- 角色 Role
- 权限 Permission
典型关系是:
- 用户可以拥有多个角色
- 角色可以绑定多个权限
- 接口或按钮需要某个权限点才能访问
例如:
ROLE_ADMINsys:user:listsys:user:createsys:user:delete
如果你只做“登录态校验”,那只是完成了第一步。真正的中后台控制,往往需要做到:
- 菜单可见性控制
- 按钮级权限控制
- 接口访问控制
- 审计日志记录谁做了什么
从代码设计上,我建议:
- Token 里放用户 ID、用户名、角色列表
- 权限列表可以按需放,或者每次从缓存/数据库加载
- 如果权限变更需要快速生效,不要把完整权限集长期固化在 Token 里
实战代码(可运行)
下面给出一个基于 Spring Boot 3 + Spring Security 6 + jjwt 的最小可运行示例。
为了聚焦鉴权流程,示例里用内存用户代替数据库,但结构是可以平滑替换成真实项目的。
1. Maven 依赖
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>jwt-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.1</version>
</parent>
<properties>
<java.version>17</java.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>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>
</dependencies>
</project>
2. application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expire-seconds: 7200
这里的 secret 只是示例。生产环境请使用足够强度的随机密钥,并通过环境变量或密钥管理系统注入。
3. 启动类
package com.example.jwtdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JwtDemoApplication {
public static void main(String[] args) {
SpringApplication.run(JwtDemoApplication.class, args);
}
}
4. JWT 工具类
package com.example.jwtdemo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.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 expireMillis;
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.expire-seconds}") long expireSeconds) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expireMillis = expireSeconds * 1000;
}
public String generateToken(Long userId, String username, List<String> roles, List<String> permissions) {
Date now = new Date();
Date expireAt = new Date(now.getTime() + expireMillis);
return Jwts.builder()
.setSubject(username)
.claim("userId", userId)
.claim("roles", roles)
.claim("permissions", permissions)
.setIssuedAt(now)
.setExpiration(expireAt)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
public String getUsername(String token) {
return parseClaims(token).getSubject();
}
}
5. 登录请求与响应对象
package com.example.jwtdemo.model;
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;
}
}
package com.example.jwtdemo.model;
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;
}
}
6. 用户模型与模拟用户服务
package com.example.jwtdemo.model;
import java.util.List;
public class LoginUser {
private Long userId;
private String username;
private String password;
private List<String> roles;
private List<String> permissions;
public LoginUser(Long userId, String username, String password, List<String> roles, List<String> permissions) {
this.userId = userId;
this.username = username;
this.password = password;
this.roles = roles;
this.permissions = permissions;
}
public Long getUserId() {
return userId;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public List<String> getRoles() {
return roles;
}
public List<String> getPermissions() {
return permissions;
}
}
package com.example.jwtdemo.service;
import com.example.jwtdemo.model.LoginUser;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
@Service
public class UserService {
private final Map<String, LoginUser> users = Map.of(
"admin", new LoginUser(
1L,
"admin",
"{noop}123456",
List.of("ROLE_ADMIN"),
List.of("sys:user:list", "sys:user:create", "sys:user:delete")
),
"editor", new LoginUser(
2L,
"editor",
"{noop}123456",
List.of("ROLE_EDITOR"),
List.of("sys:user:list")
)
);
public LoginUser findByUsername(String username) {
return users.get(username);
}
}
7. 自定义 UserDetailsService
package com.example.jwtdemo.security;
import com.example.jwtdemo.model.LoginUser;
import com.example.jwtdemo.service.UserService;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserService userService;
public CustomUserDetailsService(UserService userService) {
this.userService = userService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LoginUser loginUser = userService.findByUsername(username);
if (loginUser == null) {
throw new UsernameNotFoundException("用户不存在");
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
loginUser.getRoles().forEach(role -> authorities.add(new SimpleGrantedAuthority(role)));
loginUser.getPermissions().forEach(permission -> authorities.add(new SimpleGrantedAuthority(permission)));
return new User(loginUser.getUsername(), loginUser.getPassword(), authorities);
}
}
8. JWT 认证过滤器
package com.example.jwtdemo.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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtTokenProvider.validateToken(token)) {
Claims claims = jwtTokenProvider.parseClaims(token);
String username = claims.getSubject();
List<String> roles = claims.get("roles", List.class);
List<String> permissions = claims.get("permissions", List.class);
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
if (roles != null) {
roles.forEach(role -> authorities.add(new SimpleGrantedAuthority(role)));
}
if (permissions != null) {
permissions.forEach(permission -> authorities.add(new SimpleGrantedAuthority(permission)));
}
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
9. Security 配置
package com.example.jwtdemo.config;
import com.example.jwtdemo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
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);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
10. 登录接口
package com.example.jwtdemo.controller;
import com.example.jwtdemo.model.LoginRequest;
import com.example.jwtdemo.model.LoginResponse;
import com.example.jwtdemo.model.LoginUser;
import com.example.jwtdemo.security.JwtTokenProvider;
import com.example.jwtdemo.service.UserService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserService userService;
private final JwtTokenProvider jwtTokenProvider;
public AuthController(AuthenticationManager authenticationManager,
UserService userService,
JwtTokenProvider jwtTokenProvider) {
this.authenticationManager = authenticationManager;
this.userService = userService;
this.jwtTokenProvider = jwtTokenProvider;
}
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
);
LoginUser loginUser = userService.findByUsername(request.getUsername());
String token = jwtTokenProvider.generateToken(
loginUser.getUserId(),
loginUser.getUsername(),
loginUser.getRoles(),
loginUser.getPermissions()
);
return new LoginResponse(token);
}
}
11. 受保护接口
package com.example.jwtdemo.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/me")
public Map<String, Object> me() {
return Map.of("message", "你已通过认证");
}
@PreAuthorize("hasAuthority('sys:user:list')")
@GetMapping
public Map<String, Object> list() {
return Map.of("message", "用户列表访问成功");
}
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{id}")
public Map<String, Object> delete(@PathVariable Long id) {
return Map.of("message", "删除用户成功", "id", id);
}
}
如何运行与验证
启动项目后,可以按下面步骤测试。
1. 登录获取 Token
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
返回示例:
{
"token": "eyJhbGciOiJIUzI1NiJ9.xxx.xxx",
"tokenType": "Bearer"
}
2. 访问认证接口
curl http://localhost:8080/users/me \
-H "Authorization: Bearer 你的token"
3. 访问权限接口
curl http://localhost:8080/users \
-H "Authorization: Bearer 你的token"
4. 测试角色限制接口
curl -X DELETE http://localhost:8080/users/1 \
-H "Authorization: Bearer 你的token"
如果你用 editor / 123456 登录:
- 可以访问
/users/me - 可以访问
/users - 不能访问
DELETE /users/1
这说明角色和权限控制都生效了。
权限控制在架构层面怎么落地
代码能跑起来只是第一步。真正的项目里,更重要的是权限如何组织到架构中。
推荐分层
控制层
- 只暴露接口
- 用
@PreAuthorize声明权限要求
应用层 / 服务层
- 编排业务流程
- 不重复做简单接口权限判断
- 遇到跨接口、跨场景的复杂权限,再补充业务校验
基础设施层
- 负责 Token 生成解析
- 负责从缓存/数据库读取用户权限
- 负责黑名单、审计日志等
这样做有个好处:权限规则不会散落到每个 Controller 和 Service 的角落里。
下面这张类关系图是一个简化版的结构参考。
classDiagram
class AuthController {
+login(LoginRequest) LoginResponse
}
class UserController {
+me()
+list()
+delete(Long id)
}
class JwtTokenProvider {
+generateToken(Long,String,List,List) String
+parseClaims(String) Claims
+validateToken(String) boolean
}
class JwtAuthenticationFilter {
+doFilterInternal(req,res,chain)
}
class CustomUserDetailsService {
+loadUserByUsername(String) UserDetails
}
class UserService {
+findByUsername(String) LoginUser
}
AuthController --> UserService
AuthController --> JwtTokenProvider
JwtAuthenticationFilter --> JwtTokenProvider
CustomUserDetailsService --> UserService
常见坑与排查
这一部分非常重要,因为 JWT 鉴权“看起来简单”,但一旦失败,问题点可能在很多层。我把常见问题按现象来讲。
1. 登录成功了,但访问接口还是 401
常见原因
- 请求头没带
Authorization Bearer前缀写错- Token 已过期
- 过滤器没生效
- Token 密钥不一致
排查建议
先看请求头:
Authorization: Bearer eyJhbGciOi...
再在过滤器里打日志,确认:
- 有没有进过滤器
- Token 有没有拿到
validateToken是否返回 trueSecurityContext是否写入成功
我以前就踩过一个很低级但常见的坑:前端把 Token 放到 token 头里了,后端却只认 Authorization,结果接口全是 401。
2. 已经认证了,但权限校验返回 403
常见原因
- 角色前缀不一致
hasRole('ADMIN')和ROLE_ADMIN使用混淆- 权限点名称不一致
- 没启用方法级权限控制
关键规则
如果用:
@PreAuthorize("hasRole('ADMIN')")
那实际权限里要有:
ROLE_ADMIN
如果你写的是:
@PreAuthorize("hasAuthority('ADMIN')")
那权限就必须精确是 ADMIN。
排查建议
打印当前认证对象:
SecurityContextHolder.getContext().getAuthentication().getAuthorities()
看看到底塞进去了什么。
3. Token 解析报错:签名不合法
常见原因
- 开发、测试、生产环境的 secret 不一致
- 前端拿了旧 Token
- Token 被截断或拼接错误
排查建议
- 确认环境变量
- 确认没有多余空格
- 确认数据库/缓存中是否还保留旧配置
4. 权限修改后,老 Token 还有效
这是 JWT 的典型问题,不是 bug,而是架构特性。
原因
JWT 是自包含的,签发时把角色/权限写进去了,只要 Token 没过期,它就还能用。
解决办法
按场景选:
- 短有效期 Token:简单直接
- Redis 黑名单:支持主动失效
- Token 版本号:用户权限变更时版本递增
- 权限不放 Token,运行时查缓存:实时性高,但每次请求成本更高
5. 过滤器执行了两次
如果过滤器不是继承 OncePerRequestFilter,或者配置链路有问题,可能出现重复执行。
这也是为什么示例里我直接用 OncePerRequestFilter,它在 Web 项目里更稳。
安全最佳实践
JWT 很方便,但方便不等于天然安全。下面这些实践,我建议默认就做。
1. 密钥管理不要写死在代码里
示例里为了方便演示写在 application.yml。生产环境建议:
- 从环境变量读取
- 使用配置中心加密项
- 使用 KMS 或密钥管理系统
2. Token 有效期不要过长
对于中后台系统,常见做法是:
- Access Token:30 分钟到 2 小时
- Refresh Token:7 天到 30 天
如果你把 Access Token 设成 30 天,一旦泄露,风险会非常高。
3. 不要在 JWT Payload 放敏感信息
不要放:
- 密码
- 手机号
- 身份证号
- 银行卡号
- 完整权限快照中的敏感字段
建议只放:
- 用户 ID
- 用户名
- 必要角色标识
- 最少量的鉴权上下文
4. 前端存储 Token 要谨慎
严格说:
- 放
localStorage:实现简单,但更怕 XSS - 放 HttpOnly Cookie:更利于防止脚本读取,但要额外考虑 CSRF
前后端分离后台系统里,很多团队默认用 localStorage。这不是不行,但前提是你要把 XSS 防护 做好,包括:
- 输入输出转义
- CSP
- 富文本内容净化
5. 登出要有兜底机制
JWT 是无状态的,服务端默认“记不住它”。所以登出不能只靠前端删本地 Token。更稳妥的办法是:
- 维护 Redis 黑名单
- 或记录用户当前有效 Token 版本号
- 或缩短 Access Token 生命周期
6. 明确区分 401 与 403
- 401 Unauthorized:没登录、Token 无效、Token 过期
- 403 Forbidden:已登录,但没权限
这不仅影响接口语义,也影响前端怎么处理:
- 401 -> 跳转登录页
- 403 -> 提示“无权限”
性能最佳实践
很多人一提 JWT 就说“无状态,性能好”,这句话只对一半。
它减少了服务端 Session 存储压力,但不代表整条链路天然高效。
1. Token 不要塞太多内容
JWT 每次请求都会带上,如果你把角色、权限、组织树、菜单树都放进去:
- Header 变大
- 网络开销上升
- 解析开销增加
对于高频接口,这种开销是能累计出来的。
2. 权限数据建议缓存
如果你不把完整权限放进 Token,而是请求时查库,那建议:
- 用户基础信息走本地缓存或 Redis
- 权限集合做缓存
- 设置合理 TTL,并在角色变更时主动失效
3. 网关层统一做基础鉴权
如果系统是微服务架构,建议:
- 网关统一校验 JWT
- 下游服务只信任网关透传的用户上下文
- 对关键接口仍保留服务内授权校验
这样可以减少每个服务重复写解析逻辑。
4. 容量估算要关注什么
一个典型中后台系统,假设:
- 5000 活跃用户
- 平均每秒 200 请求
- 每个请求都解析 JWT
那么需要关注:
- JWT 解析 CPU 开销
- 权限缓存命中率
- Redis 黑名单查询成本
- 鉴权失败请求比例
如果只是单体应用,中小规模问题不大;
但当接口量上来后,建议把鉴权链路做压测,特别是:
- 高并发登录
- Token 过期集中刷新
- Redis 抖动时的降级行为
可扩展设计建议
当你的中后台从“能登录”走向“可运营”,通常会继续遇到下面几类扩展需求。
1. 刷新 Token
推荐双 Token 模型:
- Access Token:短期有效,访问接口
- Refresh Token:长期有效,用于换新 Access Token
这样既减少用户频繁登录,也降低泄露风险。
2. 数据权限
角色权限只能回答“能不能访问接口”,但很多后台更关心“能看哪些数据”。
例如:
- 只能看自己创建的数据
- 只能看本部门数据
- 只能看本部门及子部门数据
这时要在 RBAC 之外,再叠加数据范围过滤。
3. 审计日志
建议记录:
- 谁登录了
- 谁访问了敏感接口
- 谁做了删除、审批、导出
这对排查问题和安全合规都很关键。
4. 动态权限加载
如果后台权限经常调整,建议把权限元数据做成:
- 菜单表
- 角色表
- 权限表
- 角色权限关联表
- 用户角色关联表
再配合缓存做动态加载,而不是把权限写死在代码里。
边界条件:什么情况下不建议直接照搬
这套方案很适合中后台,但不是所有场景都最优。
不太适合的情况
1. 需要强实时踢下线
JWT 天然不擅长“立即失效”,需要配合黑名单或版本控制。
2. 超复杂统一认证体系
如果你有多个系统、多个租户、第三方身份源,直接上 OAuth2 / OIDC 往往更合适。
3. 极细粒度 ABAC 授权
如果权限要基于用户属性、资源属性、环境条件动态决策,纯 RBAC 不够,需要更复杂的策略引擎。
总结
用 Spring Boot 与 JWT 实现中后台登录鉴权,真正重要的不是“生成一个 Token”,而是把整套链路设计清楚:
- 认证:登录校验用户名密码
- 令牌:用 JWT 传递最小必要身份信息
- 鉴权:过滤器统一解析 Token,建立用户上下文
- 授权:基于角色和权限点保护接口
- 扩展:通过缓存、黑名单、版本号、刷新机制补足 JWT 的天然短板
如果你准备在真实项目里落地,我给几个可执行建议:
- 先做最小闭环:登录、JWT、过滤器、
@PreAuthorize - 角色和权限命名规范要统一:尤其
ROLE_前缀 - Access Token 设置短有效期
- 权限变更频繁时,不要把完整权限长期固化到 Token
- 明确 401 和 403 的处理语义
- 预留登出、黑名单、刷新 Token 的扩展点
一句话收尾:
JWT 适合做中后台认证的“基础设施”,但权限控制的质量,最终取决于你的架构分层和边界设计。
如果你现在的系统还停留在“登录后放行所有接口”,那这篇文章里的结构,已经足够作为一次可靠的升级起点。