feat(auth): implement revocable sessions and single-use password recovery
This commit is contained in:
parent
c4c85eb180
commit
30776b9181
|
|
@ -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<AuthenticatedRequest>().principal,
|
||||
);
|
||||
|
|
@ -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<boolean> {
|
||||
const targets = [context.getHandler(), context.getClass()];
|
||||
if (this.reflector.getAllAndOverride<boolean>(PUBLIC_ROUTE, targets))
|
||||
return true;
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
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<string>(
|
||||
REQUIRED_PERMISSION,
|
||||
targets,
|
||||
);
|
||||
if (permission && !request.principal.permissions.includes(permission))
|
||||
throw new ForbiddenException();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
// Express proxy trust remains disabled; never trust arbitrary forwarded IP headers.
|
||||
await this.limits.consume(`auth-ip:${request.ip}`, 30, 60);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
const digest = hashToken(key);
|
||||
const [row] = await this.db.$queryRaw<Array<{ hits: number }>>`
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
await this.store.reset(
|
||||
hashToken(token),
|
||||
await this.passwords.hash(password),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<boolean> {
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Principal> {
|
||||
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<Principal> {
|
||||
const session = await this.db.session.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
});
|
||||
if (!session) throw new UnauthorizedException();
|
||||
return readPrincipal(this.db, session.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue