← 返回

API 鉴权实战(一):AK/SK、API Key、RBAC 与统一认证授权架构设计

作者:林 | 系列:API 鉴权实战 | 适合读者:Java 后端/架构师

本文代码使用 Lombok 简化 POJO,所有 getter/setter/builder 均由 Lombok 自动生成。

<!-- pom.xml -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <scope>provided</scope>
</dependency>

一、背景:为什么要做 API 鉴权

对外暴露 API 时,两个核心问题绕不开:

  1. 调用方是谁?(认证,Authentication)
  2. 他能做什么?(授权,Authorization)

简单的内部系统可能用一个 API Key 就够了。但当系统开始支持多租户、第三方接入、AI 开放平台、文件上传、额度控制时,单一的鉴权方式撑不住。你需要一套完整的体系:从密钥管理到权限判断,从缓存设计到上下文传递。

本文从更基础的 API Key 讲起,逐步构建到 PDP 策略决策点,把认证授权的全链路打通。


二、五种鉴权方式详解

2.1 API Key

原理: 给每个调用方分配一个唯一的密钥字符串,请求时放在 Header 里,服务端查库校验。

适用场景: 内部服务间调用、快速原型、低安全要求的公开 API。

不适用场景: 对外开放的 API,API Key 是静态的,明文传输,一旦被截获攻击者就能冒充调用方。

@Component
@RequiredArgsConstructor
public class ApiKeyAuthFilter extends OncePerRequestFilter {

    private final AppAccessKeyMapper appAccessKeyMapper;

    private static final String HEADER_NAME = "X-API-Key";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String apiKey = request.getHeader(HEADER_NAME);
        if (apiKey == null || apiKey.isBlank()) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Missing API Key\"}");
            return;
        }

        AppAccessKey keyInfo = appAccessKeyMapper.findByAccessKey(apiKey);
        if (keyInfo == null || keyInfo.getStatus() != 1) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Invalid API Key\"}");
            return;
        }

        if (keyInfo.getExpiredAt() != null && keyInfo.getExpiredAt().isBefore(LocalDateTime.now())) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"API Key expired\"}");
            return;
        }

        request.setAttribute("appId", keyInfo.getAppId());
        request.setAttribute("appCode", keyInfo.getAppCode());
        chain.doFilter(request, response);
    }
}

为什么 API Key 不安全:

  • 明文传输,抓包即得
  • 无法防重放攻击
  • 无法验证请求是否被篡改
  • 密钥泄露后只能重置,没有细粒度的失效手段

2.2 AK/SK 鉴权

原理: 把密钥拆成两个,Access Key(公开标识)和 Secret Key(保密签名密钥)。请求时用 Secret Key 对请求内容做 HMAC-SHA256 签名,服务端用同样的算法验证。Secret Key 本身不在网络上传输。

适用场景: 对外开放的 API、第三方开发者接入、云服务商 API(AWS、阿里云、腾讯云都用这种)。

不适用场景: 前端浏览器(签名计算复杂,不适合 JS 环境)。

签名算法

待签名字符串构造规则:

CanonicalRequest = HTTP_METHOD + "\n"
                 + CANONICAL_URI + "\n"
                 + CANONICAL_QUERY_STRING + "\n"
                 + CANONICAL_HEADERS + "\n"
                 + SIGNED_HEADERS + "\n"
                 + BODY_SHA256

StringToSign = "HMAC-SHA256" + "\n"
             + TIMESTAMP + "\n"
             + NONCE + "\n"
             + SHA256(CanonicalRequest)

关键点是先规范化再签名:query 要按规则排序和 URL 编码,body 要签 hash,参与签名的 header 要明确列进 SignedHeaders,否则参数顺序、编码差异或 body 篡改都可能绕过校验。

签名计算:

Signature = Base64(HMAC-SHA256(SecretKey, StringToSign))

Authorization Header 格式:

Authorization: AK-HMAC AccessKey=AKIDxxxx, Signature=<signature>, Timestamp=<timestamp>, Nonce=<nonce>

签名工具类

public class AkSkUtils {

    /**
     * 生成签名
     */
    public static String sign(String method, String path, String queryString,
                              String timestamp, String nonce, String secretKey) {
        String sortedParams = sortQueryString(queryString);
        String stringToSign = method + "\n"
                + path + "\n"
                + sortedParams + "\n"
                + timestamp + "\n"
                + nonce;
        return Base64.getEncoder().encodeToString(
                HmacUtils.hmacSha256(secretKey, stringToSign));
    }

    /**
     * 验证签名(时间常量比较,防时序攻击)
     */
    public static boolean verify(String method, String path, String queryString,
                                 String timestamp, String nonce,
                                 String secretKey, String expectedSignature) {
        String actual = sign(method, path, queryString, timestamp, nonce, secretKey);
        return MessageDigest.isEqual(
                actual.getBytes(StandardCharsets.UTF_8),
                expectedSignature.getBytes(StandardCharsets.UTF_8));
    }

    /**
     * 查询参数按 key 字典序排序
     */
    public static String sortQueryString(String queryString) {
        if (queryString == null || queryString.isBlank()) {
            return "";
        }
        TreeMap<String, String> params = new TreeMap<>();
        for (String pair : queryString.split("&")) {
            String[] kv = pair.split("=", 2);
            params.put(kv[0], kv.length > 1 ? kv[1] : "");
        }
        return params.entrySet().stream()
                .map(e -> e.getKey() + "=" + e.getValue())
                .collect(Collectors.joining("&"));
    }

    public static String generateAccessKey() {
        return "AK-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase();
    }

    public static String generateSecretKey() {
        return "SK-" + UUID.randomUUID().toString().replace("-", "");
    }
}

认证过滤器

@Component
@RequiredArgsConstructor
public class AkSkAuthFilter extends OncePerRequestFilter {

    private final AppAccessKeyMapper appAccessKeyMapper;
    private final RedisTemplate<String, String> redisTemplate;

    private static final long MAX_TIMESTAMP_DIFF_MS = 300_000; // 5 分钟

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("AK-HMAC ")) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Missing Authorization header\"}");
            return;
        }

        Map<String, String> params = parseAuthHeader(authHeader);
        String accessKey = params.get("AccessKey");
        String signature = params.get("Signature");
        String timestamp = params.get("Timestamp");
        String nonce = params.get("Nonce");

        // 校验必填参数
        if (accessKey == null || signature == null || timestamp == null || nonce == null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Incomplete auth params\"}");
            return;
        }

        // 防重放:校验时间戳
        long requestTime;
        try {
            requestTime = Long.parseLong(timestamp);
        } catch (NumberFormatException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Invalid timestamp\"}");
            return;
        }
        if (Math.abs(System.currentTimeMillis() - requestTime) > MAX_TIMESTAMP_DIFF_MS) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Request expired\"}");
            return;
        }

        // 防重放:校验 Nonce(按 accessKey 维度存 Redis,TTL 5 分钟)
        String nonceKey = "auth:nonce:" + accessKey + ":" + nonce;
        Boolean isNew = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", 5, TimeUnit.MINUTES);
        if (Boolean.FALSE.equals(isNew)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Duplicate nonce\"}");
            return;
        }

        // 根据 AK 查找 App
        AppAccessKey keyInfo = appAccessKeyMapper.findByAccessKey(accessKey);
        if (keyInfo == null || keyInfo.getStatus() != 1) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Invalid Access Key\"}");
            return;
        }

        // 验证签名
        String method = request.getMethod();
        String path = request.getRequestURI();
        String queryString = request.getQueryString();

        boolean valid = AkSkUtils.verify(method, path, queryString,
                timestamp, nonce, keyInfo.getSecretKey(), signature);

        if (!valid) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Signature mismatch\"}");
            return;
        }

        // 放入上下文
        request.setAttribute("appId", keyInfo.getAppId());
        request.setAttribute("appCode", keyInfo.getAppCode());
        request.setAttribute("subjectType", "APP");

        chain.doFilter(request, response);
    }

    private Map<String, String> parseAuthHeader(String header) {
        String params = header.substring("AK-HMAC ".length());
        Map<String, String> map = new HashMap<>();
        for (String part : params.split(",")) {
            String[] kv = part.trim().split("=", 2);
            if (kv.length == 2) {
                map.put(kv[0].trim(), kv[1].trim());
            }
        }
        return map;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return "OPTIONS".equalsIgnoreCase(request.getMethod());
    }
}

客户端签名示例

public class AkSkClient {

    /**
     * 构造带签名的请求头
     */
    public static HttpHeaders buildAuthHeaders(String method, String path,
                                               String queryString,
                                               String accessKey,
                                               String secretKey) {
        String timestamp = String.valueOf(System.currentTimeMillis());
        String nonce = UUID.randomUUID().toString();

        String signature = AkSkUtils.sign(method, path, queryString,
                timestamp, nonce, secretKey);

        String authValue = "AK-HMAC "
                + "AccessKey=" + accessKey + ", "
                + "Signature=" + signature + ", "
                + "Timestamp=" + timestamp + ", "
                + "Nonce=" + nonce;

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", authValue);
        return headers;
    }

    /**
     * 使用示例
     */
    public static void main(String[] args) {
        String ak = "AK-X1Y2Z3W4V5U6T7S8";
        String sk = "SK-a1b2c3d4-e5f6-7890-abcd-ef1234567890";

        HttpHeaders headers = buildAuthHeaders(
                "GET", "/api/products", "page=1&size=10", ak, sk);

        RestTemplate restTemplate = new RestTemplate();
        HttpEntity<Void> entity = new HttpEntity<>(headers);
        ResponseEntity<String> response = restTemplate.exchange(
                "https://api.example.com/api/products?page=1&size=10",
                HttpMethod.GET, entity, String.class);
        System.out.println(response.getBody());
    }
}

2.3 JWT

原理: 服务端签发一个自包含的 Token(Header.Payload.Signature),客户端每次请求带上。服务端不需要查库,只需要验证签名和过期时间。

适用场景: 前后端分离的 Web 应用、SSO 单点登录、短时间的授权令牌。

不适用场景: 需要主动失效的场景(JWT 签发后无法单方面撤销,除非维护黑名单)。

@Component
public class JwtUtils {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration:7200}")
    private long expirationSeconds;

    /**
     * 签发 Token
     */
    public String generateToken(Long userId, String username, String tenantId) {
        Date now = new Date();
        Date expireAt = new Date(now.getTime() + expirationSeconds * 1000);

        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim("username", username)
                .claim("tenantId", tenantId)
                .setIssuedAt(now)
                .setExpiration(expireAt)
                .signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)))
                .compact();
    }

    /**
     * 解析 Token
     */
    public Claims parseToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)))
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 从 Token 中提取 userId
     */
    public Long getUserId(String token) {
        Claims claims = parseToken(token);
        return Long.parseLong(claims.getSubject());
    }
}

JWT 的局限性:

  • 签发后无法主动失效,只能等过期。解决办法是维护一个 Redis 黑名单
  • Payload 是 Base64 编码,不是加密,不要放敏感信息
  • Token 越来越长(加的 claims 越多),每次请求都要传输

2.4 OAuth2

原理: 用户(资源所有者)授权第三方应用访问自己的资源,第三方获得一个 Access Token。核心是"授权"而不是"认证"。

四种授权模式:

模式适用场景
授权码模式第三方登录(微信、GitHub),更安全
客户端凭证模式服务间调用(M2M),无用户参与
密码模式自家 App(已不推荐,暴露用户密码)
设备模式无浏览器的设备(CLI、智能硬件)

OAuth2 在本文不展开讲,重点是 AK/SK 和 RBAC。需要对接第三方登录时,推荐用 Spring Security OAuth2 或 Sa-Token。

2.5 内部服务调用

微服务之间的调用不需要 AK/SK 签名,但也不能完全不鉴权。两种常见方式:

方式一:Internal Service Token

网关为每个内部服务签发一个 Token,服务间调用时带上。

public class InternalTokenUtils {

    // 从 KMS / 配置中心 / 环境变量注入,不要硬编码在代码里
    private static final Supplier<String> SECRET_PROVIDER = () -> System.getenv("INTERNAL_TOKEN_SIGNING_KEY");

    /**
     * 网关为服务签发内部 Token
     */
    public static String generateToken(String serviceName, Set<String> scopes) {
        String payload = JSON.toJSONString(Map.of(
                "serviceName", serviceName,
                "scopes", scopes,
                "issuedAt", System.currentTimeMillis(),
                "expireAt", System.currentTimeMillis() + 3600_000
        ));
        return Base64.getEncoder().encodeToString(payload.getBytes())
                + "." + HmacUtils.hmacSha256Hex(SECRET_PROVIDER.get(), payload);
    }

    /**
     * 服务端验证内部 Token
     */
    public static boolean verify(String token) {
        int dotIdx = token.lastIndexOf('.');
        if (dotIdx == -1) return false;

        String payloadBase64 = token.substring(0, dotIdx);
        String signature = token.substring(dotIdx + 1);
        String payload = new String(Base64.getDecoder().decode(payloadBase64));

        String expectedSig = HmacUtils.hmacSha256Hex(SECRET_PROVIDER.get(), payload);
        if (!MessageDigest.isEqual(expectedSig.getBytes(), signature.getBytes())) {
            return false;
        }

        // 检查过期
        Map<String, Object> data = JSON.parseObject(payload, Map.class);
        long expireAt = ((Number) data.get("expireAt")).longValue();
        return System.currentTimeMillis() < expireAt;
    }
}

方式二:mTLS(双向 TLS)

服务间通信时双方互相验证证书。安全性更高,但运维成本也高(证书管理)。适合对安全要求极高的场景,如金融系统。

2.6 选型决策

flowchart TD API["API 调用方"] --> Internal{"内部服务"} Internal -- "是" --> MTLS["mTLS 或工作负载身份"] Internal -- "否" --> Browser{"浏览器用户"} Browser -- "自有前端" --> Session["会话或 JWT 与 RBAC"] Browser -- "第三方登录" --> OAuth["OAuth 2.0 / OIDC 授权码流程"] Browser -- "否" --> Partner{"第三方系统"} Partner -- "需要请求签名" --> AKSK["AK/SK 与 App Scope"] Partner -- "低风险简单访问" --> Key["受限 API Key"]
维度API KeyAK/SKJWTOAuth2Internal Token
传输安全明文签名值,SK 不传明文(靠 HTTPS)Token(靠 HTTPS)HMAC 签名
防篡改签名覆盖请求参数签名覆盖 Payload取决于实现签名覆盖 Payload
防重放时间戳 + NonceToken 过期时间Token 过期时间过期时间
主动失效重置 Key重置 SK黑名单撤销 Token重新签发
复杂度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

三、RBAC 权限模型

AK/SK 解决"你是谁",RBAC 解决"你能做什么"。RBAC(Role-Based Access Control)是目前主流的权限模型。

3.1 RBAC0:基础模型

入门门槛低的 RBAC:用户关联角色,角色关联权限。

erDiagram USER }o--o{ ROLE : assigned ROLE }o--o{ PERMISSION : grants USER { bigint id string tenant_id string status } ROLE { bigint id string code } PERMISSION { bigint id string action }

数据库表设计

-- 用户表
CREATE TABLE sys_user (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    username    VARCHAR(64) NOT NULL UNIQUE,
    password    VARCHAR(128) NOT NULL,
    tenant_id   VARCHAR(64) NOT NULL DEFAULT 'default',
    status      TINYINT DEFAULT 1 COMMENT '1=启用 0=禁用',
    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_tenant (tenant_id)
) COMMENT='用户表';

-- 角色表
CREATE TABLE sys_role (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    role_code   VARCHAR(64) NOT NULL COMMENT '角色编码',
    role_name   VARCHAR(128) NOT NULL COMMENT '角色名称',
    tenant_id   VARCHAR(64) NOT NULL DEFAULT 'default',
    status      TINYINT DEFAULT 1,
    UNIQUE KEY uk_tenant_code (tenant_id, role_code)
) COMMENT='角色表';

-- 权限表
CREATE TABLE sys_permission (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    permission_code VARCHAR(128) NOT NULL UNIQUE COMMENT '权限编码,如 product:create',
    permission_name VARCHAR(128) NOT NULL COMMENT '权限名称',
    resource_type   VARCHAR(32) COMMENT '资源类型:api/menu/button',
    description     VARCHAR(512)
) COMMENT='权限表';

-- 用户-角色关联
CREATE TABLE sys_user_role (
    user_id     BIGINT NOT NULL,
    role_id     BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id),
    INDEX idx_role (role_id)
) COMMENT='用户-角色关联';

-- 角色-权限关联
CREATE TABLE sys_role_permission (
    role_id         BIGINT NOT NULL,
    permission_id   BIGINT NOT NULL,
    PRIMARY KEY (role_id, permission_id),
    INDEX idx_permission (permission_id)
) COMMENT='角色-权限关联';

权限查询

@Mapper
public interface UserRoleMapper {

    /**
     * 查询用户的角色编码集合
     */
    @Select("SELECT r.role_code FROM sys_role r " +
            "INNER JOIN sys_user_role ur ON ur.role_id = r.id " +
            "WHERE ur.user_id = #{userId} AND r.status = 1")
    Set<String> findRoleCodesByUserId(@Param("userId") Long userId);

    /**
     * 查询用户的权限编码集合(通过角色关联)
     */
    @Select("SELECT DISTINCT p.permission_code FROM sys_permission p " +
            "INNER JOIN sys_role_permission rp ON rp.permission_id = p.id " +
            "INNER JOIN sys_user_role ur ON ur.role_id = rp.role_id " +
            "WHERE ur.user_id = #{userId}")
    Set<String> findPermissionCodesByUserId(@Param("userId") Long userId);
}

3.2 RBAC1:角色继承

RBAC0 的问题是角色之间没有层级关系。现实中,“部门经理"应该自动拥有"普通员工"的所有权限,不需要手动把每个权限都分配一遍。

RBAC1 通过角色继承解决:角色可以继承其他角色的权限。

flowchart BT Employee["employee<br/>基础权限"] --> Manager["manager<br/>继承基础权限并增加管理权限"] Manager --> Director["director<br/>继承管理权限并增加审批权限"]

继承关系表

-- 角色继承关系表
CREATE TABLE sys_role_hierarchy (
    parent_role_id  BIGINT NOT NULL COMMENT '被继承的角色(父)',
    child_role_id   BIGINT NOT NULL COMMENT '继承者(子)',
    PRIMARY KEY (parent_role_id, child_role_id),
    INDEX idx_child (child_role_id)
) COMMENT='角色继承关系(child 继承 parent 的权限)';

数据示例:

-- employee(id=1), manager(id=2), director(id=3)
INSERT INTO sys_role_hierarchy VALUES (1, 2);  -- manager 继承 employee
INSERT INTO sys_role_hierarchy VALUES (2, 3);  -- director 继承 manager

递归查询权限

RBAC1 的权限查询需要递归,一个角色的权限 = 自身权限 + 所有父角色的权限。

@Service
@RequiredArgsConstructor
public class RoleHierarchyService {

    private final RoleHierarchyMapper roleHierarchyMapper;
    private final RolePermissionMapper rolePermissionMapper;

    /**
     * 递归获取角色的所有父角色 ID(包括自己)
     */
    public Set<Long> getAllParentRoleIds(Long roleId) {
        Set<Long> result = new HashSet<>();
        result.add(roleId);
        collectParentRoleIds(roleId, result, new HashSet<>());
        return result;
    }

    private void collectParentRoleIds(Long roleId, Set<Long> result, Set<Long> visited) {
        if (visited.contains(roleId)) return; // 防循环
        visited.add(roleId);

        List<Long> parentIds = roleHierarchyMapper.findParentRoleIds(roleId);
        for (Long parentId : parentIds) {
            if (result.add(parentId)) {
                collectParentRoleIds(parentId, result, visited);
            }
        }
    }

    /**
     * 获取角色的全部权限(含继承)
     */
    public Set<String> getPermissionCodesWithHierarchy(Long roleId) {
        Set<Long> allRoleIds = getAllParentRoleIds(roleId);
        return rolePermissionMapper.findPermissionCodesByRoleIds(allRoleIds);
    }
}

3.3 RBAC2:约束

RBAC2 在 RBAC0/1 的基础上增加了约束条件,防止不合理的权限分配。

静态互斥角色(SSD)

一个用户不能同时拥有两个互斥角色。比如"出纳"和"审计"不能是同一个人。

-- 静态互斥角色组
CREATE TABLE sys_ssd_group (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    group_name  VARCHAR(128) NOT NULL COMMENT '互斥组名称,如 财务互斥组',
    max_members INT NOT NULL DEFAULT 1 COMMENT '该组最多允许用户拥有几个角色'
) COMMENT='静态互斥角色组';

