diff --git a/src/identity/access.decorator.ts b/src/identity/access.decorator.ts new file mode 100644 index 0000000..add49b7 --- /dev/null +++ b/src/identity/access.decorator.ts @@ -0,0 +1,17 @@ +import { + SetMetadata, + createParamDecorator, + ExecutionContext, +} from '@nestjs/common'; +import type { AuthenticatedRequest } from './identity.types'; +import type { Permission } from './permissions'; + +export const PUBLIC_ROUTE = Symbol('PUBLIC_ROUTE'); +export const REQUIRED_PERMISSION = Symbol('REQUIRED_PERMISSION'); +export const Public = () => SetMetadata(PUBLIC_ROUTE, true); +export const RequirePermission = (permission: Permission) => + SetMetadata(REQUIRED_PERMISSION, permission); +export const CurrentPrincipal = createParamDecorator( + (_: unknown, context: ExecutionContext) => + context.switchToHttp().getRequest().principal, +); diff --git a/src/identity/access.guard.ts b/src/identity/access.guard.ts new file mode 100644 index 0000000..922bb06 --- /dev/null +++ b/src/identity/access.guard.ts @@ -0,0 +1,37 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { SessionStore } from './session.store'; +import { PUBLIC_ROUTE, REQUIRED_PERMISSION } from './access.decorator'; +import type { AuthenticatedRequest } from './identity.types'; + +@Injectable() +export class AccessGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly sessions: SessionStore, + ) {} + async canActivate(context: ExecutionContext): Promise { + const targets = [context.getHandler(), context.getClass()]; + if (this.reflector.getAllAndOverride(PUBLIC_ROUTE, targets)) + return true; + const request = context.switchToHttp().getRequest(); + const match = /^Bearer ([A-Za-z0-9_-]{43})$/.exec( + request.headers.authorization ?? '', + ); + if (!match) throw new UnauthorizedException(); + request.principal = await this.sessions.authenticate(match[1]); + const permission = this.reflector.getAllAndOverride( + REQUIRED_PERMISSION, + targets, + ); + if (permission && !request.principal.permissions.includes(permission)) + throw new ForbiddenException(); + return true; + } +} diff --git a/src/identity/auth-rate.guard.ts b/src/identity/auth-rate.guard.ts new file mode 100644 index 0000000..0190289 --- /dev/null +++ b/src/identity/auth-rate.guard.ts @@ -0,0 +1,14 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import type { Request } from 'express'; +import { RateLimitService } from './rate-limit.service'; + +@Injectable() +export class AuthRateGuard implements CanActivate { + constructor(private readonly limits: RateLimitService) {} + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + // Express proxy trust remains disabled; never trust arbitrary forwarded IP headers. + await this.limits.consume(`auth-ip:${request.ip}`, 30, 60); + return true; + } +} diff --git a/src/identity/auth.controller.ts b/src/identity/auth.controller.ts new file mode 100644 index 0000000..b6f0848 --- /dev/null +++ b/src/identity/auth.controller.ts @@ -0,0 +1,50 @@ +import { + Body, + Controller, + Get, + Header, + HttpCode, + Post, + UseGuards, +} from '@nestjs/common'; +import { SchemaPipe } from '../common/validation.pipe'; +import { AuthService } from './auth.service'; +import { AuthStore } from './auth.store'; +import { AuthRateGuard } from './auth-rate.guard'; +import { CurrentPrincipal, Public } from './access.decorator'; +import { loginSchema, type LoginInput } from './identity.schemas'; +import type { Principal } from './identity.types'; + +@Controller('auth') +@UseGuards(AuthRateGuard) +export class AuthController { + constructor( + private readonly auth: AuthService, + private readonly store: AuthStore, + ) {} + @Public() + @Post('login') + @HttpCode(200) + @Header('Cache-Control', 'no-store') + login(@Body(new SchemaPipe(loginSchema)) input: LoginInput) { + return this.auth.login(input); + } + + @Get('me') + @Header('Cache-Control', 'no-store') + me(@CurrentPrincipal() principal: Principal) { + return principal; + } + + @Post('logout') + @HttpCode(204) + logout(@CurrentPrincipal() principal: Principal) { + return this.store.logout(principal, false); + } + + @Post('logout-all') + @HttpCode(204) + logoutAll(@CurrentPrincipal() principal: Principal) { + return this.store.logout(principal, true); + } +} diff --git a/src/identity/auth.service.ts b/src/identity/auth.service.ts new file mode 100644 index 0000000..f48eedb --- /dev/null +++ b/src/identity/auth.service.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ENVIRONMENT } from '../config/environment.module'; +import type { Environment } from '../config/environment'; +import { AuthStore } from './auth.store'; +import { PasswordService } from './password.service'; +import { RateLimitService } from './rate-limit.service'; +import { issueToken } from './tokens'; +import type { LoginInput } from './identity.schemas'; + +@Injectable() +export class AuthService { + constructor( + private readonly store: AuthStore, + private readonly passwords: PasswordService, + private readonly limits: RateLimitService, + @Inject(ENVIRONMENT) private readonly env: Environment, + ) {} + async login(input: LoginInput) { + await this.limits.consume( + `login:${input.organizationId}:${input.email}`, + 10, + 900, + ); + const user = await this.store.findUser(input.organizationId, input.email); + const valid = user + ? await this.passwords.verify(input.password, user.passwordHash) + : (await this.passwords.dummyVerify(input.password), false); + if (!valid || !user || user.status !== 'ACTIVE') { + if (user) await this.store.failedLogin(user.organizationId, user.id); + throw new UnauthorizedException('Invalid credentials'); + } + const { token, tokenHash } = issueToken(); + const expiresAt = new Date( + Date.now() + this.env.SESSION_TTL_MINUTES * 60_000, + ); + await this.store.createSession( + user.id, + user.passwordHash, + tokenHash, + expiresAt, + ); + return { accessToken: token, tokenType: 'Bearer', expiresAt }; + } +} diff --git a/src/identity/auth.store.ts b/src/identity/auth.store.ts new file mode 100644 index 0000000..4683605 --- /dev/null +++ b/src/identity/auth.store.ts @@ -0,0 +1,57 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { recordAudit } from './audit'; +import type { Principal } from './identity.types'; + +@Injectable() +export class AuthStore { + constructor(private readonly db: DatabaseService) {} + findUser(organizationId: string, email: string) { + return this.db.user.findUnique({ + where: { organizationId_email: { organizationId, email } }, + }); + } + async createSession( + userId: string, + expectedHash: string, + tokenHash: string, + expiresAt: Date, + ) { + return this.db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId}::uuid FOR UPDATE`; + const user = await tx.user.findUniqueOrThrow({ where: { id: userId } }); + if (user.status !== 'ACTIVE' || user.passwordHash !== expectedHash) + throw new UnauthorizedException(); + await tx.session.create({ data: { userId, tokenHash, expiresAt } }); + await recordAudit( + tx, + user.organizationId, + user.id, + 'auth.login', + user.id, + ); + }); + } + async logout(principal: Principal, all: boolean) { + await this.db.$transaction(async (tx) => { + await tx.session.deleteMany({ + where: all ? { userId: principal.userId } : { id: principal.sessionId }, + }); + await recordAudit( + tx, + principal.organizationId, + principal.userId, + all ? 'auth.logout_all' : 'auth.logout', + ); + }); + } + async failedLogin(organizationId: string, userId: string) { + await recordAudit( + this.db, + organizationId, + userId, + 'auth.login_failed', + userId, + ); + } +} diff --git a/src/identity/bootstrap.service.ts b/src/identity/bootstrap.service.ts new file mode 100644 index 0000000..63d9172 --- /dev/null +++ b/src/identity/bootstrap.service.ts @@ -0,0 +1,59 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { PasswordService } from './password.service'; +import { PERMISSIONS } from './permissions'; +import { recordAudit } from './audit'; +import type { CreateUserInput } from './identity.schemas'; + +@Injectable() +export class BootstrapService { + constructor( + private readonly db: DatabaseService, + private readonly passwords: PasswordService, + ) {} + async createOwner(organizationName: string, input: CreateUserInput) { + const passwordHash = await this.passwords.hash(input.password); + return this.db.$transaction(async (tx) => { + // One-time installation bootstrap, serialized across processes. + await tx.$queryRaw`SELECT pg_advisory_xact_lock(74192001)::text`; + if (await tx.user.count({ where: { isOwner: true } })) + throw new ConflictException('Owner already exists'); + const organization = await tx.organization.create({ + data: { name: organizationName }, + }); + const role = await tx.role.create({ + data: { + organizationId: organization.id, + name: 'Owner', + isSystem: true, + permissions: [...PERMISSIONS], + }, + }); + const user = await tx.user.create({ + data: { + organizationId: organization.id, + email: input.email, + name: input.name, + passwordHash, + isOwner: true, + status: 'ACTIVE', + }, + }); + await tx.userRole.create({ + data: { + userId: user.id, + roleId: role.id, + organizationId: organization.id, + }, + }); + await recordAudit( + tx, + organization.id, + user.id, + 'installation.bootstrapped', + user.id, + ); + return { organizationId: organization.id, userId: user.id }; + }); + } +} diff --git a/src/identity/rate-limit.service.ts b/src/identity/rate-limit.service.ts new file mode 100644 index 0000000..3bed16c --- /dev/null +++ b/src/identity/rate-limit.service.ts @@ -0,0 +1,25 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { hashToken } from './tokens'; + +@Injectable() +export class RateLimitService { + constructor(private readonly db: DatabaseService) {} + async consume(key: string, limit: number, seconds: number): Promise { + const digest = hashToken(key); + const [row] = await this.db.$queryRaw>` + INSERT INTO rate_limits (key, hits, expires_at) + VALUES (${digest}, 1, NOW() + ${seconds} * INTERVAL '1 second') + ON CONFLICT (key) DO UPDATE SET + hits = CASE WHEN rate_limits.expires_at <= NOW() THEN 1 ELSE rate_limits.hits + 1 END, + expires_at = CASE WHEN rate_limits.expires_at <= NOW() + THEN NOW() + ${seconds} * INTERVAL '1 second' ELSE rate_limits.expires_at END + RETURNING hits + `; + if (row.hits > limit) + throw new HttpException( + 'Too many requests', + HttpStatus.TOO_MANY_REQUESTS, + ); + } +} diff --git a/src/identity/recovery-mailer.ts b/src/identity/recovery-mailer.ts new file mode 100644 index 0000000..7cbead6 --- /dev/null +++ b/src/identity/recovery-mailer.ts @@ -0,0 +1,47 @@ +import { + Inject, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; +import { createTransport } from 'nodemailer'; +import { ENVIRONMENT } from '../config/environment.module'; +import type { Environment } from '../config/environment'; + +@Injectable() +export class RecoveryMailer { + constructor(@Inject(ENVIRONMENT) private readonly env: Environment) {} + assertConfigured(): void { + if (!this.env.SMTP_HOST) + throw new ServiceUnavailableException( + 'Password recovery is not configured', + ); + } + async send(email: string, token: string): Promise { + this.assertConfigured(); + const transport = createTransport({ + host: this.env.SMTP_HOST, + port: this.env.SMTP_PORT, + secure: this.env.SMTP_PORT === 465, + requireTLS: true, + auth: { user: this.env.SMTP_USER!, pass: this.env.SMTP_PASSWORD! }, + connectionTimeout: 5000, + greetingTimeout: 5000, + socketTimeout: 10000, + disableFileAccess: true, + disableUrlAccess: true, + }); + const url = new URL(this.env.RECOVERY_URL!); + // Fragment avoids putting the secret into ordinary HTTP access logs. + url.hash = new URLSearchParams({ token }).toString(); + try { + await transport.sendMail({ + from: this.env.SMTP_FROM!, + to: email, + subject: 'Reset your Mani Candles password', + text: `A password reset was requested for your account. Open ${url.toString()} within ${this.env.RECOVERY_TTL_MINUTES} minutes. If this was not you, ignore this message.`, + }); + } finally { + transport.close(); + } + } +} diff --git a/src/identity/recovery.controller.ts b/src/identity/recovery.controller.ts new file mode 100644 index 0000000..4d79e9a --- /dev/null +++ b/src/identity/recovery.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { SchemaPipe } from '../common/validation.pipe'; +import { Public } from './access.decorator'; +import { AuthRateGuard } from './auth-rate.guard'; +import { RecoveryService } from './recovery.service'; +import { + recoveryRequestSchema, + recoveryResetSchema, + type RecoveryInput, +} from './identity.schemas'; + +@Public() +@UseGuards(AuthRateGuard) +@Controller('auth/recovery') +export class RecoveryController { + constructor(private readonly recovery: RecoveryService) {} + @Post('request') + @HttpCode(202) + async request( + @Body(new SchemaPipe(recoveryRequestSchema)) input: RecoveryInput, + ) { + await this.recovery.request(input); + return { + message: + 'If the account is eligible, recovery instructions will be sent.', + }; + } + @Post('reset') + @HttpCode(204) + reset( + @Body(new SchemaPipe(recoveryResetSchema)) + input: { + token: string; + password: string; + }, + ) { + return this.recovery.reset(input.token, input.password); + } +} diff --git a/src/identity/recovery.service.ts b/src/identity/recovery.service.ts new file mode 100644 index 0000000..ac29f74 --- /dev/null +++ b/src/identity/recovery.service.ts @@ -0,0 +1,53 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ENVIRONMENT } from '../config/environment.module'; +import type { Environment } from '../config/environment'; +import { AuthStore } from './auth.store'; +import { RecoveryStore } from './recovery.store'; +import { RecoveryMailer } from './recovery-mailer'; +import { RateLimitService } from './rate-limit.service'; +import { PasswordService } from './password.service'; +import { hashToken, issueToken } from './tokens'; +import type { RecoveryInput } from './identity.schemas'; + +@Injectable() +export class RecoveryService { + private readonly logger = new Logger(RecoveryService.name); + constructor( + private readonly users: AuthStore, + private readonly store: RecoveryStore, + private readonly mailer: RecoveryMailer, + private readonly limits: RateLimitService, + private readonly passwords: PasswordService, + @Inject(ENVIRONMENT) private readonly env: Environment, + ) {} + async request(input: RecoveryInput): Promise { + this.mailer.assertConfigured(); + await this.limits.consume( + `recovery:${input.organizationId}:${input.email}`, + 3, + 900, + ); + const user = await this.users.findUser(input.organizationId, input.email); + if (!user || user.status !== 'ACTIVE') return; + const { token, tokenHash } = issueToken(); + const created = await this.store.create( + user.id, + tokenHash, + new Date(Date.now() + this.env.RECOVERY_TTL_MINUTES * 60_000), + ); + if (!created) return; + try { + await this.mailer.send(user.email, token); + } catch { + await this.store.discard(tokenHash); + // SMTP errors may contain credentials and addresses; do not log the raw error. + this.logger.error('Password recovery delivery failed'); + } + } + async reset(token: string, password: string): Promise { + await this.store.reset( + hashToken(token), + await this.passwords.hash(password), + ); + } +} diff --git a/src/identity/recovery.store.ts b/src/identity/recovery.store.ts new file mode 100644 index 0000000..a20bb97 --- /dev/null +++ b/src/identity/recovery.store.ts @@ -0,0 +1,59 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { recordAudit } from './audit'; + +@Injectable() +export class RecoveryStore { + constructor(private readonly db: DatabaseService) {} + async create( + userId: string, + tokenHash: string, + expiresAt: Date, + ): Promise { + return this.db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId}::uuid FOR UPDATE`; + const user = await tx.user.findUniqueOrThrow({ where: { id: userId } }); + if (user.status !== 'ACTIVE') return false; + await tx.recoveryToken.deleteMany({ where: { userId } }); + await tx.recoveryToken.create({ data: { userId, tokenHash, expiresAt } }); + await recordAudit( + tx, + user.organizationId, + null, + 'auth.recovery_requested', + userId, + ); + return true; + }); + } + async discard(tokenHash: string) { + await this.db.recoveryToken.deleteMany({ where: { tokenHash } }); + } + async reset(tokenHash: string, passwordHash: string) { + await this.db.$transaction(async (tx) => { + const token = await tx.recoveryToken.findUnique({ where: { tokenHash } }); + if (!token) + throw new BadRequestException('Invalid or expired recovery token'); + await tx.$queryRaw`SELECT id FROM users WHERE id = ${token.userId}::uuid FOR UPDATE`; + const user = await tx.user.findUniqueOrThrow({ + where: { id: token.userId }, + }); + const consumed = await tx.recoveryToken.deleteMany({ + where: { id: token.id, expiresAt: { gt: new Date() } }, + }); + if (consumed.count !== 1 || user.status !== 'ACTIVE') { + throw new BadRequestException('Invalid or expired recovery token'); + } + await tx.user.update({ where: { id: user.id }, data: { passwordHash } }); + await tx.recoveryToken.deleteMany({ where: { userId: user.id } }); + await tx.session.deleteMany({ where: { userId: user.id } }); + await recordAudit( + tx, + user.organizationId, + user.id, + 'auth.password_reset', + user.id, + ); + }); + } +} diff --git a/src/identity/session.store.ts b/src/identity/session.store.ts new file mode 100644 index 0000000..c6ef6bc --- /dev/null +++ b/src/identity/session.store.ts @@ -0,0 +1,42 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import type { Prisma } from '../generated/prisma/client'; +import { hashToken } from './tokens'; +import type { Principal } from './identity.types'; + +export const userAccess = { roles: { include: { role: true } } } as const; +export async function readPrincipal( + tx: Prisma.TransactionClient, + sessionId: string, +): Promise { + const session = await tx.session.findUnique({ + where: { id: sessionId }, + include: { user: { include: userAccess } }, + }); + if ( + !session || + session.expiresAt <= new Date() || + session.user.status !== 'ACTIVE' + ) { + throw new UnauthorizedException(); + } + return { + userId: session.userId, + organizationId: session.user.organizationId, + sessionId, + permissions: [ + ...new Set(session.user.roles.flatMap((item) => item.role.permissions)), + ], + }; +} +@Injectable() +export class SessionStore { + constructor(private readonly db: DatabaseService) {} + async authenticate(token: string): Promise { + const session = await this.db.session.findUnique({ + where: { tokenHash: hashToken(token) }, + }); + if (!session) throw new UnauthorizedException(); + return readPrincipal(this.db, session.id); + } +} diff --git a/test/auth-service.spec.ts b/test/auth-service.spec.ts new file mode 100644 index 0000000..64d994c --- /dev/null +++ b/test/auth-service.spec.ts @@ -0,0 +1,68 @@ +import { AuthService } from '../src/identity/auth.service'; +import { AuthStore } from '../src/identity/auth.store'; +import { PasswordService } from '../src/identity/password.service'; +import { RateLimitService } from '../src/identity/rate-limit.service'; +import { parseEnvironment } from '../src/config/environment'; + +describe('login policy', () => { + const store = { + findUser: jest.fn(), + createSession: jest.fn(), + failedLogin: jest.fn(), + }; + const passwords = { verify: jest.fn(), dummyVerify: jest.fn() }; + const limits = { consume: jest.fn() }; + const service = new AuthService( + store as unknown as AuthStore, + passwords as unknown as PasswordService, + limits as unknown as RateLimitService, + parseEnvironment({ DATABASE_URL: 'postgresql://localhost/mani' }), + ); + const input = { + organizationId: 'organization', + email: 'user@example.com', + password: 'password', + }; + beforeEach(() => { + jest.resetAllMocks(); + store.findUser.mockResolvedValue({ + id: 'user', + organizationId: 'organization', + status: 'ACTIVE', + passwordHash: 'hash', + }); + passwords.verify.mockResolvedValue(true); + }); + it('passes only the token digest to persistence', async () => { + const result = await service.login(input); + expect(store.createSession).toHaveBeenCalledWith( + 'user', + 'hash', + expect.stringMatching(/^[a-f0-9]{64}$/), + expect.any(Date), + ); + expect(JSON.stringify(store.createSession.mock.calls)).not.toContain( + result.accessToken, + ); + }); + it('does not create sessions for unapproved users', async () => { + store.findUser.mockResolvedValue({ + id: 'user', + status: 'PENDING', + organizationId: 'organization', + passwordHash: 'hash', + }); + await expect(service.login(input)).rejects.toThrow('Invalid credentials'); + expect(store.createSession).not.toHaveBeenCalled(); + }); + it('does equivalent hashing work for unknown users', async () => { + store.findUser.mockResolvedValue(null); + await expect(service.login(input)).rejects.toThrow('Invalid credentials'); + expect(passwords.dummyVerify).toHaveBeenCalledWith(input.password); + }); + it('stops before querying users when throttled', async () => { + limits.consume.mockRejectedValue(new Error('limited')); + await expect(service.login(input)).rejects.toThrow('limited'); + expect(store.findUser).not.toHaveBeenCalled(); + }); +}); diff --git a/test/recovery-mailer.spec.ts b/test/recovery-mailer.spec.ts new file mode 100644 index 0000000..7b885d1 --- /dev/null +++ b/test/recovery-mailer.spec.ts @@ -0,0 +1,66 @@ +import { createTransport } from 'nodemailer'; +import { RecoveryMailer } from '../src/identity/recovery-mailer'; +import { parseEnvironment } from '../src/config/environment'; + +jest.mock('nodemailer', () => ({ createTransport: jest.fn() })); +describe('recovery SMTP adapter', () => { + const sendMail = jest.fn(); + const close = jest.fn(); + const env = parseEnvironment({ + DATABASE_URL: 'postgresql://localhost/mani', + SMTP_HOST: 'smtp.example.com', + SMTP_USER: 'user', + SMTP_PASSWORD: 'secret', + SMTP_FROM: 'support@example.com', + RECOVERY_URL: 'https://shop.example.com/reset', + }); + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(createTransport) + .mockReturnValue({ sendMail, close } as unknown as ReturnType< + typeof createTransport + >); + }); + it('fails explicitly when recovery is not configured', () => { + expect(() => + new RecoveryMailer( + parseEnvironment({ DATABASE_URL: 'postgresql://localhost/mani' }), + ).assertConfigured(), + ).toThrow('not configured'); + }); + it('requires TLS and delivers a fragment-based reset link', async () => { + sendMail.mockResolvedValue({}); + await new RecoveryMailer(env).send('person@example.com', 'token'); + expect(createTransport).toHaveBeenCalledWith( + expect.objectContaining({ + requireTLS: true, + secure: false, + disableFileAccess: true, + disableUrlAccess: true, + }), + ); + expect(sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'person@example.com', + text: expect.stringContaining( + 'https://shop.example.com/reset#token=token', + ), + }), + ); + expect(close).toHaveBeenCalled(); + }); + it('closes transport even when sending fails and supports implicit TLS', async () => { + sendMail.mockRejectedValue(new Error('delivery failed')); + await expect( + new RecoveryMailer({ ...env, SMTP_PORT: 465 }).send( + 'person@example.com', + 'token', + ), + ).rejects.toThrow(); + expect(createTransport).toHaveBeenCalledWith( + expect.objectContaining({ secure: true }), + ); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/test/recovery-service.spec.ts b/test/recovery-service.spec.ts new file mode 100644 index 0000000..489687e --- /dev/null +++ b/test/recovery-service.spec.ts @@ -0,0 +1,65 @@ +import { Logger } from '@nestjs/common'; +import { RecoveryService } from '../src/identity/recovery.service'; +import { AuthStore } from '../src/identity/auth.store'; +import { RecoveryStore } from '../src/identity/recovery.store'; +import { RecoveryMailer } from '../src/identity/recovery-mailer'; +import { RateLimitService } from '../src/identity/rate-limit.service'; +import { PasswordService } from '../src/identity/password.service'; +import { parseEnvironment } from '../src/config/environment'; +import { hashToken } from '../src/identity/tokens'; + +describe('password recovery policy', () => { + const users = { findUser: jest.fn() }; + const store = { create: jest.fn(), discard: jest.fn(), reset: jest.fn() }; + const mailer = { assertConfigured: jest.fn(), send: jest.fn() }; + const limits = { consume: jest.fn() }; + const passwords = { hash: jest.fn() }; + const service = new RecoveryService( + users as unknown as AuthStore, + store as unknown as RecoveryStore, + mailer as unknown as RecoveryMailer, + limits as unknown as RateLimitService, + passwords as unknown as PasswordService, + parseEnvironment({ DATABASE_URL: 'postgresql://localhost/mani' }), + ); + const input = { organizationId: 'org', email: 'user@example.com' }; + beforeEach(() => { + jest.resetAllMocks(); + users.findUser.mockResolvedValue({ + id: 'user', + email: input.email, + status: 'ACTIVE', + }); + store.create.mockResolvedValue(true); + }); + afterEach(() => jest.restoreAllMocks()); + it.each([null, { status: 'PENDING' }, { status: 'SUSPENDED' }])( + 'does not disclose or deliver for ineligible accounts %j', + async (user) => { + users.findUser.mockResolvedValue(user); + await expect(service.request(input)).resolves.toBeUndefined(); + expect(mailer.send).not.toHaveBeenCalled(); + }, + ); + it('does not deliver if eligibility changes during the request', async () => { + store.create.mockResolvedValue(false); + await service.request(input); + expect(mailer.send).not.toHaveBeenCalled(); + }); + it('discards undelivered tokens without logging the raw SMTP error', async () => { + const logger = jest.spyOn(Logger.prototype, 'error').mockImplementation(); + mailer.send.mockRejectedValue(new Error('smtp-password-secret')); + await service.request(input); + expect(store.discard).toHaveBeenCalledWith( + expect.stringMatching(/^[a-f0-9]{64}$/), + ); + expect(JSON.stringify(logger.mock.calls)).not.toContain( + 'smtp-password-secret', + ); + }); + it('hashes both password and token before reset persistence', async () => { + passwords.hash.mockResolvedValue('hash'); + await service.reset('token', 'new password'); + expect(store.reset).toHaveBeenCalledWith(hashToken('token'), 'hash'); + }); +});