Files
support_backend/src/modules/identity/auth/service/auth.service.ts
T
saqib mirandClaude Sonnet 5 79bc2ef25b 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>
2026-09-07 21:22:49 +05:30

135 lines
5.0 KiB
TypeScript

import { User } from '@prisma/client';
import { AppError, AuthenticationError, RateLimitError } from '@/common/errors';
import { checkRateLimit, revokeToken } from '@/infrastructure/cache';
import { logger } from '@/infrastructure/observability';
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';
export interface LoginResult {
token: string;
user: { id: string; email: string; name: string; role: string };
}
function toPublicUser(user: User): LoginResult['user'] {
return { id: user.id, email: user.email, name: user.name, role: user.role };
}
export class AuthService {
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)
* 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.
* 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> {
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 passwordMatches = await verifyPassword(body.password, user?.passwordHash ?? null);
if (!user || !user.active || !passwordMatches) {
throw new AuthenticationError('Invalid email or password.');
}
const { token } = signToken(user);
return { token, user: toPublicUser(user) };
}
/** User Story 3: re-validated against current account state, not just the token's claims. */
async getCurrentUser(userId: string): Promise<LoginResult['user']> {
const user = await this.repo.findActiveById(userId);
if (!user) throw new AuthenticationError('Session is no longer valid.');
return toPublicUser(user);
}
async logout(token: string): Promise<void> {
const payload = verifyToken(token);
const remainingSeconds = Math.max(1, (payload.exp ?? 0) - Math.floor(Date.now() / 1000));
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();