-- 互斥组-角色关联
CREATE TABLE sys_ssd_group_role (
    group_id    BIGINT NOT NULL,
    role_id     BIGINT NOT NULL,
    PRIMARY KEY (group_id, role_id)
) COMMENT='互斥组包含的角色';

分配角色时的校验:

@Service
@RequiredArgsConstructor
public class RoleConstraintService {

    private final SsdGroupMapper ssdGroupMapper;

    /**
     * 检查给用户分配新角色是否违反 SSD 约束
     */
    public void checkSsdConstraint(Long userId, Long newRoleId) {
        List<SsdGroup> groups = ssdGroupMapper.findGroupsByRoleId(newRoleId);
        for (SsdGroup group : groups) {
            // 查询用户在该互斥组中已拥有的角色数
            int ownedCount = ssdGroupMapper.countUserRolesInGroup(userId, group.getId());
            if (ownedCount >= group.getMaxMembers()) {
                throw new ForbiddenException(
                    "违反互斥约束:角色 " + newRoleId + " 属于互斥组 [" + group.getGroupName()
                    + "],你已拥有该组 " + ownedCount + " 个角色,上限 " + group.getMaxMembers());
            }
        }
    }
}

动态互斥角色(DSD)

和 SSD 不同,DSD 限制的是同一个会话中同时激活的角色。用户可以拥有两个互斥角色,但不能在同一时间都激活。

public class SessionRoleManager {

    private static final ThreadLocal<Set<Long>> ACTIVE_ROLES = ThreadLocal.withInitial(HashSet::new);

    /**
     * 激活角色(登录后选择激活哪些角色)
     */
    public void activateRole(Long userId, Long roleId) {
        Set<Long> active = ACTIVE_ROLES.get();

        // 检查 DSD 约束
        checkDsdConstraint(userId, roleId, active);

        active.add(roleId);
    }

    private void checkDsdConstraint(Long userId, Long roleId, Set<Long> activeRoles) {
        List<SsdGroup> groups = ssdGroupMapper.findGroupsByRoleId(roleId);
        for (SsdGroup group : groups) {
            long conflictCount = activeRoles.stream()
                    .filter(activeRoleId -> ssdGroupMapper.isRoleInGroup(activeRoleId, group.getId()))
                    .count();
            if (conflictCount >= group.getMaxMembers()) {
                throw new ForbiddenException("动态互斥约束:不能同时激活同一互斥组的多个角色");
            }
        }
    }
}

3.4 RBAC3

RBAC3 = RBAC1(角色继承)+ RBAC2(约束)。实际项目中,大多数系统做到 RBAC1 就够了,RBAC2 在金融、政务等对权限控制要求严格的场景才需要。

3.5 基于注解的权限控制

自定义注解

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
    String value();
    Logical logical() default Logical.AND;

    enum Logical { AND, OR }
}

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
    String value();
}

AOP 切面

@Aspect
@Component
@RequiredArgsConstructor
public class PermissionAspect {

    private final UserRoleMapper userRoleMapper;

    @Around("@annotation(requirePermission)")
    public Object checkPermission(ProceedingJoinPoint pjp,
                                  RequirePermission requirePermission) throws Throwable {
        Long userId = SecurityContextHolder.getCurrentUserId();
        if (userId == null) {
            throw new UnauthorizedException("未登录");
        }

        Set<String> userPerms = getUserPermissions(userId);
        String required = requirePermission.value();

        boolean hasPermission;
        if (requirePermission.logical() == Logical.AND) {
            hasPermission = userPerms.contains(required);
        } else {
            hasPermission = userPerms.contains(required);
        }

        if (!hasPermission) {
            throw new ForbiddenException("权限不足:需要 " + required);
        }

        return pjp.proceed();
    }

    @Around("@annotation(requireRole)")
    public Object checkRole(ProceedingJoinPoint pjp,
                            RequireRole requireRole) throws Throwable {
        Long userId = SecurityContextHolder.getCurrentUserId();
        if (userId == null) {
            throw new UnauthorizedException("未登录");
        }

        Set<String> userRoles = userRoleMapper.findRoleCodesByUserId(userId);
        if (!userRoles.contains(requireRole.value())) {
            throw new ForbiddenException("角色不足:需要 " + requireRole.value());
        }

        return pjp.proceed();
    }

    private Set<String> getUserPermissions(Long userId) {
        return userRoleMapper.findPermissionCodesByUserId(userId);
    }
}

Controller 使用

@RestController
@RequestMapping("/products")
public class ProductController {

    @RequirePermission("product:create")
    @PostMapping
    public Product create(@RequestBody ProductDTO dto) { ... }

    @RequirePermission("product:read")
    @GetMapping("/{id}")
    public Product get(@PathVariable Long id) { ... }

    @RequirePermission("product:update")
    @PutMapping("/{id}")
    public Product update(@PathVariable Long id, @RequestBody ProductDTO dto) { ... }

    @RequirePermission("product:delete")
    @DeleteMapping("/{id}")
    public void delete(@PathVariable Long id) { ... }
}

四、App 维度授权模型

4.1 为什么需要 App 维度

RBAC 解决的是"用户能做什么”。但当第三方开发者通过 API 接入时,调用方并非用户,重点是应用。需要回答的问题变成:“这个 App 能做什么?”

直接把权限挂在用户上有几个问题:

  • 一个用户可能维护多个业务线,每个业务线的 API 权限范围不同
  • 第三方调用方关心的是"我的 App 能做什么",不是"我属于哪个租户"
  • 按用户控额度粒度太粗,需要按 App 精确控制

4.2 公司-子公司-App 多级架构:归属与管理

在真实的企业场景中,核心问题是:App 到底归谁管?

问题拆解

一个公司有多个子公司,每个子公司有自己的业务系统。但"业务系统"这个词太模糊,我们需要明确:

  1. App 到底绑在哪? 绑在组织架构上(部门/子公司),还是绑在开发者个人上?
  2. 谁来创建 App? 平台管理员统一开,还是租户自己申请?
  3. 子公司开发者拿到 appkey 后,自己对接、自己维护? 还是平台统一管?
  4. 一个子公司有多个 appid,每个 appid 的 appkey 不一样,怎么管理?

推荐方案:三层归属模型

flowchart TB Platform["平台"] --> Tenant["租户或子公司"] Tenant --> Admin["租户管理员"] Tenant --> App1["业务 App A"] Tenant --> App2["业务 App B"] Admin -->|"管理"| App1 Admin -->|"管理"| App2 App1 --> Key1["独立 AK/SK、Scope、配额"] App2 --> Key2["独立 AK/SK、Scope、配额"]

关键设计决策:

① App 绑定到租户,不绑定到个人

❌ 错误:App 绑到用户(张三创建的 App,张三离职了怎么办?)
✅ 正确:App 绑到租户(公司),由租户管理员管理

为什么?因为 App 代表的是"一个业务系统",不是"一个人的工具"。人员会流动,但系统要持续运行。

② 租户自助创建 App,平台不干预

租户管理员登录管理后台
  → "创建应用"
  → 填写应用名称、选择业务类型
  → 系统自动生成 appid + appkey(AK/SK 对)
  → 租户管理员分配 scope(从自己的权限范围里选)
  → 租户管理员指定开发者(谁能用这个 appkey 对接)

③ 每个 App 独立的 appkey,互不影响

子公司A
  ├── App: 电商系统    → appkey_1(独立计费、独立限流)
  ├── App: CRM 系统    → appkey_2(独立计费、独立限流)
  └── App: AI 客服     → appkey_3(独立计费、独立限流)

三个 appkey 互相隔离:一个被吊销不影响其他两个

数据库设计

-- 租户表(公司/子公司)
CREATE TABLE iam_tenant (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    tenant_code     VARCHAR(64) NOT NULL UNIQUE COMMENT '租户编码,如 sub_a_001',
    tenant_name     VARCHAR(256) NOT NULL COMMENT '公司/子公司名称',
    parent_id       BIGINT DEFAULT NULL COMMENT '父租户 ID,支持集团→子公司层级',
    admin_user_id   BIGINT NOT NULL COMMENT '租户管理员(创建者)',
    status          TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用',
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_parent (parent_id)
) COMMENT='租户表';

-- 用户-租户关联(一个用户可以属于多个租户)
CREATE TABLE iam_user_tenant (
    user_id         BIGINT NOT NULL,
    tenant_id       BIGINT NOT NULL,
    role_in_tenant  VARCHAR(64) NOT NULL DEFAULT 'member' COMMENT 'admin/member/developer',
    PRIMARY KEY (user_id, tenant_id)
) COMMENT='用户-租户关联';

-- 应用表(绑定到租户)
CREATE TABLE iam_app (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    app_id          VARCHAR(64) NOT NULL UNIQUE COMMENT '应用标识,系统生成',
    app_code        VARCHAR(64) NOT NULL COMMENT '应用编码(业务可读)',
    app_name        VARCHAR(128) NOT NULL COMMENT '应用名称',
    tenant_id       BIGINT NOT NULL COMMENT '所属租户(公司/子公司)',
    app_type        VARCHAR(32) NOT NULL DEFAULT 'business' COMMENT 'business/ai/open_platform',
    status          TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用',
    created_by      BIGINT NOT NULL COMMENT '创建者 userId',
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_tenant (tenant_id),
    INDEX idx_created_by (created_by)
) COMMENT='应用表(绑定到租户)';

-- 应用密钥表(一个 App 可以有多组密钥)
CREATE TABLE iam_app_access_key (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    app_id          VARCHAR(64) NOT NULL,
    access_key      VARCHAR(64) NOT NULL UNIQUE COMMENT '公开标识,即 AK',
    secret_key      VARCHAR(128) NOT NULL COMMENT '加密存储,即 SK',
    key_name        VARCHAR(128) DEFAULT NULL COMMENT '密钥名称,如"生产环境"、"测试环境"',
    status          TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用 2=已吊销',
    expire_at       DATETIME DEFAULT NULL,
    last_used_at    DATETIME DEFAULT NULL,
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_access_key (access_key),
    INDEX idx_app_id (app_id)
) COMMENT='应用密钥表(一个 App 可以有多组密钥,支持轮换)';

实际使用流程

1. 平台创建租户
   平台管理员 → 创建"子公司A"租户 → 指定张三为租户管理员

2. 租户管理员创建 App
   张三登录 → "我的应用" → "创建应用"
     → 应用名称:电商系统
     → 应用类型:business
     → 系统自动生成:appid=APP_003, AK=AKIDxxxx, SK=your-secret-key
     → 张三拿到 AK/SK,交给开发团队

