fix: 加固认证撤销与运行配置
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user