// services/api/src/auth/auth.service.ts —— 登录 / 滑动续期 / 注销 / 修改密码核心逻辑 // (Task 13;changePassword 为 P3 Task 3 契约增补)。 // 会话模型:长效单令牌 + 滑动续期(用户决策 2026-09-17,不做 access/refresh 双层)。 // 令牌 = 32B randomBytes hex(仅登录响应出现一次);库内只存 sha256(token)。 // 安全要点: // · 防账号枚举——账号不存在 / 密码错误 / 状态不合格(停用、锁定、待激活)三者 // 返回同一 401 AUTH_INVALID_CREDENTIALS 且同文案;账号不存在时也跑一次等代价 // argon2 verify(dummy hash)拉平时延,杜绝「响应快慢」侧信道。 // · 登录失败限速(总纲 P1)——account+IP 维度、15 分钟窗口、5 次失败锁定, // 成功登录清零。进程内存实现:P1a 单实例足够;P5 多实例需换 Redis 后端限速器 // (本类私有接口不变,替换实现即可)。 // · 审计——登录成功写 audit_logs(action='auth.login', actor=用户); // 注销不写审计(会话行 revokedAt 本身即持久的注销凭据); // 修改密码写 audit_logs(action='auth.password_change')(敏感凭据变更)。 import { randomBytes } from 'node:crypto'; import { HttpStatus, Injectable } from '@nestjs/common'; import { ErrorCodes } from '@photo/contracts'; import { ApiException } from '../api-exception'; import { PrismaService } from '../prisma/prisma.service'; import { hashPassword, verifyPassword } from './password'; import { hashSessionToken } from './session.guard'; /** 契约默认 30 天(.env AUTH_SESSION_TTL_SECONDS 同名可调)。 */ const DEFAULT_SESSION_TTL_SECONDS = 2_592_000; const LOGIN_RATE_LIMIT_MAX_FAILURES = 5; const LOGIN_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; function invalidCredentials(): ApiException { return new ApiException( ErrorCodes.AUTH_INVALID_CREDENTIALS, 'Invalid account or password', HttpStatus.UNAUTHORIZED, ); } // 惰性生成一次 dummy argon2 哈希并复用:账号不存在路径用它跑等代价 verify。 // 选「首用时生成」而非内嵌常量:参数始终跟随 hashPassword 当前默认,不会因 // 升级 argon2 参数而漂移成不等价时延。 let dummyHashPromise: Promise | undefined; function dummyHash(): Promise { dummyHashPromise ??= hashPassword(randomBytes(32).toString('hex')); return dummyHashPromise; } /** 进程内滑动窗口失败计数器(account+IP 维度)。 */ class InMemoryLoginRateLimiter { private readonly buckets = new Map(); constructor( private readonly maxFailures: number, private readonly windowMs: number, ) {} /** 窗口期内失败次数达到阈值即锁定;窗口过期顺手淘汰条目,Map 不无界增长。 */ isLocked(key: string): boolean { const bucket = this.buckets.get(key); if (bucket === undefined) { return false; } if (Date.now() - bucket.windowStart >= this.windowMs) { this.buckets.delete(key); return false; } return bucket.count >= this.maxFailures; } recordFailure(key: string): void { const now = Date.now(); const bucket = this.buckets.get(key); if (bucket === undefined || now - bucket.windowStart >= this.windowMs) { this.buckets.set(key, { count: 1, windowStart: now }); return; } bucket.count += 1; } clear(key: string): void { this.buckets.delete(key); } } export interface LoginResult { sessionToken: string; expiresAt: string; } export interface RenewalResult { expiresAt: string; } @Injectable() export class AuthService { private readonly sessionTtlSeconds: number; private readonly rateLimiter = new InMemoryLoginRateLimiter( LOGIN_RATE_LIMIT_MAX_FAILURES, LOGIN_RATE_LIMIT_WINDOW_MS, ); constructor(private readonly prisma: PrismaService) { const raw = Number.parseInt( process.env.AUTH_SESSION_TTL_SECONDS ?? '', 10, ); this.sessionTtlSeconds = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_TTL_SECONDS; } async login( account: string, plainPassword: string, ip: string, ): Promise { // ① 限速先行:锁定期内不触 DB、不跑 argon2(即便密码正确也 429, // 防止用「锁定与否」探测密码正确性)。 const rateKey = `${account}|${ip}`; if (this.rateLimiter.isLocked(rateKey)) { throw new ApiException( ErrorCodes.AUTH_RATE_LIMITED, 'Too many failed login attempts; try again later', HttpStatus.TOO_MANY_REQUESTS, ); } // ② 查用户:不存在 → dummy verify 拉平时延后与密码错误同响应。 const user = await this.prisma.users.findUnique({ where: { account } }); if (user === null) { await verifyPassword(await dummyHash(), plainPassword); this.rateLimiter.recordFailure(rateKey); throw invalidCredentials(); } // ③ 先验密码再查状态:状态不合格与密码错误同样不可区分(同 401 同文案; // argon2 在两条路径都真实跑过,时延也一致)。 // 哨兵哈希(P4a Task 7:同步创建的待激活账号 passwordHash='!:pending:…') // 不是合法 argon2 编码——verify 会抛错而非返回 false。此处按密码错误 // 处理并补跑等代价 dummy verify(时延与密码错误路径一致,不泄露 // 「该账号待激活」),杜绝 500。 let passwordOk: boolean; try { passwordOk = await verifyPassword(user.passwordHash, plainPassword); } catch { await verifyPassword(await dummyHash(), plainPassword); passwordOk = false; } if (!passwordOk) { this.rateLimiter.recordFailure(rateKey); throw invalidCredentials(); } if ( user.sourceStatus === 'disabled' || user.localLocked || user.credentialStatus !== 'active' ) { // 密码已证实正确——不属于凭据猜测,不计入限速(限速针对撞库, // 停用/待激活账号本就无法登录,计数无防护收益)。 throw invalidCredentials(); } // ④ 签发会话:明文令牌仅本次返回;库存 sha256,expiresAt=now+TTL。 const sessionToken = randomBytes(32).toString('hex'); const session = await this.prisma.sessions.create({ data: { userId: user.id, tokenHash: hashSessionToken(sessionToken), expiresAt: new Date(Date.now() + this.sessionTtlSeconds * 1000), }, }); this.rateLimiter.clear(rateKey); // ⑤ 审计(resourceId=会话 id:登录动作产出的资源即会话)。 await this.prisma.audit_logs.create({ data: { actorId: user.id, action: 'auth.login', resourceId: session.id, metadata: { ip }, }, }); return { sessionToken, expiresAt: session.expiresAt.toISOString(), }; } /** * 滑动续期:同一令牌不轮换(会话行只前移 expiresAt)。 * 守卫已校验有效性;此处仍防御性复核(守卫与更新之间存在微小窗口, * 且未来其它调用方可能绕过守卫)。 */ async refresh(sessionId: string): Promise { const session = await this.prisma.sessions.findUnique({ where: { id: sessionId }, }); if ( session === null || session.revokedAt !== null || session.expiresAt.getTime() <= Date.now() ) { throw new ApiException( ErrorCodes.AUTH_TOKEN_EXPIRED, 'Session token is missing, invalid, or expired', HttpStatus.UNAUTHORIZED, ); } const expiresAt = new Date(Date.now() + this.sessionTtlSeconds * 1000); await this.prisma.sessions.update({ where: { id: sessionId }, data: { expiresAt }, }); return { expiresAt: expiresAt.toISOString() }; } /** 注销:置 revokedAt;幂等(updateMany 只在尚未撤销时落时间戳)。 */ async logout(sessionId: string): Promise { await this.prisma.sessions.updateMany({ where: { id: sessionId, revokedAt: null }, data: { revokedAt: new Date() }, }); } /** * 修改密码(P3 Task 3 契约增补,需求 4.1): * ① 校验旧密码——错误时 401 AUTH_INVALID_CREDENTIALS(契约该端点 401 语义 * 即「旧密码错误」)。调用方已认证(SessionGuard),无需 dummy-verify * 拉平时延——用户身份已知,不存在账号枚举面。 * ② 事务三写:更新 passwordHash + 撤销该用户除当前会话外的全部其他会话 * (改密不应把本人踢下线,但旧凭据泄露面要即刻收敛)+ 审计 * auth.password_change。哈希在事务外先算好(argon2 耗时,不占连接)。 */ async changePassword( userId: string, sessionId: string, oldPassword: string, newPassword: string, ): Promise { const user = await this.prisma.users.findUnique({ where: { id: userId }, select: { passwordHash: true }, }); if (user === null) { // 守卫已确保用户存在且资格合格;不可达分支按同码兜底(不泄露差异)。 throw new ApiException( ErrorCodes.AUTH_INVALID_CREDENTIALS, 'Old password is incorrect', HttpStatus.UNAUTHORIZED, ); } if (!(await verifyPassword(user.passwordHash, oldPassword))) { throw new ApiException( ErrorCodes.AUTH_INVALID_CREDENTIALS, 'Old password is incorrect', HttpStatus.UNAUTHORIZED, ); } const passwordHash = await hashPassword(newPassword); const revokedAt = new Date(); await this.prisma.$transaction([ this.prisma.users.update({ where: { id: userId }, data: { passwordHash }, }), // 只撤销「其他」会话且只动尚未撤销的行(幂等;当前会话保留)。 this.prisma.sessions.updateMany({ where: { userId, id: { not: sessionId }, revokedAt: null }, data: { revokedAt }, }), this.prisma.audit_logs.create({ data: { actorId: userId, action: 'auth.password_change', resourceId: userId, }, }), ]); } }