3. 开发者对接
   李四(开发者)拿到 AK/SK
     → 按照 API 文档对接
     → 调用时用 AK/SK 签名

4. 日常管理
   张三可以:
     → 查看 APP_003 的调用量和账单
     → 轮换密钥(旧密钥 7 天过渡期)
     → 调整 scope(只能选自己权限范围内的)
     → 吊销密钥(发现异常时)
     → 指定其他开发者

5. 人员变动
   张三离职 → 平台管理员将租户管理员转给王五
   → App 和 appkey 不受影响,业务不中断
   → 王五接管所有 App 的管理权

App 权限边界约束

App 的 scope 不能超出两个边界:

边界 1:租户边界
  子公司A 的 App 不能访问子公司B 的数据
  (通过 tenant_id 隔离,每个请求校验)

边界 2:创建者权限边界
  租户管理员只有 [product:*, order:read] 权限
  → 他创建的 App 最多只能有 [product:*, order:read]
  → 不能越权分配 [admin:delete](他自己都没有)

4.3 AppKey 详解:AI 时代的应用身份标识

在 AI 开放平台、第三方 API 接入场景中,AppKey(或叫 API Key)是调用方的"身份证"。它和 AK/SK 的关系如下:

概念说明典型场景
AppKey应用的公开标识,通常就是 AK(Access Key)AI 平台调用、第三方接入
API Key单字符串密钥,不区分 AK/SK内部服务、低安全场景
AK/SK密钥对,SK 用于签名不在网上传输对外开放 API、云服务商

AI 场景下的 AppKey 使用:

// 典型的 AI API 调用(如 OpenAI、百度文心、通义千问)
POST https://api.openai.com/v1/chat/completions
Authorization: Bearer your-api-key  ← 这是单字符串 API Key,要按密钥保护
Content-Type: application/json

{"model": "gpt-4", "messages": [...]}

在我们的体系中,AI 调用走 AK/SK 签名模式:

// 我们的 AI 开放平台调用方式
POST https://ai.example.com/api/v1/image/generate
Authorization: AK-HMAC AccessKey=AKID_APP_003, Signature=<signature>, Timestamp=<timestamp>, Nonce=<nonce>
Content-Type: application/json

{"model": "seedream", "prompt": "一只猫"}

AppKey 的生命周期管理:

stateDiagram-v2 [*] --> Created Created --> Active: enable Active --> Rotating: issue replacement Rotating --> Active: revoke old key Created --> Revoked: expire or revoke Active --> Revoked: expire or revoke Rotating --> Revoked: revoke all Revoked --> [*]
  • 创建:为 App 生成 AK/SK 对,SK 只在创建时返回一次,之后加密存储
  • 轮换:创建新密钥 → 双密钥并行期(7天)→ 吊销旧密钥
  • 过期:设置 expire_at,到期自动禁用,调用方收到 401
  • 吊销:马上将 status 设为 2,所有使用该密钥的请求马上被拒绝
  • 审计:记录 last_used_at,长时间未使用的密钥可主动通知调用方

AppKey 与计费的关系:

每个 AppKey 独立计费。通过 AK 精确追踪每个 App 的调用量,实现:

  • 按 App 维度统计调用次数、Token 消耗
  • 不同 App 设置不同的额度上限
  • 超额自动限流或拒绝
  • 账单按 App 明细出账

4.4 租户内多 App 管理实践

一个子公司(租户)通常有多个业务系统,每个系统对应一个 App。实际运营中的关键问题:

租户管理员的操作界面

子公司A 管理后台
├── 应用管理
│   ├── 电商系统(APP_003)
│   │   ├── 密钥管理 [生产环境] [测试环境]
│   │   ├── Scope 配置 [product:read, product:write, order:*]
│   │   ├── 调用统计 [今日 12,345 次]
│   │   └── 额度设置 [每日上限 100,000]
│   │
│   ├── CRM 系统(APP_004)
│   │   ├── 密钥管理 [生产环境]
│   │   ├── Scope 配置 [customer:*, order:read]
│   │   └── ...
│   │
│   └── [+ 创建应用]
├── 成员管理
│   ├── 张三(admin)→ 可管理所有 App
│   ├── 李四(developer)→ 可使用 APP_003 的密钥
│   └── 王五(developer)→ 可使用 APP_004 的密钥
└── 账单
    ├── APP_003:本月 ¥1,234
    ├── APP_004:本月 ¥567
    └── 合计:¥1,801

租户内权限控制

/**
 * 租户内 App 管理权限校验
 */
@Service
@RequiredArgsConstructor
public class TenantAppService {

    private final AppMapper appMapper;
    private final UserTenantMapper userTenantMapper;

    /**
     * 校验用户是否有权管理该 App
     * 规则:必须是同一租户的 admin,或者是 App 的创建者
     */
    public void checkManagePermission(Long userId, String appId) {
        App app = appMapper.findByAppId(appId);
        if (app == null) throw new NotFoundException("应用不存在");

        // 检查是否是同一租户
        UserTenant ut = userTenantMapper.find(userId, app.getTenantId());
        if (ut == null) throw new ForbiddenException("你不是该租户成员");

        // admin 可以管理所有 App,member 只能管理自己创建的
        if (!"admin".equals(ut.getRoleInTenant())
                && !app.getCreatedBy().equals(userId)) {
            throw new ForbiddenException("无权管理该应用");
        }
    }

    /**
     * 租户管理员创建 App
     */
    public App createApp(Long userId, String appName, String appType) {
        // 校验是租户 admin
        UserTenant ut = userTenantMapper.find(userId, currentTenantId());
        if (ut == null || !"admin".equals(ut.getRoleInTenant())) {
            throw new ForbiddenException("只有租户管理员可以创建应用");
        }

        String appId = "APP_" + generateId();
        App app = App.builder()
                .appId(appId)
                .appName(appName)
                .appType(appType)
                .tenantId(currentTenantId())
                .createdBy(userId)
                .status(1)
                .build();
        appMapper.insert(app);

        // 自动生成第一组密钥
        generateAccessKey(appId, "默认密钥");

        return app;
    }

    /**
     * 生成密钥(一个 App 可以有多组,支持环境隔离)
     */
    public AppAccessKey generateAccessKey(String appId, String keyName) {
        String ak = AkSkUtils.generateAccessKey();
        String sk = AkSkUtils.generateSecretKey();

        AppAccessKey key = AppAccessKey.builder()
                .appId(appId)
                .accessKey(ak)
                .secretKey(encrypt(sk)) // 加密存储
                .keyName(keyName)
                .status(1)
                .build();
        accessKeyMapper.insert(key);

        // SK 只在创建时返回一次
        key.setSecretKey(sk); // 明文返回给调用方
        return key;
    }
}

开发者拿到 appkey 后的对接流程

开发者李四拿到 appkey 后:

1. 阅读 API 文档,了解接口规范
2. 在代码中配置 AK/SK
3. 实现签名逻辑(或使用平台提供的 SDK)
4. 调用 API,处理响应
5. 异常处理(401=密钥错误,429=超额,500=服务端错误)

整个过程不需要平台介入,租户自主完成。

4.5 三层模型

App 的权限不是凭空产生的。参考 GitHub fine-grained token 的设计,核心约束是:App 的 scope 必须是创建者权限的子集。

第一层:Scope 目录(平台级)
  平台定义所有可选的 scope,类似 GitHub 的 fine-grained permissions
  product:read, product:write, order:read, ai:model:call, file:upload ...

第二层:用户 RBAC(租户级)
  用户通过角色获得权限集合
  张三的权限 = [product:*, order:read, ai:model:call]

第三层:App Scope(应用级)
  创建 App 时,从自己的权限集合中选取一部分
  张三的 App 可以选 [product:read, order:read]
  张三的 App 不能选  [admin:delete](他没有这个权限)

约束规则:App.Scope ⊆ Owner.User.Permissions

4.6 数据模型

-- 应用表
CREATE TABLE iam_app (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    app_id          VARCHAR(64) NOT NULL UNIQUE,
    app_code        VARCHAR(64) NOT NULL,
    app_name        VARCHAR(128) NOT NULL,
    owner_user_id   BIGINT NOT NULL COMMENT '创建者',
    tenant_id       VARCHAR(64) NOT NULL,
    status          TINYINT NOT NULL DEFAULT 1,
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_tenant (tenant_id),
    INDEX idx_owner (owner_user_id)
) COMMENT='应用表';

-- 应用密钥表
CREATE TABLE iam_app_access_key (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    app_id          VARCHAR(64) NOT NULL,
    access_key      VARCHAR(64) NOT NULL UNIQUE,
    secret_key      VARCHAR(128) NOT NULL COMMENT '加密存储',
    status          TINYINT NOT NULL DEFAULT 1,
    expire_at       DATETIME,
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_access_key (access_key),
    INDEX idx_app_id (app_id)
) COMMENT='应用密钥表';

-- 应用 Scope 表
CREATE TABLE iam_app_scope (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    app_id          VARCHAR(64) NOT NULL,
    scope_code      VARCHAR(128) NOT NULL,
    conditions      JSON COMMENT '附加条件:IP白名单、文件类型等',
    granted_by      BIGINT NOT NULL COMMENT '授权人 userId',
    granted_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    version         INT NOT NULL DEFAULT 1,
    UNIQUE KEY uk_app_scope (app_id, scope_code)
) COMMENT='应用授权范围';

-- 应用额度表
CREATE TABLE iam_app_quota (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    app_id          VARCHAR(64) NOT NULL,
    action_code     VARCHAR(128) NOT NULL,
    daily_limit     INT DEFAULT NULL,
    concurrency     INT DEFAULT NULL,
    used_today      INT DEFAULT 0,
    UNIQUE KEY uk_app_action (app_id, action_code)
) COMMENT='应用额度';

-- Scope 目录表(平台级)
CREATE TABLE iam_scope_catalog (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    scope_code      VARCHAR(128) NOT NULL UNIQUE,
    scope_name      VARCHAR(128) NOT NULL,
    category        VARCHAR(64) COMMENT 'product/order/ai/file/admin',
    description     VARCHAR(512),
    risk_level      VARCHAR(16) DEFAULT 'LOW',
    enabled         TINYINT DEFAULT 1,
    INDEX idx_category (category)
) COMMENT='Scope 目录';

4.7 创建 App 时的 Scope 分配校验

@Service
@RequiredArgsConstructor
@Slf4j
public class AppScopeService {

    private final UserPermissionService userPermissionService;
    private final AppScopeMapper appScopeMapper;

