fix: 加固认证撤销与运行配置

This commit is contained in:
root
2026-09-14 20:05:40 +08:00
parent c708205084
commit 6b453af364
13 changed files with 397 additions and 26 deletions
+56 -12
View File
@@ -7,6 +7,7 @@ import { PrismaService } from "../database/prisma.service.js";
import { RedisService } from "../redis/redis.service.js";
import {
codeHmac,
demoSmsCodeEnabled,
encryptPhone,
envInt,
phoneHmac,
@@ -110,7 +111,7 @@ export class AuthService {
{ EX: envInt("SMS_CODE_TTL_SECONDS", 300) },
);
const result: { sent: true; debugCode?: string } = { sent: true };
if (process.env.NODE_ENV !== "production") result.debugCode = code;
if (demoSmsCodeEnabled()) result.debugCode = code;
return result;
}
async login(
@@ -183,6 +184,7 @@ export class AuthService {
data: {
id,
accountId: account.id,
tokenVersion: account.tokenVersion,
deviceId,
refreshTokenHash: refreshHmac(refresh),
tokenFamily: id,
@@ -213,7 +215,11 @@ export class AuthService {
expiresIn,
};
}
async refresh(token: string): Promise<TokenPair> {
async refresh(
token: string,
ip: string,
retryAttempt = 0,
): Promise<TokenPair> {
if (!this.validRefreshShape(token)) throw this.invalid();
const hash = refreshHmac(token);
const observed = await this.prisma.refreshToken.findUnique({
@@ -221,6 +227,29 @@ export class AuthService {
include: { session: true },
});
if (!observed) throw this.invalid();
if (retryAttempt === 0) {
await this.limitAll([
{
kind: "refresh-token",
parts: [hash],
max: envInt("REFRESH_RATE_LIMIT", 60),
ttl: 60,
},
{
kind: "refresh-session",
parts: [observed.sessionId],
max: envInt("REFRESH_RATE_LIMIT", 60),
ttl: 60,
},
{
kind: "refresh-ip",
parts: [phoneHmac(ip)],
max: envInt("REFRESH_RATE_LIMIT", 60),
ttl: 60,
},
]);
await this.cleanupExpiredRefreshTokens(observed.sessionId);
}
if (observed.status === "USED")
return this.revokeReusedFamily(observed.session.tokenFamily);
@@ -235,6 +264,8 @@ export class AuthService {
if (stored.status === "USED")
return { reusedFamily: stored.session.tokenFamily };
const session = stored.session;
if (session.tokenVersion !== session.account.tokenVersion)
return { reusedFamily: session.tokenFamily };
if (
session.revokedAt ||
session.expiresAt <= new Date() ||
@@ -285,23 +316,36 @@ export class AuthService {
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === "P2034" || error.code === "P2002")
) {
// 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);
if (retryAttempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, 10 * (attempt + 1)),
setTimeout(resolve, 10 * 2 ** retryAttempt),
);
return this.refresh(token, ip, retryAttempt + 1);
}
const raced = await this.prisma.refreshToken.findUnique({
where: { tokenHash: hash },
include: { session: true },
});
if (raced?.status === "USED")
return this.revokeReusedFamily(raced.session.tokenFamily);
throw new DomainException(
ErrorCode.SERVICE_UNAVAILABLE,
"Authentication temporarily unavailable",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw error;
}
}
async cleanupExpiredRefreshTokens(sessionId?: string): Promise<number> {
const deleted = await this.prisma.refreshToken.deleteMany({
where: {
expiresAt: { lt: new Date() },
...(sessionId ? { sessionId } : {}),
},
});
return deleted.count;
}
private async revokeReusedFamily(family: string): Promise<never> {
await this.prisma.session.updateMany({
where: { tokenFamily: family },