跳转到内容
123xiao | 无名键客

《Spring Boot + MyBatis 实战:在 Java Web 项目中设计高可用的用户权限与接口鉴权体系》

字数: 0 阅读时长: 1 分钟

背景与问题

做 Java Web 项目时,用户权限和接口鉴权往往一开始看起来不复杂:
“登录成功后发个 token,接口里判断一下有没有权限,不就完了?”

但项目一旦进入多人协作、接口增多、角色增多、后台管理和开放 API 并存的阶段,问题就会很快冒出来:

  • 权限逻辑散落在 Controller / Service 各处,很难维护
  • 角色、菜单、接口权限混在一起,后期扩展困难
  • 鉴权只校验登录,不校验资源权限,导致越权
  • 数据库查权限太频繁,接口性能差
  • 多实例部署后,token 与权限缓存不一致
  • 接口开放给第三方时,用户登录态鉴权与应用级签名鉴权混用,边界模糊

我自己做后台系统时,踩过一个很典型的坑:
最初只做了“用户-角色-菜单”三张表,后来发现菜单权限根本不等于接口权限。前端按钮能隐藏,不代表接口不能被绕过直调。最后还是回到“用户、角色、权限点、接口资源”这套更稳的模型上重构。

这篇文章我想从架构落地的角度,带你设计一套适合中型 Java Web 项目的权限与接口鉴权体系,基于:

  • Spring Boot
  • MyBatis
  • JWT
  • 拦截器 / AOP
  • RBAC 权限模型
  • 本地缓存 + 可扩展分布式缓存思路

目标不是“写一个最炫的权限系统”,而是做一个可运行、可扩展、可排查、可上线的方案。


方案目标与设计边界

先明确目标,避免系统越做越重。

我们要解决的问题

  1. 用户身份认证:确认“你是谁”
  2. 接口访问鉴权:确认“你能不能调用这个接口”
  3. 权限模型清晰:支持用户、角色、权限点解耦
  4. 可扩展:后续能加入按钮权限、数据权限、租户隔离
  5. 高可用:多实例部署时不依赖单机 Session
  6. 性能可控:不能每个请求都全量查库

暂不展开的边界

这篇文章不重点展开:

  • OAuth2 全量授权体系
  • 复杂数据权限表达式引擎
  • 单点登录 SSO
  • 网关统一鉴权的完整实现

不过本文的设计会给这些后续演进留接口。


核心原理

整个体系可以拆成两层:

  1. 认证(Authentication):登录后颁发 token
  2. 授权(Authorization):根据用户拥有的权限,判断接口是否可访问

一、权限模型:RBAC + 接口权限点

推荐使用经典 RBAC 模型:

  • user:用户
  • role:角色
  • permission:权限点
  • user_role:用户与角色关系
  • role_permission:角色与权限关系

但这里有个关键设计:
权限点不要直接等同于菜单,而要抽象成接口可识别的 permission code。

例如:

  • user:list
  • user:add
  • user:delete
  • order:query
  • order: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 中统一处理:

  1. 获取接口上的权限注解
  2. 解析 token 得到用户信息
  3. 查询或读取缓存中的权限集合
  4. 判断是否包含该权限
  5. 不通过则返回 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_patternmethod 可用于做“接口资源与权限”的映射管理
  • 即使已经有菜单表,也建议单独维护接口权限表

系统交互时序

登录和接口访问的时序建议这样设计:

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 缓存:多实例共享权限数据

典型流程:

  1. 先查本地缓存
  2. 本地没有再查 Redis
  3. Redis 没有再查数据库
  4. 回填 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 条件写错了

止血方案

如果线上发现越权,先做两件事:

  1. 立刻提升用户 version
  2. 清理对应用户权限缓存

这通常能快速止损。

4. 403 和 401 混用

这是很多团队都会乱掉的地方。

  • 401 Unauthorized:未登录、token 无效、token 过期
  • 403 Forbidden:已登录,但无权限

建议严格区分,否则前端处理会很混乱。

5. ThreadLocal 泄漏

用了 SecurityContext 就必须记得清理。

原因

Tomcat 线程会复用,如果不 remove(),可能串数据。

正确做法

afterCompletion 里清理,异常流程也要能执行到。

这一点我以前也踩过,表现特别诡异:偶发性地拿到了上一个请求的用户信息。

6. 权限码设计不统一

比如有人写:

  • user:list
  • USER_ADD
  • addUser
  • /api/user/add

一段时间后就没人看得懂了。

建议规范

统一采用:

资源:动作

例如:

  • user:list
  • user:add
  • user:update
  • order:export

保持一致性,远比“语法优雅”更重要。


安全/性能最佳实践

这部分是上线前最值得一条条核对的。

安全最佳实践

1. 密码必须加密存储

示例里为了方便演示用了明文,这在生产环境绝对不行。
建议使用 BCrypt:

new BCryptPasswordEncoder().encode(password)

并在登录时用 matches 校验。

2. JWT 密钥不要写死在代码里

应该放在:

  • 配置中心
  • 环境变量
  • Kubernetes Secret

同时要有轮换方案。

3. 不要把全部权限塞进 JWT

很多人喜欢把权限列表直接写入 token,图省事。
但问题很明显:

  • token 体积变大
  • 权限变更无法及时生效
  • 敏感信息暴露风险更高

更稳妥的做法是:
token 只存最小身份信息,权限走缓存/服务端查询。

4. 管理接口建议加审计日志

例如记录:

  • 谁在什么时间
  • 修改了谁的角色/权限
  • 来源 IP 是什么

出了权限事故时,这些日志特别关键。

5. 防重放与接口签名

如果接口不仅给前端页面用,还要开放给第三方系统调用,单纯 JWT 不够。
建议补充:

  • timestamp
  • nonce
  • sign

也就是用户级鉴权应用级签名鉴权分开做,不要混在一起。

性能最佳实践

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 项目里设计一套高可用的用户权限与接口鉴权体系,我建议优先抓住下面这几个核心点:

  1. 认证与授权分离

    • 认证解决“是谁”
    • 授权解决“能做什么”
  2. 使用 RBAC + 权限码,而不是硬编码角色

    • 接口依赖权限点
    • 角色只是权限组合
  3. JWT 做无状态登录,version 做可控失效

    • 适合多实例部署
    • 权限变更可快速生效
  4. 注解 + 拦截器统一鉴权

    • 业务代码更干净
    • 规则集中,便于维护
  5. 权限查询必须缓存

    • 否则数据库压力会很快上来
    • 本地缓存 + Redis 是常见实用组合
  6. 把 401/403、缓存失效、ThreadLocal 清理这些细节做好

    • 真正影响线上稳定性的,往往就是这些“小地方”

如果你的项目目前还处在“只有登录,没有细粒度权限”的阶段,最实际的第一步不是一次性做复杂平台,而是先把这三件事补齐:

  • 建立 user-role-permission 模型
  • 用注解声明接口权限
  • 加入 token version 校验

这三步做完,系统的安全性和可维护性会明显上一个台阶。


分享到:

上一篇
《Spring Boot 中级实战:基于 Actuator、Micrometer 与 Prometheus 搭建应用监控与告警体系》
下一篇
《Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:设计、穿透防护与一致性治理》