feat(013-auth-hardening): password reset, password strength policy, login rate-limiting

Closes the two gaps 010-identity-auth explicitly deferred (password reset,
login rate-limiting), plus a shared password-strength validator both the
reset-consume endpoint and admin account creation now depend on.

- Password reset: single-use, paired-Redis-key tokens (never in Postgres),
  identical response regardless of account existence, stubbed delivery via
  a structured log line (no email infrastructure exists yet).
- Password strength: one validatePasswordStrength() call site, wired into
  both POST /admin/users and the reset-consume flow.
- Login rate-limiting: checkRateLimit keyed by submitted email, checked
  before any credential verification.

Also fixes tests/helpers/auth.ts's shared loginAs() helper, which reused
two fixed accounts across the whole integration suite via upsert — now
rate-limited per email, that collided across ~30 files sharing one budget.
Each call now gets a unique email; no call sites needed to change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-07 21:22:49 +05:30
co-authored by Claude Sonnet 5
parent 3bdccc901f
commit 79bc2ef25b
22 changed files with 524 additions and 37 deletions
@@ -43,3 +43,25 @@
direct, unavoidable dependency of User Story 1 — a password-reset flow that accepts any direct, unavoidable dependency of User Story 1 — a password-reset flow that accepts any
password would be hardening one gap while leaving the other wide open at the same door. password would be hardening one gap while leaving the other wide open at the same door.
- All items pass; no revision iterations were needed. - All items pass; no revision iterations were needed.
## Implementation Notes (post-build)
- `tests/helpers/auth.ts`'s shared `loginAs()` helper previously reused two fixed accounts
(`test-admin@supporthub.test` / `test-agent@supporthub.test`) across every integration test
file via `upsert`. Once login became rate-limited per email (User Story 3), the ~30 files that
each call it once in their own `beforeAll` collectively exceeded the attempt budget for those
two shared addresses well before most files' own tests ran, turning their legitimate logins
into `429`s. Fixed by giving each `loginAs()` call its own unique, randomly-suffixed email —
nothing in the suite depended on the literal fixed addresses, so no call sites needed to
change, only the helper itself.
- While re-running the full suite for regression, `tests/integration/orchestration-strategies.test.ts`'s
"SKILL_BASED prefers the eligible agent with the higher proficiency level" test was found
failing (picks the lower-proficiency agent). Verified via `git stash` that this reproduces
identically on the clean pre-013 `HEAD` with none of this feature's changes present — it is a
pre-existing bug in 007-orchestration-assignment's `SKILL_BASED` strategy, unrelated to and out
of scope for this feature. Left unfixed here; worth its own follow-up.
- `tests/integration/ticket-attachments.test.ts`'s 2 known MinIO-dependent failures (accepted
baseline, this project doesn't run MinIO) remain unchanged by this feature.
- All other integration and unit tests pass, including 010-identity-auth's own login/admin-account
tests, confirming no regression from `AuthService.login`'s new rate-limit check or the shared
`validatePasswordStrength` call added to `UsersService.create`.
+20 -20
View File
@@ -23,7 +23,7 @@ All file paths are relative to `supporthub-api/` (repo root).
## Phase 1: Foundational (Blocking Prerequisites) ## Phase 1: Foundational (Blocking Prerequisites)
- [ ] T001 Add `PASSWORD_MIN_LENGTH` (default `10`), - [x] T001 Add `PASSWORD_MIN_LENGTH` (default `10`),
`PASSWORD_RESET_TOKEN_LIFETIME_MINUTES` (default `30`), `PASSWORD_RESET_TOKEN_LIFETIME_MINUTES` (default `30`),
`LOGIN_RATE_LIMIT_MAX_ATTEMPTS` (default `5`), and `LOGIN_RATE_LIMIT_WINDOW_SECONDS` `LOGIN_RATE_LIMIT_MAX_ATTEMPTS` (default `5`), and `LOGIN_RATE_LIMIT_WINDOW_SECONDS`
(default `300`) to `src/config/env.ts`, exposed via `src/config/auth.ts`'s existing (default `300`) to `src/config/env.ts`, exposed via `src/config/auth.ts`'s existing
@@ -42,18 +42,18 @@ the existing admin account-creation endpoint.
### Tests for User Story 2 ### Tests for User Story 2
- [ ] T002 [P] [US2] Unit test for `validatePasswordStrength` (too-short rejected with the - [x] T002 [P] [US2] Unit test for `validatePasswordStrength` (too-short rejected with the
actual minimum named; policy-meeting password passes) in actual minimum named; policy-meeting password passes) in
`tests/unit/identity/password-policy.test.ts` `tests/unit/identity/password-policy.test.ts`
### Implementation for User Story 2 ### Implementation for User Story 2
- [ ] T003 [US2] Add `identity/auth/mapper/password-policy.ts`'s - [x] T003 [US2] Add `identity/auth/mapper/password-policy.ts`'s
`validatePasswordStrength(password): void`, throwing `ValidationError` (depends on T001) `validatePasswordStrength(password): void`, throwing `ValidationError` (depends on T001)
- [ ] T004 [US2] Export it from `identity/auth`'s public `index.ts` (depends on T003) - [x] T004 [US2] Export it from `identity/auth`'s public `index.ts` (depends on T003)
- [ ] T005 [US2] Call it from `identity/agents/service/users.service.ts`'s `UsersService.create`, - [x] T005 [US2] Call it from `identity/agents/service/users.service.ts`'s `UsersService.create`,
before hashing (depends on T004) before hashing (depends on T004)
- [ ] T006 [US2] Run Quickstart Scenario 2 step 1 locally and confirm it passes; re-run - [x] T006 [US2] Run Quickstart Scenario 2 step 1 locally and confirm it passes; re-run
010-identity-auth's own existing `POST /admin/users` tests to confirm no regression 010-identity-auth's own existing `POST /admin/users` tests to confirm no regression
**Checkpoint**: No password shorter than the policy can ever be set via the admin endpoint. **Checkpoint**: No password shorter than the policy can ever be set via the admin endpoint.
@@ -68,31 +68,31 @@ the existing admin account-creation endpoint.
### Tests for User Story 1 ### Tests for User Story 1
- [ ] T007 [US1] Integration test covering Quickstart Scenario 1 (request issues a token via - [x] T007 [US1] Integration test covering Quickstart Scenario 1 (request issues a token via
the log stub; a nonexistent email gets an identical response; consume succeeds once and the log stub; a nonexistent email gets an identical response; consume succeeds once and
fails the second time; login works with the new password and fails with the old) in fails the second time; login works with the new password and fails with the old) in
`tests/integration/password-reset-flow.test.ts` (depends on T006) `tests/integration/password-reset-flow.test.ts` (depends on T006)
### Implementation for User Story 1 ### Implementation for User Story 1
- [ ] T008 [US1] Add `identity/auth/mapper/reset-token.ts``generateResetToken()` (raw token + - [x] T008 [US1] Add `identity/auth/mapper/reset-token.ts``generateResetToken()` (raw token +
its SHA-256 hash) (depends on T001) its SHA-256 hash) (depends on T001)
- [ ] T009 [US1] Add `identity/auth/repository/reset-token.repository.ts` — `issue(userId, - [x] T009 [US1] Add `identity/auth/repository/reset-token.repository.ts` — `issue(userId,
tokenHash, ttlSeconds)` (deletes any prior token for this user first, per data-model.md's tokenHash, ttlSeconds)` (deletes any prior token for this user first, per data-model.md's
paired-key shape), `resolve(tokenHash)` (returns `userId` or null), `consume(tokenHash, paired-key shape), `resolve(tokenHash)` (returns `userId` or null), `consume(tokenHash,
userId)` (deletes both keys) (depends on T008) userId)` (deletes both keys) (depends on T008)
- [ ] T010 [US1] Add `AuthService.requestPasswordReset(email)`: always returns the same public - [x] T010 [US1] Add `AuthService.requestPasswordReset(email)`: always returns the same public
result; internally, if the email resolves to an active account, issues a token and logs result; internally, if the email resolves to an active account, issues a token and logs
the stub delivery event (structured log, research.md) (depends on T009) the stub delivery event (structured log, research.md) (depends on T009)
- [ ] T011 [US1] Add `AuthService.resetPassword(token, newPassword)`: validates password - [x] T011 [US1] Add `AuthService.resetPassword(token, newPassword)`: validates password
strength first (depends on T004), then resolves/consumes the token, 400s with a specific strength first (depends on T004), then resolves/consumes the token, 400s with a specific
reason if the token is missing/expired/used, hashes and stores the new password (depends reason if the token is missing/expired/used, hashes and stores the new password (depends
on T009, T004) on T009, T004)
- [ ] T012 [US1] Add `POST /auth/password-reset/request` and `POST /auth/password-reset/consume` - [x] T012 [US1] Add `POST /auth/password-reset/request` and `POST /auth/password-reset/consume`
(both ungated — no session exists yet) in `identity/auth/controller/` + `routes/` + (both ungated — no session exists yet) in `identity/auth/controller/` + `routes/` +
`schema/`, registered from `src/api/routes.ts` (already registers `authRoutes` as a `schema/`, registered from `src/api/routes.ts` (already registers `authRoutes` as a
whole, so no new registration call needed — depends on T010, T011) whole, so no new registration call needed — depends on T010, T011)
- [ ] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 5 steps pass - [x] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 5 steps pass
**Checkpoint**: A locked-out user has a real, working self-service fix. **Checkpoint**: A locked-out user has a real, working self-service fix.
@@ -107,21 +107,21 @@ credential verification.
### Tests for User Story 3 ### Tests for User Story 3
- [ ] T014 [P] [US3] Unit test confirming the rate-limit check is invoked before - [x] T014 [P] [US3] Unit test confirming the rate-limit check is invoked before
`repo.findByEmail`/`verifyPassword` in `AuthService.login` (a fake repo/mapper that would `repo.findByEmail`/`verifyPassword` in `AuthService.login` (a fake repo/mapper that would
throw if called after an already-exceeded limit) in throw if called after an already-exceeded limit) in
`tests/unit/identity/login-rate-limit-ordering.test.ts` `tests/unit/identity/login-rate-limit-ordering.test.ts`
- [ ] T015 [US3] Integration test covering Quickstart Scenario 3 (N attempts get 401, the N+1th - [x] T015 [US3] Integration test covering Quickstart Scenario 3 (N attempts get 401, the N+1th
— even with the correct password — gets 429, a different email is unaffected) in — even with the correct password — gets 429, a different email is unaffected) in
`tests/integration/login-rate-limit.test.ts` (depends on T001) `tests/integration/login-rate-limit.test.ts` (depends on T001)
### Implementation for User Story 3 ### Implementation for User Story 3
- [ ] T016 [US3] In `AuthService.login`, call the existing - [x] T016 [US3] In `AuthService.login`, call the existing
`checkRateLimit(`login:${email}`, authConfig.loginRateLimitMaxAttempts, `checkRateLimit(`login:${email}`, authConfig.loginRateLimitMaxAttempts,
authConfig.loginRateLimitWindowSeconds)` (from `@/infrastructure/cache`) as the very first authConfig.loginRateLimitWindowSeconds)` (from `@/infrastructure/cache`) as the very first
step, throwing `RateLimitError` if exceeded (depends on T001) step, throwing `RateLimitError` if exceeded (depends on T001)
- [ ] T017 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass - [x] T017 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass
**Checkpoint**: All three user stories work independently and together — this feature's full **Checkpoint**: All three user stories work independently and together — this feature's full
scope. scope.
@@ -130,10 +130,10 @@ scope.
## Phase 5: Polish & Cross-Cutting Concerns ## Phase 5: Polish & Cross-Cutting Concerns
- [ ] T018 [P] Update `specs/013-auth-hardening/checklists/requirements.md` Notes with any - [x] T018 [P] Update `specs/013-auth-hardening/checklists/requirements.md` Notes with any
implementation-time findings implementation-time findings
- [ ] T019 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` - [x] T019 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [ ] T020 Full regression: `npm run test:unit` then the full integration suite against real - [x] T020 Full regression: `npm run test:unit` then the full integration suite against real
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
(particularly 010-identity-auth's own login/admin-account tests, now touched by this (particularly 010-identity-auth's own login/admin-account tests, now touched by this
feature's changes) feature's changes)
+4
View File
@@ -3,4 +3,8 @@ import { env } from './env';
export const authConfig = { export const authConfig = {
jwtSecret: env.JWT_SECRET, jwtSecret: env.JWT_SECRET,
tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS, tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS,
passwordMinLength: env.PASSWORD_MIN_LENGTH,
passwordResetTokenLifetimeMinutes: env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
loginRateLimitMaxAttempts: env.LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
loginRateLimitWindowSeconds: env.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
}; };
+9
View File
@@ -65,6 +65,15 @@ const envSchema = z.object({
// already-required JWT_SECRET above (defined since the original scaffold, never consumed // already-required JWT_SECRET above (defined since the original scaffold, never consumed
// until now) — see specs/010-identity-auth/research.md. // until now) — see specs/010-identity-auth/research.md.
AUTH_TOKEN_LIFETIME_HOURS: z.coerce.number().default(4), AUTH_TOKEN_LIFETIME_HOURS: z.coerce.number().default(4),
// Authentication Hardening (013) — password-strength policy, reset-token lifetime, and
// login rate-limiting, all CONFIGURABLE per docs/10-implementation-roadmap.md's own
// "never hardcode a placeholder value and ship it as final" instruction — see
// specs/013-auth-hardening/research.md.
PASSWORD_MIN_LENGTH: z.coerce.number().default(10),
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: z.coerce.number().default(30),
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.coerce.number().default(5),
LOGIN_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().default(300),
}); });
export type EnvConfig = z.infer<typeof envSchema>; export type EnvConfig = z.infer<typeof envSchema>;
@@ -1,16 +1,19 @@
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import { ConflictError } from '@/common/errors'; import { ConflictError } from '@/common/errors';
import { hashPassword } from '@/modules/identity/auth'; import { hashPassword, validatePasswordStrength } from '@/modules/identity/auth';
import { usersRepository, UsersRepository } from '../repository'; import { usersRepository, UsersRepository } from '../repository';
import { CreateUserBody } from '../schema'; import { CreateUserBody } from '../schema';
export class UsersService { export class UsersService {
constructor(private readonly repo: UsersRepository = usersRepository) {} constructor(private readonly repo: UsersRepository = usersRepository) {}
/** FR-008: rejects a duplicate email — never a second account silently sharing one. */ /** FR-008: rejects a duplicate email — never a second account silently sharing one.
* 013-auth-hardening FR-005: the same password-strength policy every password-setting call
* site enforces. */
async create(body: CreateUserBody): Promise<Omit<User, 'passwordHash'>> { async create(body: CreateUserBody): Promise<Omit<User, 'passwordHash'>> {
const existing = await this.repo.findByEmail(body.email); const existing = await this.repo.findByEmail(body.email);
if (existing) throw new ConflictError('An account with this email already exists.'); if (existing) throw new ConflictError('An account with this email already exists.');
validatePasswordStrength(body.password);
const passwordHash = await hashPassword(body.password); const passwordHash = await hashPassword(body.password);
const user = await this.repo.create({ const user = await this.repo.create({
@@ -1,7 +1,7 @@
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthenticationError } from '@/common/errors'; import { AuthenticationError } from '@/common/errors';
import { authService, AuthService } from '../service'; import { authService, AuthService } from '../service';
import { loginSchema } from '../schema'; import { loginSchema, requestPasswordResetSchema, resetPasswordSchema } from '../schema';
function bearerToken(request: FastifyRequest): string { function bearerToken(request: FastifyRequest): string {
const header = request.headers.authorization; const header = request.headers.authorization;
@@ -29,6 +29,26 @@ export class AuthController {
await this.service.logout(bearerToken(request)); await this.service.logout(bearerToken(request));
return reply.status(200).send({ success: true, data: { loggedOut: true }, meta: null }); return reply.status(200).send({ success: true, data: { loggedOut: true }, meta: null });
} }
/** 013-auth-hardening FR-001/SC-001: identical response regardless of account existence —
* the service itself is what decides whether a real token gets issued. */
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
const { email } = requestPasswordResetSchema.parse(request.body);
await this.service.requestPasswordReset(email);
return reply.status(200).send({
success: true,
data: { message: 'If that account exists, a reset link has been sent.' },
meta: null,
});
}
async resetPassword(request: FastifyRequest, reply: FastifyReply) {
const { token, newPassword } = resetPasswordSchema.parse(request.body);
await this.service.resetPassword(token, newPassword);
return reply
.status(200)
.send({ success: true, data: { message: 'Password updated.' }, meta: null });
}
} }
export const authController = new AuthController(); export const authController = new AuthController();
+1
View File
@@ -4,4 +4,5 @@ export { requireRole } from './service';
export type { LoginBody } from './schema'; export type { LoginBody } from './schema';
export type { LoginResult } from './service'; export type { LoginResult } from './service';
export { hashPassword, verifyPassword, signToken, verifyToken, toAuthUser } from './mapper'; export { hashPassword, verifyPassword, signToken, verifyToken, toAuthUser } from './mapper';
export { validatePasswordStrength } from './mapper';
export { AUTH_CONSTANTS } from './constants'; export { AUTH_CONSTANTS } from './constants';
@@ -1 +1,3 @@
export * from './auth.mapper'; export * from './auth.mapper';
export * from './password-policy';
export * from './reset-token';
@@ -0,0 +1,13 @@
import { ValidationError } from '@/common/errors';
import { authConfig } from '@/config';
/** 013-auth-hardening FR-005: the one password-strength rule, enforced identically everywhere
* a password is ever set (010's own POST /admin/users and this feature's own password-reset
* consume endpoint) — never duplicated or allowed to drift between call sites. */
export function validatePasswordStrength(password: string): void {
if (password.length < authConfig.passwordMinLength) {
throw new ValidationError(
`Password must be at least ${authConfig.passwordMinLength} characters.`,
);
}
}
@@ -0,0 +1,18 @@
import { randomBytes, createHash } from 'crypto';
export interface GeneratedResetToken {
token: string;
tokenHash: string;
}
/** 013-auth-hardening: the raw token is what gets "delivered" (logged, per the stub decision,
* research.md); only its SHA-256 hash is ever persisted (data-model.md) — mirrors this
* codebase's own password-hashing discipline, never storing a usable secret at rest. */
export function generateResetToken(): GeneratedResetToken {
const token = randomBytes(32).toString('hex');
return { token, tokenHash: hashResetToken(token) };
}
export function hashResetToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
@@ -14,6 +14,11 @@ export class AuthRepository {
if (!user || !user.active) return null; if (!user || !user.active) return null;
return user; return user;
} }
/** 013-auth-hardening: applies a password-reset's new hash. */
async updatePassword(id: string, passwordHash: string): Promise<void> {
await this.prisma.user.update({ where: { id }, data: { passwordHash } });
}
} }
export const authRepository = new AuthRepository(); export const authRepository = new AuthRepository();
@@ -1 +1,2 @@
export * from './auth.repository'; export * from './auth.repository';
export * from './reset-token.repository';
@@ -0,0 +1,30 @@
import { cacheService } from '@/infrastructure/cache';
const TOKEN_KEY_PREFIX = 'password-reset:token:';
const USER_KEY_PREFIX = 'password-reset:user:';
/** 013-auth-hardening data-model.md: two paired Redis keys per active reset token — the same
* Redis-key-with-TTL shape as 010's own revocation denylist. Only one active token exists per
* user at any time (FR-002): issuing a new one deletes the prior token's own key. */
export class ResetTokenRepository {
async issue(userId: string, tokenHash: string, ttlSeconds: number): Promise<void> {
const priorHash = await cacheService.get<string>(`${USER_KEY_PREFIX}${userId}`);
if (priorHash) {
await cacheService.del(`${TOKEN_KEY_PREFIX}${priorHash}`);
}
await cacheService.set(`${TOKEN_KEY_PREFIX}${tokenHash}`, userId, ttlSeconds);
await cacheService.set(`${USER_KEY_PREFIX}${userId}`, tokenHash, ttlSeconds);
}
async resolve(tokenHash: string): Promise<string | null> {
return cacheService.get<string>(`${TOKEN_KEY_PREFIX}${tokenHash}`);
}
/** Single-use (FR-002/SC-002): deletes both keys for this token/user pair. */
async consume(tokenHash: string, userId: string): Promise<void> {
await cacheService.del(`${TOKEN_KEY_PREFIX}${tokenHash}`);
await cacheService.del(`${USER_KEY_PREFIX}${userId}`);
}
}
export const resetTokenRepository = new ResetTokenRepository();
@@ -11,4 +11,12 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/auth/logout', { preHandler: fastify.authenticate }, (req, reply) => fastify.post('/auth/logout', { preHandler: fastify.authenticate }, (req, reply) =>
authController.handleLogout(req, reply), authController.handleLogout(req, reply),
); );
// 013-auth-hardening: ungated, like login itself — the caller has no session yet.
fastify.post('/auth/password-reset/request', (req, reply) =>
authController.requestPasswordReset(req, reply),
);
fastify.post('/auth/password-reset/consume', (req, reply) =>
authController.resetPassword(req, reply),
);
} }
@@ -8,3 +8,21 @@ export const loginSchema = z
.strict(); .strict();
export type LoginBody = z.infer<typeof loginSchema>; export type LoginBody = z.infer<typeof loginSchema>;
/** 013-auth-hardening */
export const requestPasswordResetSchema = z
.object({
email: z.string().email(),
})
.strict();
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetSchema>;
export const resetPasswordSchema = z
.object({
token: z.string().min(1),
newPassword: z.string().min(1),
})
.strict();
export type ResetPasswordBody = z.infer<typeof resetPasswordSchema>;
@@ -1,8 +1,23 @@
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import { AuthenticationError } from '@/common/errors'; import { AppError, AuthenticationError, RateLimitError } from '@/common/errors';
import { revokeToken } from '@/infrastructure/cache'; import { checkRateLimit, revokeToken } from '@/infrastructure/cache';
import { authRepository, AuthRepository } from '../repository'; import { logger } from '@/infrastructure/observability';
import { verifyPassword, signToken, verifyToken } from '../mapper'; import { authConfig } from '@/config';
import {
authRepository,
AuthRepository,
resetTokenRepository,
ResetTokenRepository,
} from '../repository';
import {
verifyPassword,
signToken,
verifyToken,
hashPassword,
generateResetToken,
hashResetToken,
validatePasswordStrength,
} from '../mapper';
import { LoginBody } from '../schema'; import { LoginBody } from '../schema';
export interface LoginResult { export interface LoginResult {
@@ -15,14 +30,29 @@ function toPublicUser(user: User): LoginResult['user'] {
} }
export class AuthService { export class AuthService {
constructor(private readonly repo: AuthRepository = authRepository) {} constructor(
private readonly repo: AuthRepository = authRepository,
private readonly resetTokens: ResetTokenRepository = resetTokenRepository,
) {}
/** /**
* FR-002/SC-003: every failure branch (no such email, inactive account, wrong password) * FR-002/SC-003: every failure branch (no such email, inactive account, wrong password)
* throws the identical AuthenticationError — bcrypt.compare always runs exactly once, * throws the identical AuthenticationError — bcrypt.compare always runs exactly once,
* against a fixed dummy hash when no user is found, so timing never leaks which branch fired. * against a fixed dummy hash when no user is found, so timing never leaks which branch fired.
* 013-auth-hardening FR-006/FR-007: the rate-limit check runs first, before any credential
* work — a rate-limited attempt never reaches (and can't distinguish itself via timing from)
* the identical-failure-response path below.
*/ */
async login(body: LoginBody): Promise<LoginResult> { async login(body: LoginBody): Promise<LoginResult> {
const rateLimit = await checkRateLimit(
`login:${body.email}`,
authConfig.loginRateLimitMaxAttempts,
authConfig.loginRateLimitWindowSeconds,
);
if (!rateLimit.allowed) {
throw new RateLimitError('Too many login attempts. Try again later.');
}
const user = await this.repo.findByEmail(body.email); const user = await this.repo.findByEmail(body.email);
const passwordMatches = await verifyPassword(body.password, user?.passwordHash ?? null); const passwordMatches = await verifyPassword(body.password, user?.passwordHash ?? null);
@@ -46,6 +76,59 @@ export class AuthService {
const remainingSeconds = Math.max(1, (payload.exp ?? 0) - Math.floor(Date.now() / 1000)); const remainingSeconds = Math.max(1, (payload.exp ?? 0) - Math.floor(Date.now() / 1000));
await revokeToken(payload.jti, remainingSeconds); await revokeToken(payload.jti, remainingSeconds);
} }
/**
* 013-auth-hardening FR-001/SC-001: always resolves the same way regardless of whether the
* email corresponds to a real, active account — only issues a real token when it does. The
* "delivery" step is a stubbed structured log line (research.md), not a real email.
*/
async requestPasswordReset(email: string): Promise<void> {
const user = await this.repo.findByEmail(email);
if (user && user.active) {
const { token, tokenHash } = generateResetToken();
await this.resetTokens.issue(
user.id,
tokenHash,
authConfig.passwordResetTokenLifetimeMinutes * 60,
);
logger.info(
{
event: 'password_reset_requested',
userId: user.id,
resetUrl: `/reset-password?token=${token}`,
},
'Password reset requested — stubbed delivery (013-auth-hardening research.md): no real ' +
'email is sent yet, this log line is the only place the token is visible.',
);
}
// Same outcome either way (FR-001) — no branch here reveals which case fired.
}
/**
* 013-auth-hardening FR-004/FR-005: password strength is checked before the token is even
* looked up (data-model.md); the token itself is single-use (SC-002) — resolving and
* consuming it happen together so a second attempt with the same token always fails.
* Edge Cases: a token issued for an account later deactivated is rejected — reactivation is
* 010's own admin domain, not something this flow performs incidentally.
*/
async resetPassword(token: string, newPassword: string): Promise<void> {
validatePasswordStrength(newPassword);
const tokenHash = hashResetToken(token);
const userId = await this.resetTokens.resolve(tokenHash);
if (!userId) {
throw new AppError('Invalid or expired reset token.', 'INVALID_RESET_TOKEN', 400);
}
await this.resetTokens.consume(tokenHash, userId);
const user = await this.repo.findActiveById(userId);
if (!user) {
throw new AppError('Invalid or expired reset token.', 'INVALID_RESET_TOKEN', 400);
}
const passwordHash = await hashPassword(newPassword);
await this.repo.updatePassword(userId, passwordHash);
}
} }
export const authService = new AuthService(); export const authService = new AuthService();
+12 -8
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'crypto';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
@@ -6,20 +7,23 @@ const TEST_PASSWORD = 'Test-Password-123!';
/** /**
* 010-identity-auth made fastify.authenticate real — every test file calling a route already * 010-identity-auth made fastify.authenticate real — every test file calling a route already
* gated by it (across 002-009's own suites) needs a real session now. Rather than depend on * gated by it (across 002-009's own suites) needs a real session now. This creates its own
* throwaway admin/agent account directly and logs in as it, so callers don't depend on
* prisma/seed/roles.seed.ts having already been run against whatever database the suite * prisma/seed/roles.seed.ts having already been run against whatever database the suite
* connects to, this upserts its own throwaway admin/agent account directly (idempotent — safe * connects to.
* to call from many test files' own beforeAll against the same database) and logs in as it. *
* 013-auth-hardening: the email is unique per call (not a fixed `test-admin@...` shared across
* every integration test file) because login is now rate-limited per email — dozens of files
* each calling this once in their own beforeAll would otherwise share one rate-limit bucket and
* trip it well before any file's own tests get to run.
*/ */
export async function loginAs( export async function loginAs(
app: FastifyInstance, app: FastifyInstance,
role: 'ADMIN' | 'AGENT' = 'ADMIN', role: 'ADMIN' | 'AGENT' = 'ADMIN',
): Promise<string> { ): Promise<string> {
const email = `test-${role.toLowerCase()}@supporthub.test`; const email = `test-${role.toLowerCase()}-${randomUUID()}@supporthub.test`;
await prismaClient.user.upsert({ await prismaClient.user.create({
where: { email }, data: {
update: {},
create: {
email, email,
name: `Test ${role}`, name: `Test ${role}`,
role, role,
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { authConfig } from '@/config';
/**
* Covers specs/013-auth-hardening/quickstart.md Scenario 3 against a real Postgres/Redis.
*/
describe('Login rate limiting (User Story 3)', () => {
let app: FastifyInstance;
const suffix = Date.now();
const email = `rate-limit-test-${suffix}@supporthub.test`;
const otherEmail = `rate-limit-other-${suffix}@supporthub.test`;
const correctPassword = 'Correct-Password-1!';
let userId: string;
let otherUserId: string;
beforeAll(async () => {
app = await buildApp();
const user = await prismaClient.user.create({
data: {
email,
name: 'Rate Limit Test User',
role: 'AGENT',
passwordHash: await bcrypt.hash(correctPassword, 10),
},
});
userId = user.id;
const otherUser = await prismaClient.user.create({
data: {
email: otherEmail,
name: 'Rate Limit Other User',
role: 'AGENT',
passwordHash: await bcrypt.hash(correctPassword, 10),
},
});
otherUserId = otherUser.id;
});
afterAll(async () => {
await prismaClient.user.deleteMany({ where: { id: { in: [userId, otherUserId] } } });
await app.close();
});
it('blocks the same email after its attempt budget is exhausted, without affecting other emails', async () => {
for (let i = 0; i < authConfig.loginRateLimitMaxAttempts; i++) {
const res = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: 'definitely-wrong' },
});
expect(res.statusCode).toBe(401);
}
// One more attempt for the same email, this time with the CORRECT password — still 429.
const blockedRes = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: correctPassword },
});
expect(blockedRes.statusCode).toBe(429);
// A different email in the same window is unaffected.
const otherRes = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: otherEmail, password: correctPassword },
});
expect(otherRes.statusCode).toBe(200);
});
});
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { logger } from '@/infrastructure/observability';
/**
* Covers specs/013-auth-hardening/quickstart.md Scenario 1 against a real Postgres/Redis — the
* full request -> (read the token from the stub's own log line) -> consume -> login-with-new-
* password flow, and the identical-response-regardless-of-existing-account behavior.
*/
describe('Password reset flow (User Story 1)', () => {
let app: FastifyInstance;
const suffix = Date.now();
const email = `reset-test-${suffix}@supporthub.test`;
const originalPassword = 'Original-Password-1!';
const newPassword = 'Brand-New-Password-2!';
let userId: string;
beforeAll(async () => {
app = await buildApp();
const user = await prismaClient.user.create({
data: {
email,
name: 'Reset Test User',
role: 'AGENT',
passwordHash: await bcrypt.hash(originalPassword, 10),
},
});
userId = user.id;
});
afterAll(async () => {
await prismaClient.user.deleteMany({ where: { id: userId } });
await app.close();
});
function extractLoggedToken(): string {
const infoSpy = vi.mocked(logger.info);
const call = infoSpy.mock.calls.find(
([data]) => (data as { event?: string }).event === 'password_reset_requested',
);
if (!call) throw new Error('Expected a password_reset_requested log line, but none was found.');
const resetUrl = (call[0] as unknown as { resetUrl: string }).resetUrl;
const token = new URL(resetUrl, 'http://localhost').searchParams.get('token');
if (!token) throw new Error('Expected the logged resetUrl to carry a token query param.');
return token;
}
it('Scenario 1: request -> stub-logged token -> consume -> login with the new password', async () => {
const infoSpy = vi.spyOn(logger, 'info');
const requestRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/request',
payload: { email },
});
expect(requestRes.statusCode).toBe(200);
expect(requestRes.json().data.message).not.toMatch(/token|[a-f0-9]{64}/i);
const nonexistentRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/request',
payload: { email: `nobody-${suffix}@supporthub.test` },
});
expect(nonexistentRes.statusCode).toBe(200);
expect(nonexistentRes.json()).toEqual(requestRes.json());
const token = extractLoggedToken();
const consumeRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/consume',
payload: { token, newPassword },
});
expect(consumeRes.statusCode).toBe(200);
// Single-use — the same token fails a second time.
const secondConsumeRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/consume',
payload: { token, newPassword: 'Another-Password-3!' },
});
expect(secondConsumeRes.statusCode).toBe(400);
const loginWithNew = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: newPassword },
});
expect(loginWithNew.statusCode).toBe(200);
const loginWithOld = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: originalPassword },
});
expect(loginWithOld.statusCode).toBe(401);
infoSpy.mockRestore();
});
it('rejects an invalid token outright', async () => {
const res = await app.inject({
method: 'POST',
url: '/auth/password-reset/consume',
payload: { token: 'not-a-real-token', newPassword: 'Whatever-Password-1!' },
});
expect(res.statusCode).toBe(400);
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { AuthService } from '@/modules/identity/auth/service/auth.service'; import { AuthService } from '@/modules/identity/auth/service/auth.service';
import * as cache from '@/infrastructure/cache';
const REAL_PASSWORD_HASH = bcrypt.hashSync('the-real-password', 10); const REAL_PASSWORD_HASH = bcrypt.hashSync('the-real-password', 10);
@@ -19,6 +20,10 @@ function fakeUser(overrides: Partial<Record<string, unknown>> = {}) {
} }
describe('AuthService.login failure parity', () => { describe('AuthService.login failure parity', () => {
beforeEach(() => {
vi.spyOn(cache, 'checkRateLimit').mockResolvedValue({ allowed: true, count: 1 });
});
it('throws the identical error for a nonexistent email and a wrong password', async () => { it('throws the identical error for a nonexistent email and a wrong password', async () => {
const repoFoundUser = { findByEmail: vi.fn().mockResolvedValue(fakeUser()) } as never; const repoFoundUser = { findByEmail: vi.fn().mockResolvedValue(fakeUser()) } as never;
const repoNoUser = { findByEmail: vi.fn().mockResolvedValue(null) } as never; const repoNoUser = { findByEmail: vi.fn().mockResolvedValue(null) } as never;
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthService } from '@/modules/identity/auth/service/auth.service';
import * as cache from '@/infrastructure/cache';
describe('AuthService.login rate-limit ordering (User Story 3)', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('checks the rate limit before ever looking up the account', async () => {
const findByEmail = vi.fn().mockResolvedValue(null);
const repo = { findByEmail } as never;
const service = new AuthService(repo);
vi.spyOn(cache, 'checkRateLimit').mockResolvedValue({ allowed: false, count: 6 });
await expect(
service.login({ email: 'agent@example.com', password: 'anything' }),
).rejects.toMatchObject({ statusCode: 429 });
expect(findByEmail).not.toHaveBeenCalled();
});
it('proceeds to credential checks once the rate limit allows the attempt', async () => {
const findByEmail = vi.fn().mockResolvedValue(null);
const repo = { findByEmail } as never;
const service = new AuthService(repo);
vi.spyOn(cache, 'checkRateLimit').mockResolvedValue({ allowed: true, count: 1 });
await expect(
service.login({ email: 'agent@example.com', password: 'anything' }),
).rejects.toMatchObject({ statusCode: 401 });
expect(findByEmail).toHaveBeenCalledWith('agent@example.com');
});
});
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { validatePasswordStrength } from '@/modules/identity/auth/mapper/password-policy';
import { authConfig } from '@/config';
describe('validatePasswordStrength', () => {
it('rejects a password shorter than the configured minimum, naming the actual requirement', () => {
const tooShort = 'a'.repeat(authConfig.passwordMinLength - 1);
expect(() => validatePasswordStrength(tooShort)).toThrowError(
`Password must be at least ${authConfig.passwordMinLength} characters.`,
);
});
it('accepts a password meeting the configured minimum', () => {
const meetsPolicy = 'a'.repeat(authConfig.passwordMinLength);
expect(() => validatePasswordStrength(meetsPolicy)).not.toThrow();
});
});