背景与问题
做 Java Web 项目时,用户权限和接口鉴权往往一开始看起来不复杂:
“登录成功后发个 token,接口里判断一下有没有权限,不就完了?”
但项目一旦进入多人协作、接口增多、角色增多、后台管理和开放 API 并存的阶段,问题就会很快冒出来:
- 权限逻辑散落在 Controller / Service 各处,很难维护
- 角色、菜单、接口权限混在一起,后期扩展困难
- 鉴权只校验登录,不校验资源权限,导致越权
- 数据库查权限太频繁,接口性能差
- 多实例部署后,token 与权限缓存不一致
- 接口开放给第三方时,用户登录态鉴权与应用级签名鉴权混用,边界模糊
我自己做后台系统时,踩过一个很典型的坑:
最初只做了“用户-角色-菜单”三张表,后来发现菜单权限根本不等于接口权限。前端按钮能隐藏,不代表接口不能被绕过直调。最后还是回到“用户、角色、权限点、接口资源”这套更稳的模型上重构。
这篇文章我想从架构落地的角度,带你设计一套适合中型 Java Web 项目的权限与接口鉴权体系,基于:
- Spring Boot
- MyBatis
- JWT
- 拦截器 / AOP
- RBAC 权限模型
- 本地缓存 + 可扩展分布式缓存思路
目标不是“写一个最炫的权限系统”,而是做一个可运行、可扩展、可排查、可上线的方案。
方案目标与设计边界
先明确目标,避免系统越做越重。
我们要解决的问题
- 用户身份认证:确认“你是谁”
- 接口访问鉴权:确认“你能不能调用这个接口”
- 权限模型清晰:支持用户、角色、权限点解耦
- 可扩展:后续能加入按钮权限、数据权限、租户隔离
- 高可用:多实例部署时不依赖单机 Session
- 性能可控:不能每个请求都全量查库
暂不展开的边界
这篇文章不重点展开:
- OAuth2 全量授权体系
- 复杂数据权限表达式引擎
- 单点登录 SSO
- 网关统一鉴权的完整实现
不过本文的设计会给这些后续演进留接口。
核心原理
整个体系可以拆成两层:
- 认证(Authentication):登录后颁发 token
- 授权(Authorization):根据用户拥有的权限,判断接口是否可访问
一、权限模型:RBAC + 接口权限点
推荐使用经典 RBAC 模型:
user:用户role:角色permission:权限点user_role:用户与角色关系role_permission:角色与权限关系
但这里有个关键设计:
权限点不要直接等同于菜单,而要抽象成接口可识别的 permission code。
例如:
user:listuser:adduser:deleteorder:queryorder:export
接口通过注解声明自己需要什么权限,而不是硬编码某个角色名。
这样做的好处是:
- 角色可灵活组合权限
- 一个接口只依赖“权限点”,不依赖具体角色
- 菜单权限、按钮权限、接口权限可以共用 permission code
二、鉴权流程
整体流程如下:
flowchart TD
A[用户登录] --> B[校验用户名密码]
B --> C[查询用户角色与权限]
C --> D[签发JWT Token]
D --> E[客户端携带Token访问接口]
E --> F[鉴权拦截器解析Token]
F --> G[校验Token合法性]
G --> H[读取接口所需权限]
H --> I[比对用户权限集合]
I -->|通过| J[进入Controller/Service]
I -->|拒绝| K[返回403]
三、为什么选 JWT 而不是 Session
在高可用部署场景下,JWT 有几个现实优势:
- 天然无状态,适合多实例
- 不强依赖 Session 共享
- 接口调用方更容易接入
- 可在网关层做初步解析
但 JWT 也不是万能的,典型问题有:
- token 一旦签发,默认在过期前都有效
- 权限变更后,旧 token 仍可能可用
- 无法像 Session 一样天然服务端失效
所以实际落地时,一般会配合:
- 短期 access token
- 服务端版本号 / 黑名单
- 权限缓存失效策略
四、注解 + 拦截器的授权方式
接口授权建议用注解声明,例如:
@RequirePermission("user:list")
@GetMapping("/users")
public List<UserDTO> list() { ... }
然后在拦截器或 AOP 中统一处理:
- 获取接口上的权限注解
- 解析 token 得到用户信息
- 查询或读取缓存中的权限集合
- 判断是否包含该权限
- 不通过则返回 401 / 403
这样业务代码就不会被大量 if(hasPermission(...)) 污染。
方案对比与取舍分析
方案一:只做登录态校验
只验证 token 是否存在,不校验具体权限。
优点
- 实现简单
- 适合内部极小型系统
缺点
- 越权风险大
- 无法满足后台管理系统需要
方案二:角色名硬编码
例如:
if ("ADMIN".equals(role)) { ... }
优点
- 上手快
缺点
- 角色与业务耦合严重
- 新增角色时代码改动大
- 无法灵活组合权限
方案三:RBAC + 权限码 + 注解鉴权
本文采用这一套。
优点
- 解耦清晰
- 易维护
- 支持后续扩展到按钮级和数据级权限
缺点
- 前期表结构和拦截逻辑设计稍复杂
- 权限变更与缓存失效要设计好
取舍建议
如果你的项目满足以下特征,建议直接上 RBAC + 权限码:
- 接口超过 30 个
- 角色超过 3 类
- 有后台管理功能
- 预计未来还要继续迭代
别等系统做大了再补权限体系,重构代价会高很多。
数据库设计
下面给出一套简化但实用的表结构。
1. 用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
version INT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
2. 角色表
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_code VARCHAR(64) NOT NULL UNIQUE,
role_name VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 1
);
3. 权限表
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
permission_code VARCHAR(128) NOT NULL UNIQUE,
permission_name VARCHAR(128) NOT NULL,
api_pattern VARCHAR(255),
method VARCHAR(16),
status TINYINT NOT NULL DEFAULT 1
);
4. 用户角色关系表
CREATE TABLE sys_user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
UNIQUE KEY uk_user_role (user_id, role_id)
);
5. 角色权限关系表
CREATE TABLE sys_role_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
UNIQUE KEY uk_role_permission (role_id, permission_id)
);
设计说明
version字段用于支持“权限变更后让 token 失效”的策略permission_code是接口鉴权的核心api_pattern和method可用于做“接口资源与权限”的映射管理- 即使已经有菜单表,也建议单独维护接口权限表
系统交互时序
登录和接口访问的时序建议这样设计:
sequenceDiagram
participant C as Client
participant A as AuthController
participant S as AuthService
participant DB as MySQL
participant I as AuthInterceptor
C->>A: POST /login 用户名密码
A->>S: login(username,password)
S->>DB: 查询用户/角色/权限/version
DB-->>S: 用户信息
S-->>A: JWT Token
A-->>C: 返回Token
C->>I: 携带Token访问API
I->>I: 解析JWT
I->>DB: 按userId查询权限/version(可加缓存)
DB-->>I: 权限集合
I->>I: 比对注解权限
I-->>C: 通过或403
项目结构建议
为了避免权限逻辑散在各层,我建议目录尽量清晰:
src/main/java/com/example/auth
├── annotation
│ └── RequirePermission.java
├── config
│ └── WebMvcConfig.java
├── controller
│ ├── AuthController.java
│ └── UserController.java
├── domain
│ ├── User.java
│ └── LoginRequest.java
├── mapper
│ ├── UserMapper.java
│ └── PermissionMapper.java
├── security
│ ├── AuthInterceptor.java
│ ├── JwtUtil.java
│ ├── LoginUser.java
│ └── SecurityContext.java
├── service
│ ├── AuthService.java
│ └── PermissionService.java
└── Application.java
实战代码(可运行)
下面给出一套精简版实现,核心逻辑完整,能直接作为项目骨架。
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
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>auth-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.7.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
</project>
2. application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/auth_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
type-aliases-package: com.example.auth.domain
configuration:
map-underscore-to-camel-case: true
jwt:
secret: mySecretKey123456
expire-seconds: 3600
3. 启动类
package com.example.auth;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@MapperScan("com.example.auth.mapper")
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
4. 权限注解
package com.example.auth.annotation;
import java.lang.annotation.*;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequirePermission {
String value();
}
5. 登录用户对象
package com.example.auth.security;
public class LoginUser {
private Long userId;
private String username;
private Integer version;
public LoginUser() {
}
public LoginUser(Long userId, String username, Integer version) {
this.userId = userId;
this.username = username;
this.version = version;
}
public Long getUserId() {
return userId;
}
public String getUsername() {
return username;
}
public Integer getVersion() {
return version;
}
}
6. SecurityContext
package com.example.auth.security;
public class SecurityContext {
private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>();
public static void set(LoginUser user) {
HOLDER.set(user);
}
public static LoginUser get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}
7. JWT 工具类
package com.example.auth.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire-seconds}")
private Long expireSeconds;
public String generateToken(LoginUser user) {
Date now = new Date();
Date expire = new Date(now.getTime() + expireSeconds * 1000);
return Jwts.builder()
.setSubject(String.valueOf(user.getUserId()))
.claim("username", user.getUsername())
.claim("version", user.getVersion())
.setIssuedAt(now)
.setExpiration(expire)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public LoginUser parseToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
Long userId = Long.valueOf(claims.getSubject());
String username = (String) claims.get("username");
Integer version = (Integer) claims.get("version");
return new LoginUser(userId, username, version);
}
}
8. 实体与请求对象
package com.example.auth.domain;
public class User {
private Long id;
private String username;
private String password;
private Integer status;
private Integer version;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public Integer getStatus() {
return status;
}
public Integer getVersion() {
return version;
}
public void setPassword(String password) {
this.password = password;
}
public void setStatus(Integer status) {
this.status = status;
}
public void setVersion(Integer version) {
this.version = version;
}
}
package com.example.auth.domain;
import javax.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank
private String username;
@NotBlank
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;
}
}
9. Mapper
package com.example.auth.mapper;
import com.example.auth.domain.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface UserMapper {
@Select("SELECT id, username, password, status, version FROM sys_user WHERE username = #{username}")
User findByUsername(String username);
@Select("SELECT id, username, password, status, version FROM sys_user WHERE id = #{id}")
User findById(Long id);
}
package com.example.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface PermissionMapper {
@Select("SELECT DISTINCT p.permission_code " +
"FROM sys_permission p " +
"JOIN sys_role_permission rp ON p.id = rp.permission_id " +
"JOIN sys_user_role ur ON rp.role_id = ur.role_id " +
"WHERE ur.user_id = #{userId} AND p.status = 1")
List<String> findPermissionCodesByUserId(Long userId);
}
10. 权限服务
这里我用一个简单的本地缓存演示。生产环境建议替换为 Caffeine 或 Redis。
package com.example.auth.service;
import com.example.auth.mapper.PermissionMapper;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class PermissionService {
private final PermissionMapper permissionMapper;
private final Map<Long, CacheItem> localCache = new ConcurrentHashMap<>();
public PermissionService(PermissionMapper permissionMapper) {
this.permissionMapper = permissionMapper;
}
public Set<String> getPermissions(Long userId) {
CacheItem item = localCache.get(userId);
long now = System.currentTimeMillis();
if (item != null && now < item.expireAt) {
return item.permissions;
}
List<String> list = permissionMapper.findPermissionCodesByUserId(userId);
Set<String> set = new HashSet<>(list);
localCache.put(userId, new CacheItem(set, now + 60_000));
return set;
}
public void evict(Long userId) {
localCache.remove(userId);
}
private static class CacheItem {
private final Set<String> permissions;
private final long expireAt;
private CacheItem(Set<String> permissions, long expireAt) {
this.permissions = permissions;
this.expireAt = expireAt;
}
}
}
11. 认证服务
为了演示简洁,这里直接明文比较密码。
生产环境请务必使用 BCrypt。
package com.example.auth.service;
import com.example.auth.domain.User;
import com.example.auth.mapper.UserMapper;
import com.example.auth.security.JwtUtil;
import com.example.auth.security.LoginUser;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final UserMapper userMapper;
private final JwtUtil jwtUtil;
public AuthService(UserMapper userMapper, JwtUtil jwtUtil) {
this.userMapper = userMapper;
this.jwtUtil = jwtUtil;
}
public String login(String username, String password) {
User user = userMapper.findByUsername(username);
if (user == null || user.getStatus() != 1) {
throw new RuntimeException("用户不存在或已禁用");
}
if (!user.getPassword().equals(password)) {
throw new RuntimeException("用户名或密码错误");
}
LoginUser loginUser = new LoginUser(user.getId(), user.getUsername(), user.getVersion());
return jwtUtil.generateToken(loginUser);
}
public User findById(Long id) {
return userMapper.findById(id);
}
}
12. 鉴权拦截器
package com.example.auth.security;
import com.example.auth.annotation.RequirePermission;
import com.example.auth.domain.User;
import com.example.auth.service.AuthService;
import com.example.auth.service.PermissionService;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Set;
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
private final PermissionService permissionService;
private final AuthService authService;
public AuthInterceptor(JwtUtil jwtUtil, PermissionService permissionService, AuthService authService) {
this.jwtUtil = jwtUtil;
this.permissionService = permissionService;
this.authService = authService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod method = (HandlerMethod) handler;
RequirePermission requirePermission = method.getMethodAnnotation(RequirePermission.class);
if (requirePermission == null) {
return true;
}
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
response.setStatus(401);
response.getWriter().write("Unauthorized");
return false;
}
String token = authHeader.substring(7);
LoginUser loginUser;
try {
loginUser = jwtUtil.parseToken(token);
} catch (Exception e) {
response.setStatus(401);
response.getWriter().write("Invalid token");
return false;
}
User dbUser = authService.findById(loginUser.getUserId());
if (dbUser == null || dbUser.getStatus() != 1) {
response.setStatus(401);
response.getWriter().write("User disabled");
return false;
}
if (!dbUser.getVersion().equals(loginUser.getVersion())) {
response.setStatus(401);
response.getWriter().write("Token expired by version");
return false;
}
Set<String> permissions = permissionService.getPermissions(loginUser.getUserId());
if (!permissions.contains(requirePermission.value())) {
response.setStatus(403);
response.getWriter().write("Forbidden");
return false;
}
SecurityContext.set(loginUser);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
SecurityContext.clear();
}
}
13. MVC 配置
package com.example.auth.config;
import com.example.auth.security.AuthInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
public WebMvcConfig(AuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login", "/error");
}
}
14. Controller
package com.example.auth.controller;
import com.example.auth.domain.LoginRequest;
import com.example.auth.service.AuthService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.Map;
@RestController
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
public Map<String, String> login(@Validated @RequestBody LoginRequest request) {
String token = authService.login(request.getUsername(), request.getPassword());
return Collections.singletonMap("token", token);
}
}
package com.example.auth.controller;
import com.example.auth.annotation.RequirePermission;
import com.example.auth.security.SecurityContext;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/users")
public class UserController {
@RequirePermission("user:list")
@GetMapping
public List<Map<String, Object>> list() {
Map<String, Object> u1 = new HashMap<>();
u1.put("id", 1);
u1.put("name", "alice");
Map<String, Object> u2 = new HashMap<>();
u2.put("id", 2);
u2.put("name", "bob");
return Arrays.asList(u1, u2);
}
@RequirePermission("user:add")
@PostMapping
public Map<String, Object> add() {
Map<String, Object> result = new HashMap<>();
result.put("message", "created by " + SecurityContext.get().getUsername());
return result;
}
}
15. 初始化测试数据
INSERT INTO sys_user (id, username, password, status, version, created_at)
VALUES (1, 'admin', '123456', 1, 1, NOW()),
(2, 'editor', '123456', 1, 1, NOW());
INSERT INTO sys_role (id, role_code, role_name, status)
VALUES (1, 'ADMIN', '管理员', 1),
(2, 'EDITOR', '编辑', 1);
INSERT INTO sys_permission (id, permission_code, permission_name, api_pattern, method, status)
VALUES (1, 'user:list', '用户查询', '/users', 'GET', 1),
(2, 'user:add', '用户新增', '/users', 'POST', 1);
INSERT INTO sys_user_role (user_id, role_id)
VALUES (1, 1),
(2, 2);
INSERT INTO sys_role_permission (role_id, permission_id)
VALUES (1, 1),
(1, 2),
(2, 1);
16. 测试步骤
登录
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
访问有权限接口
curl http://localhost:8080/users \
-H "Authorization: Bearer 你的token"
访问新增接口
curl -X POST http://localhost:8080/users \
-H "Authorization: Bearer 你的token"
如果用 editor 登录,应该能查询用户,但不能新增用户,这就说明权限点生效了。
权限模型关系图
这张图可以帮助你在脑子里把表关系理清楚。
classDiagram
class User {
+Long id
+String username
+String password
+Integer status
+Integer version
}
class Role {
+Long id
+String roleCode
+String roleName
}
class Permission {
+Long id
+String permissionCode
+String permissionName
+String apiPattern
+String method
}
User --> UserRole
Role --> UserRole
Role --> RolePermission
Permission --> RolePermission
高可用设计要点
文章标题里说的是“高可用”,那就不能只停留在单机能跑。
1. 无状态认证
JWT 本身就是为多实例部署准备的。
应用实例 A 签发的 token,实例 B 也能校验,只要共享同一套签名密钥即可。
2. 权限缓存分层
建议采用两层缓存:
- 本地缓存:减少同一实例重复查库
- Redis 缓存:多实例共享权限数据
典型流程:
- 先查本地缓存
- 本地没有再查 Redis
- Redis 没有再查数据库
- 回填 Redis 与本地缓存
3. token 失效策略
权限系统经常会遇到一个问题:
管理员刚把某用户权限回收,结果该用户的旧 token 还能继续调用接口。
实战中常见解决方案有三种:
方案 A:短 token 生命周期
比如 15 分钟过期。
- 优点:简单
- 缺点:权限变更不会立即生效
方案 B:用户版本号 version
本文代码已经示范了。
做法是:
- token 中带
version - 数据库用户表也有
version - 用户权限、角色、状态变更时,把
version + 1 - 请求时比对 token version 与数据库 version
这样旧 token 就会失效。
方案 C:token 黑名单
适合强制下线、注销所有终端等场景。
- 优点:精确控制
- 缺点:需要服务端维护状态
实际建议
后台系统优先推荐:
- 短 token + version 校验
- 必要时再补充黑名单
4. 容量估算思路
以中型后台系统举例:
- 5 万用户
- 日活 5000
- 峰值 QPS 300
- 单次权限集合 20~100 个权限码
如果每个请求都查数据库,压力会非常明显。
所以权限数据一定要缓存,至少做到:
- 热点用户权限 90% 命中缓存
- 权限集合以
Set<String>存放,避免每次重复构造 - 管理端变更权限后主动清理缓存
常见坑与排查
这个部分我尽量写得接地气一些,因为很多问题不是“不会写”,而是“看起来都对但就是不生效”。
1. 接口明明加了注解,却没拦截
常见原因
addInterceptors没注册- 路径被
excludePathPatterns排除了 - 注解加在类上,但拦截器只读取方法注解
- 请求未进入
HandlerMethod
排查方法
在拦截器里打日志:
System.out.println("request uri = " + request.getRequestURI());
System.out.println("handler = " + handler.getClass().getName());
如果不是 HandlerMethod,说明请求可能走的是静态资源或错误页。
2. token 解析总是报错
常见原因
- secret 不一致
- token 前缀没去掉
Bearer - 前端把 token 截断了
- 服务端机器时间不一致导致过期判断异常
排查建议
- 打印请求头完整内容
- 本地先用固定 token 回归测试
- 多实例部署时检查所有实例配置是否一致
3. 权限变更后,接口还是能访问
常见原因
- 本地缓存没清理
- Redis 缓存没失效
- token 中没有版本校验
- 查询权限时 join 条件写错了
止血方案
如果线上发现越权,先做两件事:
- 立刻提升用户
version - 清理对应用户权限缓存
这通常能快速止损。
4. 403 和 401 混用
这是很多团队都会乱掉的地方。
- 401 Unauthorized:未登录、token 无效、token 过期
- 403 Forbidden:已登录,但无权限
建议严格区分,否则前端处理会很混乱。
5. ThreadLocal 泄漏
用了 SecurityContext 就必须记得清理。
原因
Tomcat 线程会复用,如果不 remove(),可能串数据。
正确做法
在 afterCompletion 里清理,异常流程也要能执行到。
这一点我以前也踩过,表现特别诡异:偶发性地拿到了上一个请求的用户信息。
6. 权限码设计不统一
比如有人写:
user:listUSER_ADDaddUser/api/user/add
一段时间后就没人看得懂了。
建议规范
统一采用:
资源:动作
例如:
user:listuser:adduser:updateorder:export
保持一致性,远比“语法优雅”更重要。
安全/性能最佳实践
这部分是上线前最值得一条条核对的。
安全最佳实践
1. 密码必须加密存储
示例里为了方便演示用了明文,这在生产环境绝对不行。
建议使用 BCrypt:
new BCryptPasswordEncoder().encode(password)
并在登录时用 matches 校验。
2. JWT 密钥不要写死在代码里
应该放在:
- 配置中心
- 环境变量
- Kubernetes Secret
同时要有轮换方案。
3. 不要把全部权限塞进 JWT
很多人喜欢把权限列表直接写入 token,图省事。
但问题很明显:
- token 体积变大
- 权限变更无法及时生效
- 敏感信息暴露风险更高
更稳妥的做法是:
token 只存最小身份信息,权限走缓存/服务端查询。
4. 管理接口建议加审计日志
例如记录:
- 谁在什么时间
- 修改了谁的角色/权限
- 来源 IP 是什么
出了权限事故时,这些日志特别关键。
5. 防重放与接口签名
如果接口不仅给前端页面用,还要开放给第三方系统调用,单纯 JWT 不够。
建议补充:
timestampnoncesign
也就是用户级鉴权和应用级签名鉴权分开做,不要混在一起。
性能最佳实践
1. 权限集合使用 Set
查权限时要做 contains 判断,Set<String> 比 List<String> 更合适。
2. 给关系表建联合唯一索引
已经在 SQL 里示范过:
uk_user_role (user_id, role_id)uk_role_permission (role_id, permission_id)
这样能避免脏数据和重复绑定。
3. 避免每次请求全量查用户
如果只是校验用户状态和版本,可以:
- 短时缓存用户状态/version
- 或者放到 Redis 中
否则高并发下会多一次数据库压力。
4. 管理缓存失效时机
典型失效事件:
- 用户禁用/启用
- 用户角色变更
- 角色权限变更
- 权限点状态变更
要么通过事件通知清缓存,要么统一在管理后台修改后主动调用失效逻辑。
5. 热点权限预热
如果系统每天上班高峰有大量后台用户集中登录,可以在登录后将权限预热到缓存,减少首次访问抖动。
可进一步扩展的方向
本文这套方案已经够支撑多数中型后台项目,但如果业务继续发展,可以这样演进:
1. 从接口权限扩展到按钮权限
前端在获取当前用户权限集合后,控制页面按钮显示。
但要注意:
前端隐藏按钮只是体验优化,真正安全仍靠后端接口鉴权。
2. 增加数据权限
例如:
- 只能看自己创建的数据
- 只能看本部门数据
- 只能看某租户数据
这通常需要在 Service / SQL 层加过滤条件,而不是只看接口权限。
3. 网关层统一认证
如果是微服务架构:
- 网关做 token 初步解析
- 用户信息透传到下游服务
- 下游服务继续做资源级权限判断
这样职责更清晰。
4. 权限中心化
多个系统共用同一套账号和权限时,可以把:
- 用户中心
- 权限中心
- token 服务
独立出来,避免每个系统重复造轮子。
总结
如果你要在 Spring Boot + MyBatis 项目里设计一套高可用的用户权限与接口鉴权体系,我建议优先抓住下面这几个核心点:
-
认证与授权分离
- 认证解决“是谁”
- 授权解决“能做什么”
-
使用 RBAC + 权限码,而不是硬编码角色
- 接口依赖权限点
- 角色只是权限组合
-
JWT 做无状态登录,version 做可控失效
- 适合多实例部署
- 权限变更可快速生效
-
注解 + 拦截器统一鉴权
- 业务代码更干净
- 规则集中,便于维护
-
权限查询必须缓存
- 否则数据库压力会很快上来
- 本地缓存 + Redis 是常见实用组合
-
把 401/403、缓存失效、ThreadLocal 清理这些细节做好
- 真正影响线上稳定性的,往往就是这些“小地方”
如果你的项目目前还处在“只有登录,没有细粒度权限”的阶段,最实际的第一步不是一次性做复杂平台,而是先把这三件事补齐:
- 建立
user-role-permission模型 - 用注解声明接口权限
- 加入 token version 校验
这三步做完,系统的安全性和可维护性会明显上一个台阶。