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>
135 lines
5.0 KiB
TypeScript
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();
|