feat/identity-access #1
|
|
@ -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