    /**
     * 给 App 分配 scope
     * 核心约束:scope 必须在 owner 的权限范围内
     */
    public void assignScope(String appId, String scopeCode, Long ownerUserId) {
        Set<String> ownerPerms = userPermissionService.getPermissions(ownerUserId);
        if (!ownerPerms.contains(scopeCode) && !hasWildcardMatch(ownerPerms, scopeCode)) {
            throw new ForbiddenException(
                "你没有权限 [" + scopeCode + "],不能授权给 App");
        }
        appScopeMapper.insert(new AppScope(appId, scopeCode, ownerUserId));
    }

    private boolean hasWildcardMatch(Set<String> perms, String scopeCode) {
        for (String perm : perms) {
            if (perm.endsWith(":*")) {
                String prefix = perm.substring(0, perm.length() - 2);
                if (scopeCode.startsWith(prefix + ":") || scopeCode.equals(prefix)) {
                    return true;
                }
            }
        }
        return false;
    }
}

4.8 App 与开放 API 的绑定

每个 API 端点声明自己需要什么 scope。类似 GitHub REST API 文档标注每个 endpoint 需要的 permission。

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireScope {
    String value();
}

@RestController
@RequestMapping("/products")
public class ProductController {

    @RequireScope("product:read")
    @GetMapping("/{id}")
    public Product getProduct(@PathVariable Long id) { ... }

    @RequireScope("product:write")
    @PutMapping("/{id}")
    public Product updateProduct(@PathVariable Long id, @RequestBody ProductDTO dto) { ... }
}

4.9 同一端点的两条认证路径

同一个 API,可能被 AK/SK(App)调用,也可能被 JWT(User)调用。PDP 根据主体类型选择检查逻辑:

PUT /products/123

路径 A:AK/SK → 主体是 App
  → 检查 App.Scope 是否包含 product:write

路径 B:JWT → 主体是 User
  → 检查 User.Permissions 是否包含 product:write
@Component
@RequiredArgsConstructor
public class AppScopeEvaluator implements PermissionEvaluator {

    private final AppScopeManager appScopeManager;

    @Override
    public AuthDecision evaluate(AuthSubject subject, AuthAction action,
                                 AuthResource resource, AuthContext context) {
        if (subject.getType() != SubjectType.APP) {
            return AuthDecision.abstain(); // 不是 App,跳过
        }

        String appId = subject.getId();
        String requiredScope = action.getActionCode();
        Set<String> appScopes = appScopeManager.getScopes(appId);

        if (appScopes.contains(requiredScope)) {
            return AuthDecision.allow();
        }

        // 通配符匹配
        for (String scope : appScopes) {
            if (scope.endsWith(":*") && requiredScope.startsWith(
                    scope.substring(0, scope.length() - 2))) {
                return AuthDecision.allow();
            }
        }

        return AuthDecision.deny("App [" + appId + "] 缺少 scope: " + requiredScope);
    }
}

五、缓存分层设计

5.1 为什么需要多级缓存

每次鉴权都查数据库会把请求压力传递给持久层。Redis 可以共享权限缓存,本地缓存还能减少网络访问。具体收益取决于部署拓扑、数据规模和缓存命中情况,应通过压测和线上分位数判断。

三级缓存的分工:

flowchart LR Request["鉴权请求"] --> L1{"L1 本地缓存"} L1 -- "命中" --> Result["返回权限快照"] L1 -- "未命中" --> L2{"L2 Redis"} L2 -- "命中并回填 L1" --> Result L2 -- "未命中" --> DB["L3 权限数据库"] DB --> Fill["回填 L2 与 L1"] --> Result Change["权限变更事件"] -. "失效或更新" .-> L1 Change -. "失效或更新" .-> L2

5.2 缓存 Key 设计

public final class CacheKeyPatterns {

    // ===== 用户维度 =====
    /** 用户角色集合:user:roles:{userId} → Set<String> roleCodes */
    public static final String USER_ROLES = "user:roles:%d";
    /** 用户权限集合:user:perms:{userId} → Set<String> permCodes */
    public static final String USER_PERMS = "user:perms:%d";
    /** 用户权限版本号:user:perm:ver:{userId} → long */
    public static final String USER_PERM_VERSION = "user:perm:ver:%d";

    // ===== 角色维度 =====
    /** 角色权限集合:role:perms:{roleCode} → Set<String> permCodes */
    public static final String ROLE_PERMS = "role:perms:%s";

    // ===== App 维度 =====
    /** App 授权范围:app:scopes:{appId} → Set<String> scopeCodes */
    public static final String APP_SCOPES = "app:scopes:%s";
    /** App 额度:app:quota:{appId}:{actionCode} → QuotaDTO */
    public static final String APP_QUOTA = "app:quota:%s:%s";
    /** App 配置版本号:app:ver:{appId} → long */
    public static final String APP_VERSION = "app:ver:%s";

    // ===== AK/SK 维度 =====
    /** AK 配置:ak:config:{accessKey} → AppAccessKey */
    public static final String AK_CONFIG = "ak:config:%s";

    // ===== Action 路由 =====
    /** Action 路由:action:route:{method}:{path} → actionCode */
    public static final String ACTION_ROUTE = "action:route:%s:%s";
}

5.3 TTL 配置

@Component
public class CacheTtlConfig {

    /**
     * L1 本地缓存 TTL(短,保证变更快速生效)
     */
    public Duration l1Ttl(CacheType type) {
        return switch (type) {
            case USER_ROLES, USER_PERMS -> Duration.ofSeconds(60);
            case ROLE_PERMS -> Duration.ofSeconds(120);
            case AK_CONFIG -> Duration.ofSeconds(30);
            case APP_SCOPES -> Duration.ofSeconds(60);
            case ACTION_ROUTE -> Duration.ofSeconds(300);
        };
    }

    /**
     * L2 Redis TTL(较长,减少 DB 压力)
     */
    public Duration l2Ttl(CacheType type) {
        return switch (type) {
            case USER_ROLES, USER_PERMS -> Duration.ofMinutes(10);
            case ROLE_PERMS -> Duration.ofMinutes(30);
            case AK_CONFIG -> Duration.ofMinutes(5);
            case APP_SCOPES -> Duration.ofMinutes(30);
            case ACTION_ROUTE -> Duration.ofHours(12);
        };
    }

    public enum CacheType {
        USER_ROLES, USER_PERMS, ROLE_PERMS, AK_CONFIG, APP_SCOPES, ACTION_ROUTE
    }
}

5.4 Caffeine 配置

@Configuration
public class CaffeineConfig {

    @Bean
    public Cache<String, Object> authCache(CacheTtlConfig ttlConfig) {
        return Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(ttlConfig.l1Ttl(CacheType.USER_PERMS))
                .recordStats()
                .build();
    }
}

5.5 统一缓存查询服务

@Service
@Slf4j
@RequiredArgsConstructor
public class AuthCacheService {

    private final Cache<String, Object> caffeineCache;
    private final RedisTemplate<String, Object> redisTemplate;
    private final UserRoleMapper userRoleMapper;
    private final CacheTtlConfig ttlConfig;

    /**
     * 三级缓存穿透查询
     */
    @SuppressWarnings("unchecked")
    public Set<String> getUserPermissions(Long userId) {
        String key = String.format(CacheKeyPatterns.USER_PERMS, userId);

        // L1
        Set<String> cached = (Set<String>) caffeineCache.getIfPresent(key);
        if (cached != null) return cached;

        // L2
        cached = (Set<String>) redisTemplate.opsForValue().get(key);
        if (cached != null) {
            caffeineCache.put(key, cached);
            return cached;
        }

        // L3
        cached = userRoleMapper.findPermissionCodesByUserId(userId);
        if (cached == null) cached = Collections.emptySet();

        redisTemplate.opsForValue().set(key, cached, ttlConfig.l2Ttl(CacheType.USER_PERMS));
        caffeineCache.put(key, cached);
        return cached;
    }

    /**
     * 主动失效(权限变更时调用)
     */
    public void invalidateUserPermissions(Long userId) {
        String key = String.format(CacheKeyPatterns.USER_PERMS, userId);
        redisTemplate.delete(key);
        caffeineCache.invalidate(key);
    }
}

六、缓存失效机制

不能只靠 TTL。权限变更后需要尽快生效,TTL 设太长会导致变更延迟,设太短会增加 DB 压力。

6.1 四种策略组合

  1. TTL 自然过期 — 兜底,防止缓存无限增长
  2. 管理端变更后主动删除 Redis — 写操作时同步清缓存
  3. Redis Pub/Sub 广播清本地缓存 — 通知所有实例清 L1
  4. 版本号校验 — 读取时检查版本,防止读到旧数据

6.2 主动删除 + Pub/Sub 广播

@Service
@RequiredArgsConstructor
public class CacheEventPublisher {

    private final RedisTemplate<String, Object> redisTemplate;

    private static final String CHANNEL = "auth:cache:invalidate";

    /**
     * 发布缓存失效事件
     */
    public void publish(CacheInvalidateEvent event) {
        redisTemplate.convertAndSend(CHANNEL, JSON.toJSONString(event));
    }
}

@Component
@RequiredArgsConstructor
public class CacheEventListener {

    private final Cache<String, Object> caffeineCache;

    @RedisListener(channel = "auth:cache:invalidate")
    public void onCacheInvalidate(String message) {
        CacheInvalidateEvent event = JSON.parseObject(message, CacheInvalidateEvent.class);

        switch (event.getType()) {
            case "USER_PERM" -> {
                String key = String.format(CacheKeyPatterns.USER_PERMS, event.getEntityId());
                caffeineCache.invalidate(key);
            }
            case "ROLE_PERM" -> {
                String key = String.format(CacheKeyPatterns.ROLE_PERMS, event.getEntityId());
                caffeineCache.invalidate(key);
            }
            case "APP_SCOPE" -> {
                String key = String.format(CacheKeyPatterns.APP_SCOPES, event.getEntityId());
                caffeineCache.invalidate(key);
            }
            case "AK_CONFIG" -> {
                String key = String.format(CacheKeyPatterns.AK_CONFIG, event.getEntityId());
                caffeineCache.invalidate(key);
            }
        }
    }
}

6.3 版本号机制

每次写操作递增版本号,读取时对比版本。如果缓存中的版本落后于 DB,说明数据已变更,需要重新加载。

