Files
photo-live/services/api/src/auth/auth.service.ts
T
浅念 3681999667 feat(api): scheduled user sync with mock source (T30 semantics)
P4a Task 7 —— cn 侧用户定期同步(mock 源先行,G4 真实接口到位后仅替换
UserSourcePort provider,引擎/契约/表不变)。

数据模型(db-cn 迁移 20260920114149,utf8mb4_0900_bin 逐表校对):
- user_sync_runs:批次账本(trigger cron|manual、state running|success|
  partial|failed、cursor 水位、counts JSON、起止时间与错误摘要);
- user_sync_errors:单批次坏行明细(runId 索引);
- external_user_mappings:本地↔外部 ID 映射(@@unique([sourceSystem,
  externalUserId]),sourceVersion 逐行高水位);
- user_sync_state:单行状态(lastRunAt/lastSuccessAt/cursor/runCount)。

同步引擎(UserSyncEngine):
- Redis SETNX 分布式互斥(TTL 10min,锁值=runId,Lua CAS 释放);
  定时 tick 与手动触发共用,运行中批次复用 runId(202);
- 游标增量 + 周期全量对账(RECONCILE_EVERY_N_RUNS 默认 10);
- 逐页单事务提交(run counts+cursor / state cursor),分页中断→failed
  且水位不前进、会话不动;
- 红线遵守:sync 不写 password_hash/role/album_members/localLocked;
  空/部分页不误伤停用;上游恢复不解除本地锁定;
- 新用户按 pending 建(随机不可用哨兵哈希,激活+重置后方可登录);
- 停用即撤销该用户全部会话;本地预建同名账号走关联而非重建。

Admin API(/admin/user-sync):GET status|runs|errors?runId=、POST runs
(202 {runId});runs 为 startedAt+ID 倒序游标分页。

auth.service:登录对哨兵/损坏哈希加固(等代价 dummy verify,
401 而非 500,时序平坦)。

测试:user-sync.e2e-spec 12 用例(首轮全量/改名幂等/停用撤会话/
恢复不解锁/新增/空页/分页中断/部分无效/手动互斥/增量+对账/API 形状
与权限/pending 登录门);api e2e 22 套件 239 用例全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 20:01:17 +08:00

280 lines
10 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 在两条路径都真实跑过,时延也一致)。
// 哨兵哈希(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<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,
},
}),
]);
}
}