feat(auth): implement revocable sessions and single-use password recovery

This commit is contained in:
mihir 2026-09-08 21:24:48 +05:30
parent c4c85eb180
commit 30776b9181
16 changed files with 742 additions and 0 deletions

View File

@ -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,
);

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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 };
}
}

View File

@ -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,
);
}
}

View File

@ -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 };
});
}
}

View File

@ -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,
);
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}
}

View File

@ -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),
);
}
}

View File

@ -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,
);
});
}
}

View File

@ -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);
}
}

68
test/auth-service.spec.ts Normal file
View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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');
});
});