@Service
@RequiredArgsConstructor
public class VersionedCacheService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final UserPermissionMapper userPermissionMapper;

    public Set<String> getUserPermissionsWithVersion(Long userId) {
        String permKey = String.format(CacheKeyPatterns.USER_PERMS, userId);
        String verKey = String.format(CacheKeyPatterns.USER_PERM_VERSION, userId);

        Set<String> cached = (Set<String>) redisTemplate.opsForValue().get(permKey);
        Long cachedVer = (Long) redisTemplate.opsForValue().get(verKey);

        Long dbVer = userPermissionMapper.getVersionByUserId(userId);

        // 版本一致,直接返回缓存
        if (cached != null && cachedVer != null && cachedVer.equals(dbVer)) {
            return cached;
        }

        // 版本不一致,重新加载
        Set<String> fresh = userPermissionMapper.findPermissionCodesByUserId(userId);
        redisTemplate.opsForValue().set(permKey, fresh, Duration.ofMinutes(10));
        redisTemplate.opsForValue().set(verKey, dbVer, Duration.ofMinutes(10));
        return fresh;
    }
}

七、上下文传递

7.1 为什么要做上下文传递

网关校验了身份后,需要把结果传给下游业务服务。如果每个服务都自己解析 token,会出现:

  • 每个服务重复查 Redis
  • 身份解析逻辑重复
  • 链路日志不好串

网关统一解析身份,签名后通过 Header 传给下游,下游验签后直接使用。

7.2 AuthContext 结构设计

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthContext {

    private AuthSubject subject;
    private AuthAction action;
    private AuthEnvironment environment;
    private long authenticatedAt;
    private int version;
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthSubject {

    private SubjectType type;      // USER / APP / SERVICE
    private String id;             // userId 或 appId
    private String name;
    private Set<String> roles;
    private Set<String> permissions;
    private String tenantId;
    private Long deptId;

    public enum SubjectType { USER, APP, SERVICE }
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthAction {

    private String actionCode;     // 如 product:write
    private String resourceType;   // 如 PRODUCT
    private String operation;      // 如 write
    private String riskLevel;      // LOW / MEDIUM / HIGH
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthEnvironment {

    private String ip;
    private String userAgent;
    private String traceId;
    private String source;         // WEB / APP / API
    private Map<String, Object> attributes;
}

7.3 网关签名传递

@Component
@RequiredArgsConstructor
public class GatewayAuthFilter implements GlobalFilter, Ordered {

    private final AuthCacheService authCacheService;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();

        // 1. 认证
        AuthSubject subject = authenticate(request);
        if (subject == null) {
            return unauthorized(exchange);
        }

        // 2. 解析 action
        String actionCode = resolveAction(request);

        // 3. 构建 AuthContext
        AuthContext ctx = AuthContext.builder()
                .subject(subject)
                .action(new AuthAction(actionCode))
                .environment(buildEnvironment(request))
                .authenticatedAt(System.currentTimeMillis())
                .version(1)
                .build();

        // 4. 签名后注入 Header
        String contextJson = JSON.toJSONString(ctx);
        String contextBase64 = Base64.getUrlEncoder().encodeToString(
                contextJson.getBytes(StandardCharsets.UTF_8));
        String timestamp = String.valueOf(System.currentTimeMillis());
        String signature = signContext(contextBase64, timestamp);

        ServerHttpRequest mutated = request.mutate()
                .header("X-Auth-Context", contextBase64)
                .header("X-Auth-Timestamp", timestamp)
                .header("X-Auth-Signature", signature)
                .build();

        return chain.filter(exchange.mutate().request(mutated).build());
    }

    private String signContext(String contextBase64, String timestamp) {
        String secret = getGatewaySecret();
        String payload = contextBase64 + "\n" + timestamp;
        return HmacUtils.hmacSha256Hex(secret, payload);
    }

    private AuthSubject authenticate(ServerHttpRequest request) {
        // AK/SK
        String authHeader = request.getHeaders().getFirst("Authorization");
        if (authHeader != null && authHeader.startsWith("AK-HMAC ")) {
            return authenticateByAkSk(request, authHeader);
        }
        // API Key
        String apiKey = request.getHeaders().getFirst("X-API-Key");
        if (apiKey != null) {
            return authenticateByApiKey(apiKey);
        }
        // JWT
        String jwt = request.getHeaders().getFirst("X-Auth-Token");
        if (jwt != null) {
            return authenticateByJwt(jwt);
        }
        return null;
    }

    @Override
    public int getOrder() { return -100; }
}

7.4 服务端验签解析

@Component
public class AuthContextFilter extends OncePerRequestFilter {

    @Value("${gateway.context.signing.secret}")
    private String gatewaySecret;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        String contextBase64 = request.getHeader("X-Auth-Context");
        String timestamp = request.getHeader("X-Auth-Timestamp");
        String signature = request.getHeader("X-Auth-Signature");

        if (contextBase64 == null || signature == null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Missing auth context\"}");
            return;
        }

        // 1. 验签
        String payload = contextBase64 + "\n" + timestamp;
        String expected = HmacUtils.hmacSha256Hex(gatewaySecret, payload);
        if (!MessageDigest.isEqual(expected.getBytes(), signature.getBytes())) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Invalid auth context signature\"}");
            return;
        }

        // 2. 防重放(5 分钟过期)
        long age = System.currentTimeMillis() - Long.parseLong(timestamp);
        if (age > 300_000) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Auth context expired\"}");
            return;
        }

        // 3. 解析
        String contextJson = new String(Base64.getUrlDecoder().decode(contextBase64),
                StandardCharsets.UTF_8);
        AuthContext authContext = JSON.parseObject(contextJson, AuthContext.class);

        // 4. 放入 ThreadLocal
        AuthContextHolder.set(authContext);
        try {
            chain.doFilter(request, response);
        } finally {
            AuthContextHolder.clear();
        }
    }
}

public final class AuthContextHolder {

    private static final ThreadLocal<AuthContext> CONTEXT = new ThreadLocal<>();

    public static void set(AuthContext ctx) { CONTEXT.set(ctx); }
    public static AuthContext get() { return CONTEXT.get(); }
    public static void clear() { CONTEXT.remove(); }

    public static Long getUserId() {
        AuthContext ctx = get();
        if (ctx != null && ctx.getSubject().getType() == AuthSubject.SubjectType.USER) {
            return Long.parseLong(ctx.getSubject().getId());
        }
        return null;
    }

    public static String getAppId() {
        AuthContext ctx = get();
        if (ctx != null && ctx.getSubject().getType() == AuthSubject.SubjectType.APP) {
            return ctx.getSubject().getId();
        }
        return null;
    }
}

八、PDP 策略决策点

8.1 什么是 PDP

PDP(Policy Decision Point)是 NIST RBAC 标准中的核心组件。业务代码不直接写 if-else 判断权限,而是调用 PDP 的 decide() 方法,PDP 遍历评估器链做出决策。

好处:

  • 权限判断逻辑集中管理,不散落在业务代码里
  • 新增权限维度(如 App scope、额度)只需加评估器,不改业务代码
  • 每个评估器可独立测试

8.2 评估器接口

public interface PermissionEvaluator {

    /**
     * 评估器名称(日志和调试用)
     */
    String name();

    /**
     * 执行优先级,数字越小越先执行
     */
    int order();

    /**
     * 执行评估
     * @return ALLOW / DENY / ABSTAIN
     */
    AuthDecision evaluate(AuthSubject subject, AuthAction action,
                          AuthResource resource, AuthContext context);
}

8.3 各评估器实现

RoleEvaluator

检查用户是否有匹配的角色。超级管理员直接放行。

@Component
public class RoleEvaluator implements PermissionEvaluator {

    @Override public String name() { return "RoleEvaluator"; }
    @Override public int order() { return 100; }

    @Override
    public AuthDecision evaluate(AuthSubject subject, AuthAction action,
                                 AuthResource resource, AuthContext context) {
        if (subject.getRoles() != null && subject.getRoles().contains("SUPER_ADMIN")) {
            return AuthDecision.allow("Super admin bypass");
        }
        return AuthDecision.abstain();
    }
}

PermissionEvaluator

检查用户的权限集合是否覆盖所需权限。

@Component
public class PermissionEvaluatorImpl implements PermissionEvaluator {

    @Override public String name() { return "PermissionEvaluator"; }
    @Override public int order() { return 200; }

    @Override
    public AuthDecision evaluate(AuthSubject subject, AuthAction action,
                                 AuthResource resource, AuthContext context) {
        // 只处理 USER 类型
        if (subject.getType() != AuthSubject.SubjectType.USER) {
            return AuthDecision.abstain();
        }

        Set<String> userPerms = subject.getPermissions();
        String required = action.getActionCode();

        if (userPerms.contains(required)) {
            return AuthDecision.allow("Permission matched: " + required);
        }

        // 通配符:product:* 匹配 product:write
        String resourceType = required.split(":")[0];
        if (userPerms.contains(resourceType + ":*")) {
            return AuthDecision.allow("Wildcard matched: " + resourceType + ":*");
        }

        return AuthDecision.deny("Permission denied: " + required);
    }
}

AppScopeEvaluator

检查 App 的 scope 是否覆盖所需权限。只在 SubjectType=APP 时生效。

@Component
@RequiredArgsConstructor
public class AppScopeEvaluator implements PermissionEvaluator {

    private final AppScopeManager appScopeManager;

    @Override public String name() { return "AppScopeEvaluator"; }
    @Override public int order() { return 150; }

    @Override
    public AuthDecision evaluate(AuthSubject subject, AuthAction action,
                                 AuthResource resource, AuthContext context) {
        if (subject.getType() != AuthSubject.SubjectType.APP) {
            return AuthDecision.abstain();
        }

        String appId = subject.getId();
        String requiredScope = action.getActionCode();
        Set<String> appScopes = appScopeManager.getScopes(appId);

        if (appScopes.contains(requiredScope)) {
            return AuthDecision.allow("App scope matched");
        }

        for (String scope : appScopes) {
            if (scope.endsWith(":*") && requiredScope.startsWith(
                    scope.substring(0, scope.length() - 2))) {
                return AuthDecision.allow("App scope wildcard match");
            }
        }

        return AuthDecision.deny("App [" + appId + "] 缺少 scope: " + requiredScope);
    }
}

AppQuotaEvaluator

检查 App 的调用额度。

@Component
@RequiredArgsConstructor
public class AppQuotaEvaluator implements PermissionEvaluator {

    private final QuotaManager quotaManager;

    @Override public String name() { return "AppQuotaEvaluator"; }
    @Override public int order() { return 160; }

