fix: 完善刷新令牌与联合限流

This commit is contained in:
root
2026-09-14 19:31:51 +08:00
parent 3e04197b02
commit c708205084
6 changed files with 393 additions and 112 deletions
+122 -97
View File
@@ -11,7 +11,6 @@ import {
envInt,
phoneHmac,
refreshHmac,
safeEqual,
} from "./auth.config.js";
import { TokenService } from "./token.service.js";
@@ -45,19 +44,31 @@ export class AuthService {
private prefix() {
return process.env.REDIS_KEY_PREFIX ?? "drift:auth:";
}
private async limit(
kind: string,
parts: string[],
max: number,
ttl: number,
private async limitAll(
dimensions: Array<{
kind: string;
parts: string[];
max: number;
ttl: number;
}>,
): Promise<void> {
await this.redis.ensureConnected();
const key = `${this.prefix()}limit:${kind}:${parts.join(":")}`;
const result = (await this.redis.client.eval(
`local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]) end; return n`,
{ keys: [key], arguments: [String(ttl)] },
)) as number;
if (result > max)
const keys = dimensions.map(
({ kind, parts }) => `${this.prefix()}limit:${kind}:${parts.join(":")}`,
);
const args = dimensions.flatMap(({ max, ttl }) => [
String(max),
String(ttl),
]);
// Atomic across all dimensions on the MVP's single Redis node. Redis Cluster
// would require a colocated hash tag or a different distributed limiter.
const result = Number(
await this.redis.client.eval(
`for i,key in ipairs(KEYS) do local n=tonumber(redis.call('GET',key) or '0'); local max=tonumber(ARGV[(i-1)*2+1]); if n+1>max then return 0 end end; for i,key in ipairs(KEYS) do local n=redis.call('INCR',key); if n==1 then redis.call('EXPIRE',key,ARGV[(i-1)*2+2]) end end; return 1`,
{ keys, arguments: args },
),
);
if (result !== 1)
throw new DomainException(
ErrorCode.RATE_LIMITED,
"Too many requests",
@@ -71,25 +82,26 @@ export class AuthService {
): Promise<{ sent: true; debugCode?: string }> {
const phone = this.normalizePhone(rawPhone);
const digest = phoneHmac(phone);
await Promise.all([
this.limit(
"send-phone",
[digest],
envInt("SMS_SEND_LIMIT", 3),
envInt("SMS_RATE_WINDOW_SECONDS", 300),
),
this.limit(
"send-device",
[deviceId],
envInt("SMS_SEND_DEVICE_LIMIT", 5),
envInt("SMS_RATE_WINDOW_SECONDS", 300),
),
this.limit(
"send-ip",
[phoneHmac(ip)],
envInt("SMS_SEND_IP_LIMIT", 10),
envInt("SMS_RATE_WINDOW_SECONDS", 300),
),
const window = envInt("SMS_RATE_WINDOW_SECONDS", 300);
await this.limitAll([
{
kind: "send-phone",
parts: [digest],
max: envInt("SMS_SEND_LIMIT", 3),
ttl: window,
},
{
kind: "send-device",
parts: [deviceId],
max: envInt("SMS_SEND_DEVICE_LIMIT", 5),
ttl: window,
},
{
kind: "send-ip",
parts: [phoneHmac(ip)],
max: envInt("SMS_SEND_IP_LIMIT", 10),
ttl: window,
},
]);
const code = randomInt(0, 1000000).toString().padStart(6, "0");
await this.redis.client.set(
@@ -109,19 +121,20 @@ export class AuthService {
): Promise<TokenPair> {
const phone = this.normalizePhone(rawPhone);
const digest = phoneHmac(phone);
await Promise.all([
this.limit(
"verify-phone",
[digest],
envInt("SMS_VERIFY_LIMIT", 5),
envInt("SMS_RATE_WINDOW_SECONDS", 300),
),
this.limit(
"verify-combined",
[digest, phoneHmac(ip), deviceId],
envInt("SMS_VERIFY_COMBINED_LIMIT", 5),
envInt("SMS_RATE_WINDOW_SECONDS", 300),
),
const window = envInt("SMS_RATE_WINDOW_SECONDS", 300);
await this.limitAll([
{
kind: "verify-phone",
parts: [digest],
max: envInt("SMS_VERIFY_LIMIT", 5),
ttl: window,
},
{
kind: "verify-combined",
parts: [digest, phoneHmac(ip), deviceId],
max: envInt("SMS_VERIFY_COMBINED_LIMIT", 5),
ttl: window,
},
]);
const key = `${this.prefix()}code:${digest}`;
const consumed = await this.redis.client.eval(
@@ -162,7 +175,7 @@ export class AuthService {
deviceId: string,
): Promise<TokenPair> {
const id = randomUUID();
const refresh = this.tokens.newRefresh(id);
const refresh = this.tokens.newRefresh();
const expiresAt = new Date(
Date.now() + envInt("REFRESH_TOKEN_TTL_SECONDS", 2592000) * 1000,
);
@@ -174,6 +187,9 @@ export class AuthService {
refreshTokenHash: refreshHmac(refresh),
tokenFamily: id,
expiresAt,
refreshTokens: {
create: { tokenHash: refreshHmac(refresh), generation: 0, expiresAt },
},
},
});
return this.pair(account, id, deviceId, refresh);
@@ -198,48 +214,59 @@ export class AuthService {
};
}
async refresh(token: string): Promise<TokenPair> {
const parsed = this.parseRefresh(token);
if (!this.validRefreshShape(token)) throw this.invalid();
const hash = refreshHmac(token);
const observed = await this.prisma.session.findUnique({
where: { id: parsed },
const observed = await this.prisma.refreshToken.findUnique({
where: { tokenHash: hash },
include: { session: true },
});
if (
observed?.previousTokenHash &&
safeEqual(observed.previousTokenHash, hash)
)
return this.revokeReusedFamily(observed.tokenFamily);
if (!observed) throw this.invalid();
if (observed.status === "USED")
return this.revokeReusedFamily(observed.session.tokenFamily);
try {
const result = await this.prisma.$transaction(
async (tx): Promise<TokenPair | { reusedFamily: string }> => {
const session = await tx.session.findUnique({
where: { id: parsed },
include: { account: true },
const stored = await tx.refreshToken.findUnique({
where: { tokenHash: hash },
include: { session: { include: { account: true } } },
});
if (!session) throw this.invalid();
if (!stored) throw this.invalid();
if (stored.status === "USED")
return { reusedFamily: stored.session.tokenFamily };
const session = stored.session;
if (
session.previousTokenHash &&
safeEqual(session.previousTokenHash, hash)
)
return { reusedFamily: session.tokenFamily };
if (
!safeEqual(session.refreshTokenHash, hash) ||
session.revokedAt ||
session.expiresAt <= new Date() ||
stored.expiresAt <= new Date() ||
session.account.status !== "ACTIVE"
)
throw this.invalid();
const next = this.tokens.newRefresh(session.id);
const changed = await tx.session.updateMany({
where: { id: session.id, refreshTokenHash: hash, revokedAt: null },
data: {
previousTokenHash: hash,
refreshTokenHash: refreshHmac(next),
generation: { increment: 1 },
rotatedAt: new Date(),
},
const next = this.tokens.newRefresh();
const nextHash = refreshHmac(next);
const usedAt = new Date();
const changed = await tx.refreshToken.updateMany({
where: { id: stored.id, status: "ACTIVE" },
data: { status: "USED", usedAt, replacedByHash: nextHash },
});
if (changed.count !== 1) return { reusedFamily: session.tokenFamily };
await tx.refreshToken.create({
data: {
tokenHash: nextHash,
sessionId: session.id,
generation: stored.generation + 1,
expiresAt: session.expiresAt,
},
});
await tx.session.update({
where: { id: session.id },
data: {
previousTokenHash: hash,
refreshTokenHash: nextHash,
generation: stored.generation + 1,
rotatedAt: usedAt,
},
});
return this.pair(
session.account,
session.id,
@@ -256,16 +283,21 @@ export class AuthService {
if (error instanceof DomainException) throw error;
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2034"
(error.code === "P2034" || error.code === "P2002")
) {
const session = await this.prisma.session.findUnique({
where: { id: parsed },
});
if (
session?.previousTokenHash &&
safeEqual(session.previousTokenHash, hash)
)
return this.revokeReusedFamily(session.tokenFamily);
// A serialization/uniqueness loser can observe the winner only after its
// transaction commits. Poll briefly, then revoke outside the failed tx.
for (let attempt = 0; attempt < 5; attempt += 1) {
const raced = await this.prisma.refreshToken.findUnique({
where: { tokenHash: hash },
include: { session: true },
});
if (raced?.status === "USED")
return this.revokeReusedFamily(raced.session.tokenFamily);
await new Promise((resolve) =>
setTimeout(resolve, 10 * (attempt + 1)),
);
}
}
throw error;
}
@@ -282,26 +314,19 @@ export class AuthService {
);
}
async logout(token: string | undefined): Promise<void> {
if (!token) return;
let id: string;
try {
id = this.parseRefresh(token);
} catch {
return;
}
const hash = refreshHmac(token);
if (!token || !this.validRefreshShape(token)) return;
const stored = await this.prisma.refreshToken.findUnique({
where: { tokenHash: refreshHmac(token) },
include: { session: true },
});
if (!stored) return;
await this.prisma.session.updateMany({
where: {
id,
OR: [{ refreshTokenHash: hash }, { previousTokenHash: hash }],
},
where: { tokenFamily: stored.session.tokenFamily },
data: { revokedAt: new Date() },
});
}
private parseRefresh(token: string): string {
const id = token.split(".")[0];
if (!id || !/^[0-9a-f-]{36}$/.test(id)) throw this.invalid();
return id;
private validRefreshShape(token: string): boolean {
return /^[A-Za-z0-9_-]{43}$/.test(token);
}
private invalid() {
return new DomainException(