Files
plp/apps/api/src/auth/auth.service.ts
T
root fa0fa78312 fix(治理): 完成任务 8 安全与通知闭环修复
- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
2026-09-17 13:08:55 +08:00

398 lines
12 KiB
TypeScript

import { HttpStatus, Inject, Injectable } from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import { Prisma, type Account } from "@prisma/client";
import { randomInt, randomUUID } from "node:crypto";
import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js";
import { RedisService } from "../redis/redis.service.js";
import {
codeHmac,
demoSmsCodeEnabled,
encryptPhone,
envInt,
phoneHmac,
refreshHmac,
} from "./auth.config.js";
import { TokenService } from "./token.service.js";
export interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
@Injectable()
export class AuthService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(RedisService) private readonly redis: RedisService,
@Inject(TokenService) private readonly tokens: TokenService,
) {}
normalizePhone(raw: string): string {
const compact = raw.replace(/[\s()-]/g, "");
const local = compact.startsWith("+86")
? compact.slice(3)
: compact.startsWith("86") && compact.length === 13
? compact.slice(2)
: compact;
if (!/^1[3-9]\d{9}$/.test(local))
throw new DomainException(
ErrorCode.AUTH_INVALID_CREDENTIALS,
"Invalid credentials",
HttpStatus.UNAUTHORIZED,
);
return `+86${local}`;
}
private prefix() {
return process.env.REDIS_KEY_PREFIX ?? "drift:auth:";
}
private async limitAll(
dimensions: Array<{
kind: string;
parts: string[];
max: number;
ttl: number;
}>,
): Promise<void> {
await this.redis.ensureConnected();
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",
HttpStatus.TOO_MANY_REQUESTS,
);
}
async sendSms(
rawPhone: string,
deviceId: string,
ip: string,
): Promise<{ sent: true; debugCode?: string }> {
const phone = this.normalizePhone(rawPhone);
const digest = phoneHmac(phone);
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(
`${this.prefix()}code:${digest}`,
codeHmac(digest, code),
{ EX: envInt("SMS_CODE_TTL_SECONDS", 300) },
);
const result: { sent: true; debugCode?: string } = { sent: true };
if (demoSmsCodeEnabled()) result.debugCode = code;
return result;
}
async login(
rawPhone: string,
code: string,
deviceId: string,
ip: string,
): Promise<TokenPair> {
const phone = this.normalizePhone(rawPhone);
const digest = phoneHmac(phone);
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(
`local v=redis.call('GET',KEYS[1]); if v and v==ARGV[1] then redis.call('DEL',KEYS[1]); return 1 end; return 0`,
{ keys: [key], arguments: [codeHmac(digest, code)] },
);
if (Number(consumed) !== 1) throw this.invalid();
let account = await this.prisma.account.findUnique({
where: { phoneHmac: digest },
});
if (!account) account = await this.createAccount(phone, digest);
if (account.status !== "ACTIVE") throw this.invalid();
const now = new Date();
const sanction = await this.prisma.sanction.findFirst({
where: {
accountId: account.id,
type: { in: ["SUSPENSION", "BAN"] },
revokedAt: null,
startsAt: { lte: now },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: { id: true },
});
if (sanction) throw this.invalid();
return this.createSession(account, deviceId);
}
private async createAccount(phone: string, digest: string): Promise<Account> {
try {
return await this.prisma.account.create({
data: {
phoneHmac: digest,
phoneCiphertext: new Uint8Array(encryptPhone(phone)),
},
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
const found = await this.prisma.account.findUnique({
where: { phoneHmac: digest },
});
if (found) return found;
}
throw error;
}
}
private async createSession(
account: Account,
deviceId: string,
): Promise<TokenPair> {
const id = randomUUID();
const refresh = this.tokens.newRefresh();
const expiresAt = new Date(
Date.now() + envInt("REFRESH_TOKEN_TTL_SECONDS", 2592000) * 1000,
);
await this.prisma.session.create({
data: {
id,
accountId: account.id,
tokenVersion: account.tokenVersion,
deviceId,
refreshTokenHash: refreshHmac(refresh),
tokenFamily: id,
expiresAt,
refreshTokens: {
create: { tokenHash: refreshHmac(refresh), generation: 0, expiresAt },
},
},
});
return this.pair(account, id, deviceId, refresh);
}
private pair(
account: Account,
sessionId: string,
deviceId: string,
refreshToken: string,
): TokenPair {
const expiresIn = envInt("ACCESS_TOKEN_TTL_SECONDS", 900);
return {
accessToken: this.tokens.issueAccess({
sub: account.id,
session_id: sessionId,
device_id: deviceId,
scopes: ["user"],
token_version: account.tokenVersion,
}),
refreshToken,
expiresIn,
};
}
async refresh(
token: string,
ip: string,
retryAttempt = 0,
): Promise<TokenPair> {
if (!this.validRefreshShape(token)) throw this.invalid();
if (retryAttempt === 0)
await this.limitAll([
{
kind: "refresh-attempt-ip",
parts: [phoneHmac(ip)],
max: envInt("REFRESH_ATTEMPT_IP_LIMIT", 30),
ttl: envInt("REFRESH_ATTEMPT_IP_WINDOW_SECONDS", 60),
},
]);
const hash = refreshHmac(token);
const observed = await this.prisma.refreshToken.findUnique({
where: { tokenHash: hash },
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,
},
]);
await this.cleanupExpiredRefreshTokens(observed.sessionId);
}
if (observed.status === "USED")
return this.revokeReusedFamily(observed.session.tokenFamily);
try {
const result = await this.prisma.$transaction(
async (tx): Promise<TokenPair | { reusedFamily: string }> => {
const stored = await tx.refreshToken.findUnique({
where: { tokenHash: hash },
include: { session: { include: { account: true } } },
});
if (!stored) throw this.invalid();
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() ||
stored.expiresAt <= new Date() ||
session.account.status !== "ACTIVE"
)
throw this.invalid();
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,
session.deviceId ?? "",
next,
);
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
if ("reusedFamily" in result)
return this.revokeReusedFamily(result.reusedFamily);
return result;
} catch (error) {
if (error instanceof DomainException) throw error;
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === "P2034" || error.code === "P2002")
) {
if (retryAttempt < 3) {
await new Promise((resolve) =>
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 },
data: { revokedAt: new Date() },
});
throw new DomainException(
ErrorCode.AUTH_REFRESH_REUSED,
"Refresh token reused",
HttpStatus.UNAUTHORIZED,
);
}
async logout(token: string | undefined): Promise<void> {
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: { tokenFamily: stored.session.tokenFamily },
data: { revokedAt: new Date() },
});
}
private validRefreshShape(token: string): boolean {
return /^[A-Za-z0-9_-]{43}$/.test(token);
}
private invalid() {
return new DomainException(
ErrorCode.AUTH_INVALID_CREDENTIALS,
"Invalid credentials",
HttpStatus.UNAUTHORIZED,
);
}
}