    @Override
    public AuthDecision evaluate(AuthSubject subject, AuthAction action,
                                 AuthResource resource, AuthContext context) {
        if (subject.getType() != AuthSubject.SubjectType.APP) {
            return AuthDecision.abstain();
        }

        String appId = subject.getId();
        String actionCode = action.getActionCode();

        AppQuota quota = quotaManager.getQuota(appId, actionCode);
        if (quota == null) {
            return AuthDecision.abstain();
        }

        // 每日额度
        if (quota.getDailyLimit() != null) {
            int used = quotaManager.getDailyUsed(appId, actionCode);
            if (used >= quota.getDailyLimit()) {
                return AuthDecision.deny("每日额度已用完: " + used + "/" + quota.getDailyLimit());
            }
        }

        // 并发额度
        if (quota.getConcurrency() != null) {
            long current = quotaManager.incrementConcurrency(appId, actionCode);
            if (current > quota.getConcurrency()) {
                quotaManager.decrementConcurrency(appId, actionCode);
                return AuthDecision.deny("并发额度已满: " + current + "/" + quota.getConcurrency());
            }
            context.setAttribute("needConcurrencyDecrement", true);
        }

        quotaManager.incrementDaily(appId, actionCode);
        return AuthDecision.allow("Quota check passed");
    }
}

OwnershipEvaluator

检查用户是否是资源的拥有者。比如"只能修改自己的订单"。

@Component
public class OwnershipEvaluator implements PermissionEvaluator {

    @Override public String name() { return "OwnershipEvaluator"; }
    @Override public int order() { return 300; }

    @Override
    public AuthDecision evaluate(AuthSubject subject, AuthAction action,
                                 AuthResource resource, AuthContext context) {
        if (resource == null || resource.getOwnerId() == null) {
            return AuthDecision.abstain();
        }

        // 资源拥有者直接放行
        if (subject.getId().equals(String.valueOf(resource.getOwnerId()))) {
            return AuthDecision.allow("Resource owner");
        }

        return AuthDecision.abstain();
    }
}

TimeWindowEvaluator

限制操作的时间窗口。比如"审批操作只能在工作时间进行"。

@Component
public class TimeWindowEvaluator implements PermissionEvaluator {

    @Override public String name() { return "TimeWindowEvaluator"; }
    @Override public int order() { return 400; }

    @Override
    public AuthDecision evaluate(AuthSubject subject, AuthAction action,
                                 AuthResource resource, AuthContext context) {
        Map<String, Object> attrs = context.getEnvironment().getAttributes();
        if (attrs == null || !attrs.containsKey("allowedHours")) {
            return AuthDecision.abstain();
        }

        String allowedHours = (String) attrs.get("allowedHours"); // "09:00-18:00"
        String[] parts = allowedHours.split("-");
        LocalTime start = LocalTime.parse(parts[0]);
        LocalTime end = LocalTime.parse(parts[1]);
        LocalTime now = LocalTime.now();

        if (now.isBefore(start) || now.isAfter(end)) {
            return AuthDecision.deny("不在允许的操作时间: " + allowedHours);
        }

        return AuthDecision.abstain();
    }
}

8.4 评估器链执行引擎

@Component
public class EvaluatorChain {

    private final List<PolicyEvaluator> evaluators;

    public EvaluatorChain(List<PolicyEvaluator> evaluators) {
        // 按 order 排序
        this.evaluators = evaluators.stream()
                .sorted(Comparator.comparingInt(PolicyEvaluator::order))
                .collect(Collectors.toList());
    }

    /**
     * 遍历评估器链,做出最终决策
     */
    public AuthDecision evaluate(AuthSubject subject, AuthAction action,
                                 AuthResource resource, AuthContext context) {
        for (PolicyEvaluator evaluator : evaluators) {
            AuthDecision decision = evaluator.evaluate(subject, action, resource, context);

            // DENY 立即返回(任何一个评估器拒绝,整体拒绝)
            if (decision.getEffect() == DecisionEffect.DENY) {
                return decision;
            }

            // NEED_APPROVAL 立即返回(需要人工审批)
            if (decision.getEffect() == DecisionEffect.NEED_APPROVAL) {
                return decision;
            }

            // ALLOW 继续(后面的评估器可能覆盖)
            // ABSTAIN 继续
        }

        // 所有评估器都 abstain,默认拒绝
        return AuthDecision.deny("No evaluator matched, default deny");
    }
}

8.5 PDP 服务门面

@Service
@RequiredArgsConstructor
public class AuthorizationService {

    private final EvaluatorChain evaluatorChain;

    /**
     * 核心决策方法
     */
    public AuthDecision decide(AuthSubject subject, AuthAction action,
                               AuthResource resource, AuthContext context) {
        return evaluatorChain.evaluate(subject, action, resource, context);
    }

    /**
     * 便捷方法:检查是否允许,不允许则抛异常
     */
    public void check(AuthSubject subject, AuthAction action,
                      AuthResource resource, AuthContext context) {
        AuthDecision decision = decide(subject, action, resource, context);
        if (decision.getEffect() == DecisionEffect.DENY) {
            throw new ForbiddenException("Access denied: " + decision.getReason());
        }
        if (decision.getEffect() == DecisionEffect.NEED_APPROVAL) {
            throw new ApprovalRequiredException("需要审批: " + decision.getReason());
        }
    }
}

九、从 hasPermission 迁移到 can(subject, action, resource, context)

9.1 现状问题

很多系统用的是 Sa-Token 或 Spring Security 的 hasPermission

// 老方式:只检查权限字符串
StpUtil.checkPermission("product:update");

这种方式的局限:

  • 只能做 RBAC 判断,无法处理 App scope、额度、资源归属
  • 没有 resource 上下文,无法判断"能不能改这个具体的资源"
  • 没有 environment 上下文,无法做时间窗口、IP 限制

9.2 三阶段迁移

阶段一:封装入口,不改逻辑

// 统一入口,内部仍然调用老逻辑
Authz.check("product:update");

// Authz 内部实现
public class Authz {
    public static void check(String actionCode) {
        // 第一版:直接调 Sa-Token
        StpUtil.checkPermission(actionCode);
    }
}

阶段二:引入 AuthContext

// 支持 resource 参数
Authz.check("product:update", resource);

// 内部开始走 PDP
public class Authz {
    public static void check(String actionCode, AuthResource resource) {
        AuthContext ctx = AuthContextFactory.current();
        ctx.setAction(new AuthAction(actionCode));
        ctx.setResource(resource);
        authorizationService.check(ctx.getSubject(), ctx.getAction(),
                ctx.getResource(), ctx);
    }
}

阶段三:完整 PDP

// 全部走 PDP 评估器链
Authz.check(subject, action, resource, context);

9.3 注解增强

在原来的 @RequirePermission 基础上增加 resource 支持:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorize {
    String action();
    String resourceType() default "";
    String ownerField() default ""  // 资源对象中表示 owner 的字段名
}

// Controller 中使用
@Authorize(action = "product:update", resourceType = "PRODUCT", ownerField = "sellerId")
@PutMapping("/products/{id}")
public Product update(@PathVariable Long id, @RequestBody ProductDTO dto) {
    Product product = productService.getById(id);
    // AOP 切面会自动:1. 构建 resource  2. 调用 PDP.check()
    return productService.update(product, dto);
}

十、完整请求流程

把前面所有东西串起来,看一个请求从进入到执行业务代码的完整路径。

10.1 AK/SK 请求

1. 客户端构造签名
   → CanonicalRequest = "GET\n/api/products\npage=1\nhost:api.example.com\nhost\n<body-sha256>"
   → StringToSign = "HMAC-SHA256\n1778560000000\nuuid-xxx\nSHA256(CanonicalRequest)"
   → 签名 = Base64(HMAC-SHA256(SK, StringToSign))
   → Authorization: AK-HMAC AccessKey=AKIDxxxx, Signature=<signature>, Timestamp=<timestamp>, Nonce=<nonce>

2. 网关接收
   → AkSkAuthFilter 拦截
   → 校验时间戳(5 分钟内)
   → 校验 Nonce(Redis setIfAbsent,防重放)
   → 根据 AK 查 App(走三级缓存:ak:config:{ak})
   → 验证签名
   → 构建 AuthSubject(type=APP, id=appId, permissions=appScopes)

3. 网关构建 AuthContext
   → 解析 action(action:route:GET:/api/products → product:read)
   → AuthContext(subject, action, environment)
   → Base64 编码 + HMAC 签名 → 注入 Header

4. 网关转发到业务服务
   → X-Auth-Context: xxx
   → X-Auth-Timestamp: xxx
   → X-Auth-Signature: xxx

5. 业务服务 AuthContextFilter
   → 验签
   → 检查时间戳(5 分钟内)
   → 解析 AuthContext
   → 放入 ThreadLocal

6. 业务代码调用 PDP
   → AuthorizationService.check(subject, action, resource, context)
   → EvaluatorChain 遍历:
     - RoleEvaluator: ABSTAIN(不是 USER)
     - AppScopeEvaluator: ALLOW(app 有 product:read scope)
     - 其他评估器...
   → 返回 ALLOW

7. 执行业务逻辑

10.2 JWT 请求

1. 前端请求
   → X-Auth-Token: eyJhbGciOi...

2. 网关接收
   → 解析 JWT,提取 userId
   → 查用户权限(走三级缓存:user:perms:{userId})
   → 构建 AuthSubject(type=USER, id=userId, permissions=userPerms)

3. 后续流程同 AK/SK 的 3-7 步
   → PDP 时 PermissionEvaluator 生效(AppScopeEvaluator 跳过)

十一、踩坑总结

坑 1:Secret Key 明文存储

问题: SK 直接存数据库,被拖库后所有接口暴露。

正确做法: SK 存 AES 加密值,运行时解密。或者只在创建时展示一次,之后只存哈希。

坑 2:签名时参数编码不一致

问题: 客户端 URL 编码了参数,服务端没编码,签名不匹配。

正确做法: 签名时统一用原始参数值,不编码。编码放在签名之后。

坑 3:Nonce 没有过期清理

问题: Nonce Set 无限增长,OOM。

正确做法: 用 Redis 存 Nonce,TTL 5 分钟。

坑 4:CORS 预检请求被拦截

问题: OPTIONS 请求没带 Authorization,被过滤器拦截返回 401。

正确做法: 过滤器跳过 OPTIONS 请求。

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
    return "OPTIONS".equalsIgnoreCase(request.getMethod());
}

坑 5:前端权限绕过

问题: 前端隐藏了按钮,但直接调接口还是成功。

正确做法: 后端必须独立校验权限,前端只是 UI 层。

