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 时,两个核心问题绕不开:
- 调用方是谁?(认证,Authentication)
- 他能做什么?(授权,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 选型决策
| 维度 | API Key | AK/SK | JWT | OAuth2 | Internal Token |
|---|---|---|---|---|---|
| 传输安全 | 明文 | 签名值,SK 不传 | 明文(靠 HTTPS) | Token(靠 HTTPS) | HMAC 签名 |
| 防篡改 | 无 | 签名覆盖请求参数 | 签名覆盖 Payload | 取决于实现 | 签名覆盖 Payload |
| 防重放 | 无 | 时间戳 + Nonce | Token 过期时间 | Token 过期时间 | 过期时间 |
| 主动失效 | 重置 Key | 重置 SK | 黑名单 | 撤销 Token | 重新签发 |
| 复杂度 | ⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
三、RBAC 权限模型
AK/SK 解决"你是谁",RBAC 解决"你能做什么"。RBAC(Role-Based Access Control)是目前主流的权限模型。
3.1 RBAC0:基础模型
入门门槛低的 RBAC:用户关联角色,角色关联权限。
数据库表设计
-- 用户表
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 通过角色继承解决:角色可以继承其他角色的权限。
继承关系表
-- 角色继承关系表
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 到底归谁管?
问题拆解
一个公司有多个子公司,每个子公司有自己的业务系统。但"业务系统"这个词太模糊,我们需要明确:
- App 到底绑在哪? 绑在组织架构上(部门/子公司),还是绑在开发者个人上?
- 谁来创建 App? 平台管理员统一开,还是租户自己申请?
- 子公司开发者拿到 appkey 后,自己对接、自己维护? 还是平台统一管?
- 一个子公司有多个 appid,每个 appid 的 appkey 不一样,怎么管理?
推荐方案:三层归属模型
关键设计决策:
① 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 的生命周期管理:
- 创建:为 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 可以共享权限缓存,本地缓存还能减少网络访问。具体收益取决于部署拓扑、数据规模和缓存命中情况,应通过压测和线上分位数判断。
三级缓存的分工:
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 四种策略组合
- TTL 自然过期 — 兜底,防止缓存无限增长
- 管理端变更后主动删除 Redis — 写操作时同步清缓存
- Redis Pub/Sub 广播清本地缓存 — 通知所有实例清 L1
- 版本号校验 — 读取时检查版本,防止读到旧数据
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) | 失效方式 |
|---|---|---|---|
| 用户权限 | 60s | 10min | 主动删除 + Pub/Sub |
| 角色权限 | 120s | 30min | 主动删除 + Pub/Sub |
| App Scope | 60s | 30min | 主动删除 + Pub/Sub |
| AK 配置 | 30s | 5min | 主动删除 |
| Action 路由 | 300s | 12h | 重启时刷新 |
安全检查清单
- 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%");
}
}
}