Files
photo-live/services/api/test/auth.e2e-spec.ts
T
浅念 39cae26106 feat(api): password login, sliding renewal, revocable sessions
- POST /v1/auth/password|refresh|logout per contract operationIds;
  long-lived single token (32B hex, DB stores sha256 only), sliding
  renewal without rotation, logout sets sessions.revokedAt.
- SessionGuard: Bearer -> session -> user eligibility (disabled/locked/
  pending revoke access); all failures map to 401 AUTH_TOKEN_EXPIRED.
- Anti-enumeration: unknown account / wrong password / status-blocked
  share one 401 AUTH_INVALID_CREDENTIALS; unknown-account path runs a
  dummy argon2 verify to equalize timing.
- Login rate limiting (P1): in-memory account+IP sliding window,
  5 failures / 15 min -> 429; success clears. Contract amendment
  (registered): new ErrorCodes.AUTH_RATE_LIMITED added to errors.ts +
  openapi Error enum + 429 response on /auth/password; generated types
  regenerated and committed. P1a single-instance limiter; P5 needs a
  Redis-backed implementation.
- Global ValidationPipe (APP_PIPE) enforces class-validator DTOs ->
  400 VALIDATION_FAILED via the existing exception filter.

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

284 lines
11 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/test/auth.e2e-spec.ts —— Task 13 登录/刷新/注销 e2e(先写后实现)。
// 真实 app + MySQL(reset.ts 隔离,用户经 Prisma 直种)。覆盖:
// 成功登录(令牌形状 / TTL / audit_logs / sessions.tokenHash)、
// 凭据错误的三种 401 归并(错误密码 / 账号不存在 / 状态不合格——同码防枚举)、
// 滑动续期(同令牌不轮换、过期时间前移)、垃圾/缺失令牌 401、
// 注销 204 + 令牌即刻失效 + sessions.revokedAt、
// 账号被停用后既有会话被守卫撤销、
// 登录失败限速(第 6 次 429 AUTH_RATE_LIMITED;成功登录清零计数)、
// DTO 校验 400 VALIDATION_FAILED(全局校验管道)。
// 限速计数在 app 实例内存中跨用例存留——各限速用例使用独立账号,避免相互污染;
// alice 在首个用例失败 1 次后由成功登录清零,后续用例不受影响。
import { createHash } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import { AppCnModule } from '../src/app-cn.module';
import { hashPassword } from '../src/auth/password';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
const TTL_SECONDS = Number.parseInt(
process.env.AUTH_SESSION_TTL_SECONDS ?? '2592000',
10,
);
/** 断言容差:登录/续期到响应到达之间的真实耗时(本机 e2e 足够)。 */
const TTL_TOLERANCE_MS = 120_000;
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
describe('auth (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let aliceId: string;
const PASSWORD = 'correct-pass-1';
beforeAll(async () => {
await resetDb();
const moduleRef = await Test.createTestingModule({
imports: [AppCnModule],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix(API_PREFIX);
await app.init();
prisma = new PrismaClient();
const hash = await hashPassword(PASSWORD);
aliceId = (
await prisma.users.create({
data: { account: 'alice', passwordHash: hash, displayName: 'Alice' },
select: { id: true },
})
).id;
// 状态不合格三态:停用 / 本地锁定 / 凭据待激活(A01 未激活无法进入)。
await prisma.users.create({
data: {
account: 'bob',
passwordHash: hash,
displayName: 'Bob',
sourceStatus: 'disabled',
},
});
await prisma.users.create({
data: {
account: 'carol',
passwordHash: hash,
displayName: 'Carol',
localLocked: true,
},
});
await prisma.users.create({
data: {
account: 'dave',
passwordHash: hash,
displayName: 'Dave',
credentialStatus: 'pending',
},
});
// 限速专用账号(与其它用例隔离,避免内存计数交叉污染)。
await prisma.users.create({
data: { account: 'erin', passwordHash: hash, displayName: 'Erin' },
});
await prisma.users.create({
data: { account: 'frank', passwordHash: hash, displayName: 'Frank' },
});
// 会话撤销语义专用账号(测试内会被置为 disabled,不复用其它账号)。
await prisma.users.create({
data: { account: 'gina', passwordHash: hash, displayName: 'Gina' },
});
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
function login(account: string, password: string) {
return request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/password`)
.send({ account, password });
}
it('正确账号密码 → 200 {sessionToken, expiresAt≈now+TTL},tokenHash=sha256(token),且写 audit_logs(auth.login)', async () => {
const before = Date.now();
const res = await login('alice', PASSWORD);
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual(['expiresAt', 'sessionToken']);
const { sessionToken, expiresAt } = res.body as {
sessionToken: string;
expiresAt: string;
};
// 32B 随机数的 hex 编码。
expect(sessionToken).toMatch(/^[0-9a-f]{64}$/);
const expiresMs = Date.parse(expiresAt);
const lower = before + TTL_SECONDS * 1000 - TTL_TOLERANCE_MS;
const upper = before + TTL_SECONDS * 1000 + TTL_TOLERANCE_MS;
expect(expiresMs).toBeGreaterThanOrEqual(lower);
expect(expiresMs).toBeLessThanOrEqual(upper);
// 库内只存哈希;明文令牌不落库。
const session = await prisma.sessions.findUnique({
where: { tokenHash: sha256Hex(sessionToken) },
});
expect(session).not.toBeNull();
expect(session?.userId).toBe(aliceId);
expect(session?.revokedAt).toBeNull();
const audit = await prisma.audit_logs.findFirst({
where: { action: 'auth.login', actorId: aliceId },
});
expect(audit).not.toBeNull();
});
it('错误密码 → 401 AUTH_INVALID_CREDENTIALS', async () => {
const res = await login('alice', 'wrong-password');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
expect(typeof res.body.requestId).toBe('string');
});
it('不存在的账号 → 401 同一错误码(防账号枚举)', async () => {
const res = await login('ghost-account', 'whatever');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
});
it.each([
['sourceStatus=disabled', 'bob'],
['localLocked=true', 'carol'],
['credentialStatus=pending(A01 未激活)', 'dave'],
])('%s → 401 同一错误码(正确密码也不放行)', async (_label, account) => {
const res = await login(account as string, PASSWORD);
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
});
it('请求体缺 password → 400 VALIDATION_FAILED(全局校验管道)', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/password`)
.send({ account: 'alice' });
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('滑动续期:有效令牌 → 200 {expiresAt} 前移,且旧令牌仍可用(不轮换)', async () => {
const loginRes = await login('alice', PASSWORD);
const token: string = loginRes.body.sessionToken;
const originalExpires: number = Date.parse(loginRes.body.expiresAt);
// 把会话过期时间手工缩短到即将过期,续期后应回到 ~now+TTL(前移可观测)。
await prisma.sessions.update({
where: { tokenHash: sha256Hex(token) },
data: { expiresAt: new Date(Date.now() + 60_000) },
});
const before = Date.now();
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(Object.keys(res.body)).toEqual(['expiresAt']);
const renewedMs = Date.parse(res.body.expiresAt);
expect(renewedMs).toBeGreaterThan(originalExpires);
expect(renewedMs).toBeGreaterThanOrEqual(
before + TTL_SECONDS * 1000 - TTL_TOLERANCE_MS,
);
expect(renewedMs).toBeLessThanOrEqual(
before + TTL_SECONDS * 1000 + TTL_TOLERANCE_MS,
);
// 不轮换:同一令牌再次续期仍 200(若实现为轮换,旧令牌应已失效)。
const again = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${token}`);
expect(again.status).toBe(200);
});
it('续期携带垃圾令牌 → 401 AUTH_TOKEN_EXPIRED', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', 'Bearer not-a-real-token');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
it('续期缺失 Authorization 头 → 401 AUTH_TOKEN_EXPIRED', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`);
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
it('注销 → 204 无响应体;同令牌后续受保护请求 401;sessions.revokedAt 落库', async () => {
const loginRes = await login('alice', PASSWORD);
const token: string = loginRes.body.sessionToken;
const logout = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/logout`)
.set('Authorization', `Bearer ${token}`);
expect(logout.status).toBe(204);
expect(logout.body).toEqual({});
const after = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${token}`);
expect(after.status).toBe(401);
expect(after.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
const session = await prisma.sessions.findUnique({
where: { tokenHash: sha256Hex(token) },
select: { revokedAt: true },
});
expect(session?.revokedAt).not.toBeNull();
});
it('账号被停用后,既有会话被守卫拒绝(撤销语义)', async () => {
const res = await login('gina', PASSWORD);
expect(res.status).toBe(200);
const token: string = res.body.sessionToken;
await prisma.users.update({
where: { account: 'gina' },
data: { sourceStatus: 'disabled' },
});
const refresh = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${token}`);
expect(refresh.status).toBe(401);
expect(refresh.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
it('限速:同账号连续 5 次失败后,第 6 次(即使密码正确)→ 429 AUTH_RATE_LIMITED', async () => {
for (let i = 0; i < 5; i++) {
const res = await login('erin', 'wrong-password');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
}
const locked = await login('erin', PASSWORD);
expect(locked.status).toBe(429);
expect(locked.body.code).toBe(ErrorCodes.AUTH_RATE_LIMITED);
});
it('限速:成功登录清零失败计数(4 次失败 + 成功 + 再失败仍是 401 而非 429)', async () => {
for (let i = 0; i < 4; i++) {
const res = await login('frank', 'wrong-password');
expect(res.status).toBe(401);
}
const ok = await login('frank', PASSWORD);
expect(ok.status).toBe(200);
const res = await login('frank', 'wrong-password');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
});
});