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 { 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 { 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 { 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 { 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 { 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 => { 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 { const deleted = await this.prisma.refreshToken.deleteMany({ where: { expiresAt: { lt: new Date() }, ...(sessionId ? { sessionId } : {}), }, }); return deleted.count; } private async revokeReusedFamily(family: string): Promise { 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 { 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, ); } }