Token鉴权的 N 种方案
前言
最近有球友问我:微服务中Token鉴权除了使用 JWT
之外,还有什么其他的方案?
今天这篇文章跟大家一起聊聊微服务Token鉴权的 N
种方案,希望对会有所帮助。
为什么必须做Token鉴权?
传统Session的致命缺陷
- 多个服务无法共享Session。
- 重复认证,导致系统性能严重下降。
2023年某电商平台发送安全事故
GET /api/users/balance HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Gfx6VO9tcxwk6xqx9yYzSfebbeKDTHkQKh0xhu4nJE0
2
黑客通过XSS攻击窃取此Token后,在2小时内盗取5万用户余额,暴露三大漏洞:
- Token未绑定IP/设备指纹
- 敏感操作未二次认证
- 无异常行为检测机制
常见的Token鉴权方案
方案1:基础JWT+Redis方案
该方案适合初创系统。
核心架构
致命陷阱
// 错误示例:未校验Token有效性
public Claims parseJwt(String token) {
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody(); // 若Token被注销仍能解析通过!
}
2
3
4
5
6
7
正确实现
// 结合Redis校验Token状态
public boolean validateToken(String token, UserDetails details) {
String username = extractUsername(token);
String redisToken = redisTemplate.opsForValue().get("token:"+username);
// 双重验证:签名有效且未注销
return (username.equals(details.getUsername())
&& !isTokenExpired(token)
&& token.equals(redisToken);
}
2
3
4
5
6
7
8
9
10
适用场景:用户量 <100万
的中小型系统
方案2:OAuth2.0授权框架
该方案是第三方接入的首选。
OAuth2.0包含了4种授权模式
授权码模式流程
Spring Boot
配置示例:
spring:
security:
oauth2:
client:
registration:
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_SECRET}
scope: user:email,read:user
provider:
github:
token-uri: https://github.com/login/oauth/access_token
user-info-uri: https://api.github.com/user
2
3
4
5
6
7
8
9
10
11
12
13
关键点:必须使用PKCE扩展防止授权码截持攻击
方案3:Sa-Token轻量级框架
该方案是的国产Token鉴权方案的精品。
三大核心优势
一行代码实现登录鉴权
// 登录
StpUtil.login(10001);
// 鉴权
@SaCheckPermission("user:delete")
public void deleteUser(Long id) {
// 业务代码
}
2
3
4
5
6
7
8
内置会话管理
// 查询所有会话
List<String> sessionList = StpUtil.searchSessionId("user:*", 0, 10);
2
踢人下线机制
// 根据账号ID踢人
StpUtil.kickout(10001);
// 根据Token值踢人
StpUtil.kickoutByTokenValue("xxxx");
2
3
4
5
网关集成方案
@Bean
public SaReactorFilter saReactorFilter() {
return new SaReactorFilter()
.addInclude("/**")
.setAuth(obj -> {
SaRouter.match("/user/**").check(r -> StpUtil.checkPermission("USER"));
SaRouter.match("/admin/**").check(r -> StpUtil.checkPermission("ADMIN"));
});
}
2
3
4
5
6
7
8
9
性能实测:QPS 12,000(Redis集群模式)
方案4:API网关统一鉴权
该方案是微服务的标配。
架构设计
响应式鉴权过滤器
public class AuthFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 提取Token
String token = extractToken(exchange.getRequest());
// 2. 响应式鉴权调用
return reactiveAuthService.validateToken(token)
.flatMap(valid -> {
if (!valid) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
性能优化技巧
- 本地缓存:使用Caffeine缓存验证结果
- 批量验证:聚合10ms内请求统一鉴权
- 热点Token特殊处理
方案5:Token中继模式
该方案适合服务链调用。
核心问题:服务A调用服务B时Token如何传递。
解决方案
Feign中继实现
@FeignClient(name = "service-b")
public interface ServiceBClient {
@GetMapping("/data")
Data getData(@RequestHeader("Authorization") String token);
}
// 调用方
public Data getData(String token) {
// 原样传递Token
return serviceBClient.getData("Bearer " + token);
}
2
3
4
5
6
7
8
9
10
11
12
安全加固:使用JWT嵌套加密防止内部Token泄露
方案6:JWE加密令牌
该方案能保证金融级安全。
与JWT的核心区别
Java生成示例
public String createJwe(User user) throws JOSEException {
// 1. 组装Header
JWEHeader header = new JWEHeader.Builder(JWEAlgorithm.A256GCMKW,
EncryptionMethod.A256GCM).build();
// 2. 创建Payload
Payload payload = new Payload(new JSONObject()
.put("sub", user.getId())
.put("ssn", encrypt(user.getSsn()))); // 敏感信息加密
// 3. 加密Token
JWEObject jwe = new JWEObject(header, payload);
jwe.encrypt(new AESEncrypter(SECRET_KEY.getBytes()));
return jwe.serialize();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
适用场景
- 支付凭证
- 身份证号传输
- 医疗健康数据
方案7:双向TLS认证
该方案是零信任架构。
工作流程
Spring Boot配置
server:
ssl:
key-store: classpath:server-keystore.p12
key-store-password: changeit
key-alias: server
client-auth: need # 关键配置
trust-store: classpath:client-truststore.p12
trust-store-password: changeit
2
3
4
5
6
7
8
适用场景
- 服务网格内部通信
- 行核心系统
- 政府机密数据交换
方案8:AK/SK 加密认证
ak/sk
是一种身份认证方式,常用于系统间接口调用时的身份验证,其中 ak
为 Access Key ID
,sk
为 Secret Access Key
。客户端和服务端两者会协商保存一份相同的 sk
,其中 sk
必须保密。
客户端在调用的服务端接口时候,会带上 ak
以及 signature
(使用sk对内容进行加密后得出的签名)进行请求,在服务端接收到这个请求的时候,首先会根据ak去数据库里面去找到对应的sk,然后使用sk对请求内容进行加密得到一个签名,然后对比客户端传过来的签名和服务端计算的出来的签名是否一致,如果一致则代表身份认证通过,反之则不通过。
AK/SK(Access Key/Secert Key) 是一组用于鉴权的密钥对,类比常见的账号密码体系,AK 相当于账号,SK 相当于密码,只不过 AK/SK 一般是给开放平台使用的鉴权手段,一个用户可以设置多个 AK/SK,并且可以精细控制每一组 Key 的权限范围。
为什么需要 AK/SK?
当我们的平台需要对外提供 API 服务时,就需要对调用者进行鉴权处理。要如何识别是哪个用户进行的操作,一种是token,用户授权后生成一个唯一标识,后续调用服务时根据这个token识别用户,但是这种方案风险性较高,其他人通过请求抓包可以拦截到token进行调用;另一种安全性较高的手段就是通过 AK/SK 的形式,AK 标识用户或者应用,SK 作为密钥将 timestamp、nonce 等信息计算出签名,客户端和服务端通过相同的 SK 计算得到的签名是一致的,如果请求被拦截,nonce 已经被使用过可以避免请求重放,timestamp 可以保证签名的有效期。因为签名由 SK 生成,所以只要 SK 不泄漏,就可以保证服务的安全。
流程
- 首先在平台生成 AK/SK,客户端将 AK/SK 保存起来。后续每次调用平台服务之前,通过 SK 将当前的时间戳 timestamp 和 请求唯一标识 nonce 进行加密计算得到签名 signature,并将三者一起发送到服务端。
- 服务端接收到调用请求后,从请求中拿出这些参数进行校验。
- 首先是时效性校验,判断时间戳是否在有效时间内,以及 nonce 是否已经生效过。
- 然后从数据库中查询 AK/SK 是否存在。
- 最后用查询到的 SK 在对时间戳和请求标识进行加密计算,把结果与客户端的签名进行比对。
为什么要将时间戳和唯一标识进行加密?
它们各自解决了不同的安全问题,同时使用两者可以提供更全面的安全保障。
- Timestamp: 主要用于防止请求过期。通过在请求中包含一个时间戳,服务端可以验证请求是否在允许的时间窗口内发出。这有助于避免重放攻击(replay attack)。
- Nonce: Nonce 是“数字仅一次使用”的缩写,它确保了每个请求的唯一性。即使两个请求发生在同一秒内并且其他参数完全相同,由于 nonce 的不同,它们仍然会被视为独立的请求。Nonce 通常是一个随机生成的字符串或者递增的计数器值,在一段时间内必须是唯一的。
整个 AK/SK 鉴权的交互过程
AK/SK认证例子
设计ak/sk的请求参数
- platform_type:标明请求方是谁,即该例子中的ak
- time:请求时间,时间戳,将会被对应的sk配合加密算法进行加密,得到一个signature签名
- sign:签名,使用sk配合对应的加密算法后进行加密得到的签名。
当发送请求时,我们会带上这几个参数去请求接口,如请求 https:///xxx.com/students?platform_type=school&time=1640494526&sign=54acba6857b284a8a481ed5913edd34d994721cc584305ff02c81bb3ced17212
实战
实战阶段我们直接通过 MidwayJS 来完成,首先来🧠脑暴一下我们的AKSK实现思路,首先我们需要在数据库层面存储AKSK相关的数据记录;然后我们要有对这部数据增删改查的能力;其次需要实现我们上面两小节提到的签名校验能力,这部分可以通过中间件的形式实现。
最终,我们需要实现的整体代码架构如下
AKSK鉴权的中间件
import { Middleware, IMiddleware, Logger, ILogger } from '@midwayjs/core';
import { NextFunction, Context } from '@midwayjs/koa';
import { AKSKService } from '../service/aksk.service';
import * as crypto from 'crypto';
const nonceList: Array<string> = [];
@Middleware()
export class AKSKMiddleware implements IMiddleware<Context, NextFunction> {
@Logger()
logger: ILogger;
resolve() {
return async (ctx: Context, next: NextFunction) => {
const akskService = await ctx.requestContext.getAsync<AKSKService>(
AKSKService
);
const ak = ctx.get('x-ak');
const nonce = ctx.get('x-nonce');
const timestamp = ctx.get('x-timestamp');
const signature = ctx.get('x-signature');
if (!ak || !nonce || !timestamp || !signature) {
this.logger.warn('缺少认证信息');
ctx.status = 401;
return { success: false, message: '无效请求' };
}
// 验证时间戳是否在有效期内(例如5分钟)
const timestampNum = parseInt(timestamp);
const now = Date.now();
if (isNaN(timestampNum) || Math.abs(now - timestampNum) > 5 * 60 * 1000) {
this.logger.warn('%s 请求过期, timestamp -> %s', ak, timestamp);
ctx.status = 401;
return { success: false, message: '无效请求' };
}
// TODO: 在实际生产环境中,这里应该使用Redis等存储nonce,防止重放攻击
if (nonceList.includes(nonce)) {
this.logger.warn('%s nonce已使用, nonce -> %s', ak, nonce);
ctx.status = 401;
return { success: false, message: '无效请求' };
}
// 保存nonce
nonceList.push(nonce);
const aksk = await akskService.getAKSKByAK(ak);
// 使用sk对nonce和timestamp进行签名
const hmac = crypto.createHmac('sha256', aksk.sk);
hmac.update(nonce + timestamp);
const calculatedSignature = hmac.digest('hex');
// 验证签名
const isValid = calculatedSignature === signature;
if (!isValid) {
ctx.status = 401;
this.logger.warn('%s 签名验证失败', ak);
return { success: false, message: '无效请求' };
}
return await next();
};
}
static getName(): string {
return 'aksk';
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
这里我们按照上面的介绍的AKSK鉴权流程对timestamp、nonce和signature进行校验,其中nonce应该使用Redis等手段来存储,这里我们为了简化环境直接使用了数据保存。
为了验证我们的逻辑,我们编写一个测试文件来进行单元测试。
import { createApp, close, createHttpRequest } from '@midwayjs/mock';
import { Application, Framework } from '@midwayjs/koa';
import * as crypto from 'crypto';
describe('test/controller/api.test.ts', () => {
let app: Application;
const nonce = '1111123',
timestamp = Date.now(),
ak = '8527d2683fab2da4ac2a02d8be6c07d4',
sk = 'd4a38df5a6e1b19d5c2853818cd7ac597fa5b87a8da8a1a0d6be54f2dc016724'
const hmac = crypto.createHmac('sha256', sk);
hmac.update(nonce + timestamp);
const calculatedSignature = hmac.digest('hex');
beforeAll(async () => {
// 只创建一次 app,可以复用
app = await createApp<Framework>();
});
afterAll(async () => {
// close app
await close(app);
});
it('normal get /api/get_user', async () => {
// make request
const result = await createHttpRequest(app)
.get('/api/get_user')
.set('x-ak', ak)
.set('x-nonce', nonce)
.set('x-timestamp', timestamp)
.set('x-signature', calculatedSignature)
.query({ uid: 1 });
// use expect by jest
expect(result.status).toBe(200);
expect(result.body.message).toBe('OK');
});
it('replace get /api/get_user', async () => {
// make request
const result = await createHttpRequest(app)
.get('/api/get_user')
.set('x-ak', ak)
.set('x-nonce', nonce)
.set('x-timestamp', timestamp)
.set('x-signature', calculatedSignature)
.query({ uid: 1 });
// use expect by jest
expect(result.status).toBe(401);
expect(result.body.message).toBe('无效请求');
});
it('time over get /api/get_user', async () => {
// make request
const result = await createHttpRequest(app)
.get('/api/get_user')
.set('x-ak', ak)
.set('x-nonce', nonce)
.set('x-timestamp', String(1737452047255))
.set('x-signature', calculatedSignature)
.query({ uid: 1 });
// use expect by jest
expect(result.status).toBe(401);
expect(result.body.message).toBe('无效请求');
});
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
性能压测对比
方案 | 平均延时 | CPU消耗 | 安全等级 | 适用场景 |
---|---|---|---|---|
基础JWT | 3ms | 15% | ★★☆ | 内部微服务 |
OAuth2.0 | 35ms | 40% | ★★★☆ | 第三方开放平台 |
Sa-Token | 5ms | 18% | ★★★ | 快速开发项目 |
网关统一鉴权 | 8ms | 25% | ★★★☆ | 多语言混合架构 |
Token中继 | 12ms | 30% | ★★★ | 服务链调用 |
JWE加密 | 45ms | 60% | ★★★★☆ | 金融敏感数据 |
mTLS | 20ms | 50% | ★★★★★ | 零信任网络 |
测试环境:AWS c5.4xlarge 16核32GB × 3节点
安全攻防
四大攻击手段及防御
攻击类型 | 防御方案 | 代码实现 |
---|---|---|
Token窃取 | 绑定设备指纹 | StpUtil.getToken().setExtra("deviceId", fingerprint) |
重放攻击 | Nonce校验+时间戳 | redis.opsForValue().setIfAbsent(nonce, "used", 5, TimeUnit.SECONDS) |
越权访问 | 动态权限校验 | @SaCheckPermission("#user.id") |
Token破解 | 定期轮换签名密钥 | Jwts.parserBuilder().setSigningKeyResolver(new KeyRotationResolver()) |
审计日志必备字段
为了保证系统的操作安全,我们需要增加审计日志表。
审计日志必备字段如下:
public class AuditLog {
private String tokenId; // Token唯一标识
private String userId; // 用户ID
private String operation; // 操作类型
private String resource; // 访问资源
private String clientIp; // 客户端IP
private String deviceInfo; // 设备信息
private LocalDateTime time; // 操作时间
}
2
3
4
5
6
7
8
9
5.方案如何选型?
总结
- 初创期:基础JWT+Redis方案
- 发展期:OAuth2.0+网关鉴权
- 成熟期:JWE加密+双向TLS
- 高级期:零信任架构+AI风控
微服务安全如同城堡防御——单一的护城河无法阻挡所有入侵,需要城墙、箭塔、卫兵的多层防护。
没有绝对安全的系统,只有不断提高的攻击成本。