坑 6:缓存和数据库不一致

问题: 管理端修改了权限,但缓存没清,用户还是用旧权限操作。

正确做法: 权限变更时删除 Redis 条目并广播失效本地缓存,TTL 仅承担遗漏事件后的兜底过期。

坑 7:AK/SK 过期后服务中断

问题: AK/SK 过期了,第三方调用直接失败,没有提前通知。

正确做法: 过期前 7 天发通知。过期后返回明确的错误码,不是 generic 401。

坑 8:Evaluator 执行顺序错误

问题: AppScopeEvaluator 在 PermissionEvaluator 之后执行,导致 APP 请求被误判为"没有权限"。

正确做法: 每个评估器用 order() 控制执行顺序,先执行的优先级高。

坑 9:AuthContext 密钥泄露

问题: 网关签名密钥和业务服务共享,如果一个服务被攻破,攻击者可以伪造 AuthContext。

正确做法: 签名密钥定期轮换。不同环境用不同密钥。限制密钥的访问范围。

坑 10:缓存穿透

问题: 查询一个不存在的用户/权限,每次都穿透到数据库。

正确做法: 空结果也缓存,TTL 设短一些(30 秒)。


十二、总结与配置清单

缓存策略

缓存类型L1 (Caffeine)L2 (Redis)失效方式
用户权限60s10min主动删除 + Pub/Sub
角色权限120s30min主动删除 + Pub/Sub
App Scope60s30min主动删除 + Pub/Sub
AK 配置30s5min主动删除
Action 路由300s12h重启时刷新

安全检查清单

  • SK 不明文存储
  • 签名时间戳容差 ≤ 5 分钟
  • Nonce 用 Redis 存储,TTL 5 分钟
  • OPTIONS 请求放行
  • AuthContext 签名校验
  • 签名密钥定期轮换
  • 权限变更时清缓存 + 广播
  • App scope 分配时校验 owner 权限
  • 后端独立校验权限(不依赖前端隐藏)

十三、调用链路与性能优化

13.1 完整调用链路

一次 API 请求的鉴权全链路:

鉴权耗时要在目标部署环境中测量,至少拆出签名校验、Nonce 存取、权限缓存命中与回源、PDP 决策和异步审计。报告同时给出缓存命中率、P50、P95、P99、并发量和依赖版本。没有这些条件的固定毫秒数字不具备可复用性。

13.2 网关 vs 业务服务的分工

职责网关业务服务
身份认证✅ 统一处理❌ 不重复做
AK/SK 验签
JWT 解析
权限决策(PDP)✅ 业务自己决定
额度控制✅ 业务自己决定
数据级权限✅ 业务自己决定

原则:网关只管"你是谁",业务服务管"你能做什么"。

13.3 性能优化策略

L1 本地缓存(Caffeine)

权限数据读多写少,L1 命中率通常在 80% 以上。关键配置:

  • 上限条目数:10,000(根据实际内存调整)
  • 写入后过期:60 秒(用户权限)、120 秒(角色权限)
  • 主动失效:权限变更时通过 Redis Pub/Sub 广播清 L1

L2 Redis 缓存

L1 miss 后查 Redis,TTL 比 L1 长:

  • 用户权限:10 分钟
  • 角色权限:30 分钟
  • AK 配置:5 分钟

降级策略

当 Redis 不可用时,降级方案:

public Set<String> getUserPermissionsWithFallback(Long userId) {
    try {
        return getUserPermissions(userId); // 正常三级缓存
    } catch (RedisConnectionException e) {
        log.warn("Redis 不可用,降级查本地缓存", e);
        Set<String> cached = (Set<String>) caffeineCache.getIfPresent(key);
        if (cached != null) return cached;
        // 最后降级直接查 DB
        return userRoleMapper.findPermissionCodesByUserId(userId);
    }
}

十四、记账逻辑与并发控制

14.1 为什么需要记账

对外 API 服务需要精确记录每个 App 的调用量,用于:

  • 按量计费(调用次数、Token 消耗)
  • 额度控制(防止某个 App 把资源打满)
  • 异常检测(调用量突增告警)
  • 审计追溯(谁在什么时候调了什么)

14.2 同步记账 vs 异步记账

维度同步记账异步记账
实时性高,每次调用马上记录低,有延迟(毫秒~秒级)
性能影响大,增加接口 RT 1-5ms小,发送消息后马上返回
数据一致性强,不会丢弱,极端情况可能丢少量数据
实现复杂度中(需要消息队列)
适用场景低并发、强一致性要求高并发、可容忍末尾一致性

推荐方案:混合模式

  • 额度检查:同步,必须在请求处理前完成(Redis INCR,延迟 < 1ms)
  • 调用量记录:异步,请求完成后发送到消息队列
  • 日终结算:异步,定时任务汇总
@Service
@RequiredArgsConstructor
@Slf4j
public class BillingService {

    private final RedisTemplate<String, String> redisTemplate;
    private final KafkaTemplate<String, String> kafkaTemplate;

    /**
     * 同步额度检查(请求前调用)
     * 使用 Redis INCR 原子操作,延迟 < 1ms
     */
    public boolean checkQuota(String appId, String actionCode, int dailyLimit) {
        String key = "quota:" + appId + ":" + actionCode + ":" + today();
        Long current = redisTemplate.opsForValue().increment(key);
        if (current == null) return false;

        // 首次设置 TTL(当天 23:59:59 过期)
        if (current == 1) {
            redisTemplate.expire(key, Duration.ofSeconds(secondsUntilMidnight()));
        }

        if (current > dailyLimit) {
            log.warn("App {} 超额:{} 当日已调用 {} 次,上限 {}", appId, actionCode, current, dailyLimit);
            return false;
        }
        return true;
    }

    /**
     * 异步记账(请求后调用)
     * 发送到 Kafka,由消费者批量写入 DB
     */
    public void recordUsage(String appId, String actionCode, long latencyMs,
                            int statusCode, String traceId) {
        Map<String, Object> record = Map.of(
            "appId", appId,
            "actionCode", actionCode,
            "latencyMs", latencyMs,
            "statusCode", statusCode,
            "traceId", traceId,
            "timestamp", System.currentTimeMillis()
        );
        kafkaTemplate.send("api.usage", JSON.toJSONString(record));
    }

    private String today() {
        return LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
    }

    private long secondsUntilMidnight() {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime midnight = now.toLocalDate().plusDays(1).atStartOfDay();
        return ChronoUnit.SECONDS.between(now, midnight);
    }
}

14.3 并发额度控制

当需要限制同时并发的请求数(而非累计调用次数)时,方案不同:

方案一:Redis 原子计数

/**
 * 并发计数器
 * 请求前 +1,请求后 -1,超过上限直接拒绝
 */
public boolean tryAcquire(String appId, int maxConcurrency) {
    String key = "concurrency:" + appId;
    Long current = redisTemplate.opsForValue().increment(key);
    if (current == null) return false;

    if (current > maxConcurrency) {
        redisTemplate.opsForValue().decrement(key);
        return false;
    }
    return true;
}

public void release(String appId) {
    String key = "concurrency:" + appId;
    redisTemplate.opsForValue().decrement(key);
}

方案二:滑动窗口限流

用于限制单位时间内的调用频率(如每秒上限 100 次):

/**
 * 滑动窗口限流(Redis Sorted Set 实现)
 */
public boolean isAllowed(String appId, int maxPerSecond) {
    String key = "rate:" + appId;
    long now = System.currentTimeMillis();
    long windowStart = now - 1000;

    // 清理窗口外的记录
    redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);

    // 当前窗口内的请求数
    Long count = redisTemplate.opsForZSet().zCard(key);
    if (count != null && count >= maxPerSecond) {
        return false;
    }

    // 记录本次请求
    redisTemplate.opsForZSet().add(key, String.valueOf(now), now);
    redisTemplate.expire(key, Duration.ofSeconds(2));
    return true;
}

14.4 超额处理策略

策略行为适用场景
硬拒绝返回 429 Too Many Requests付费 API、严格配额
软限流降级到低优先级队列,延迟处理内部服务、非关键路径
排队等待返回 Retry-After Header,调用方重试批量任务、允许延迟
弹性扩容超额时自动扩容,超出绝对上限才拒绝云原生、Serverless
// 硬拒绝
if (!billingService.checkQuota(appId, actionCode, dailyLimit)) {
    return ResponseEntity.status(429)
        .header("Retry-After", "60")
        .body(Map.of("error", "Rate limit exceeded",
                      "resetAt", nextResetTime()));
}

// 软限流(降级到异步队列)
if (isOverQuota) {
    asyncTaskQueue.submit(() -> processLater(request));
    return ResponseEntity.accepted()
        .body(Map.of("status", "queued", "estimatedWait", "30s"));
}

14.5 日终结算

每天凌晨定时任务汇总当天的调用记录:

@Scheduled(cron = "0 5 0 * * ?") // 每天 00:05
public void dailySettlement() {
    String yesterday = LocalDate.now().minusDays(1)
            .format(DateTimeFormatter.ISO_LOCAL_DATE);

    // 1. 从 Kafka 消费昨天的调用记录,写入 DB
    List<UsageRecord> records = usageRecordDao.findByDate(yesterday);

    // 2. 按 App 聚合
    Map<String, List<UsageRecord>> byApp = records.stream()
            .collect(Collectors.groupingBy(UsageRecord::getAppId));

    for (Map.Entry<String, List<UsageRecord>> entry : byApp.entrySet()) {
        String appId = entry.getKey();
        List<UsageRecord> appRecords = entry.getValue();

        // 3. 计算总额
        long totalCalls = appRecords.size();
        long totalTokens = appRecords.stream()
                .mapToLong(UsageRecord::getTokens)
                .sum();
        long totalLatency = appRecords.stream()
                .mapToLong(UsageRecord::getLatencyMs)
                .sum();

        // 4. 生成账单
        BillingSummary summary = BillingSummary.builder()
                .appId(appId)
                .date(yesterday)
                .totalCalls(totalCalls)
                .totalTokens(totalTokens)
                .avgLatencyMs(totalLatency / totalCalls)
                .amount(calculateAmount(totalCalls, totalTokens))
                .build();
        billingSummaryDao.insert(summary);

        // 5. 超额告警
        AppQuota quota = appQuotaDao.findByAppId(appId);
        if (quota != null && totalCalls > quota.getDailyLimit() * 1.2) {
            alertService.send("App " + appId + " 昨日调用量超限 20%");
        }
    }
}

参考资料