Files
photo-live/services/api/src/auth/auth.service.ts
T
浅念 31ba03de11 feat(api): password change endpoint and source device id on uploads
P3 Task 3 契约增补批次(单一事实来源 openapi.yaml + cn 实现 + e2e):

- POST /v1/auth/password-change(operationId changePassword,需求 4.1 的
  P1 契约缺口补齐):Bearer + {oldPassword 1-128, newPassword 8-128} → 204;
  旧密码错误 401 AUTH_INVALID_CREDENTIALS;成功后事务内更新 argon2 哈希、
  撤销该用户除当前会话外的全部其他会话(当前会话保留,本人不下线)、写
  审计 auth.password_change。
- sourceDeviceId(A19 设备来源语义):UploadInitRequest 可选 8-64 位设备
  标识;upload_sessions 落列(VARCHAR(64) utf8mb4_0900_bin,可空,存量
  行 NULL 兼容);GET /uploads 条目回显;幂等重放不比对(first-writer,
  首次落库值不随重放漂移)。
- e2e:auth 增 4 例(未认证 401 / 新密码过短 400 / 旧密码错误 401 且会话
  无副作用 / 成功后他端会话撤销当前保留 + 新旧密码登录裁决 + 审计);
  uploads 增 1 例(携带落库回显、换设备重放保留首值、缺省 null、长度
  越界 400)。api e2e 179 → 184;contracts gen/redocly/单测、worker、
  sync 门全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 00:59:39 +08:00

269 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string> | undefined;
function dummyHash(): Promise<string> {
dummyHashPromise ??= hashPassword(randomBytes(32).toString('hex'));
return dummyHashPromise;
}
/** 进程内滑动窗口失败计数器(account+IP 维度)。 */
class InMemoryLoginRateLimiter {
private readonly buckets = new Map<string, { count: number; windowStart: number }>();
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<LoginResult> {
// ① 限速先行:锁定期内不触 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 在两条路径都真实跑过,时延也一致)。
if (!(await verifyPassword(user.passwordHash, plainPassword))) {
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<RenewalResult> {
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<void> {
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<void> {
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,
},
}),
]);
}
}