Files
bookorbit/server/src/modules/auth/auth.service.ts
T
neon 9bcde5b0c8 feat: implement password reset via UI-configured system mail provider
- replace env-var SMTP config with DB-backed system mail provider
- add isSystemProvider flag to emailProviders table
- add SystemMailService for sending password reset emails
- add PATCH :id/system and DELETE system endpoints on email providers
- add forgotPassword/resetPassword audit logging with IP tracking
- block OIDC and inactive users from password reset flow
- fire-and-forget async work to prevent email enumeration timing oracle
- add HTML email template for password reset
- remove mailer.service and all SMTP env vars
2026-03-21 04:12:55 -06:00

522 lines
19 KiB
TypeScript

import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { compare, hash } from 'bcryptjs';
import { createHash, randomBytes } from 'crypto';
import '@fastify/cookie';
import { and, count, eq, gt, isNull, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { AuditAction } from '@projectx/types';
import { DB } from '../../db/db.module';
import * as schema from '../../db/schema';
import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service';
import type { RequestUser } from '../../common/types/request-user';
import { SystemMailService } from '../email/system-mail.service';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { UserService } from '../user/user.service';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { SetupDto } from './dto/setup.dto';
import { OidcDiscoveryService } from './oidc/oidc-discovery.service';
import { OidcSessionRepository } from './oidc/oidc-session.repository';
function parseDurationMs(duration: string): number {
const match = duration.match(/^(\d+)([smhd])$/);
if (!match) throw new Error(`Invalid duration: ${duration}`);
const n = parseInt(match[1], 10);
const units: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
return n * units[match[2]];
}
function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
const INITIAL_SETUP_KEY = 'initial_setup_completed_at';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
private readonly systemMailService: SystemMailService,
private readonly appSettings: AppSettingsService,
private readonly oidcSessionRepo: OidcSessionRepository,
private readonly oidcDiscovery: OidcDiscoveryService,
private readonly auditEvents: AuditEventsService,
@Inject(DB) private readonly db: NodePgDatabase<typeof schema>,
) {}
async register(dto: RegisterDto) {
const setting = await this.db.query.appSettings.findFirst({
where: eq(schema.appSettings.key, 'allow_registration'),
});
if (setting?.value !== 'true') {
throw new ForbiddenException('Registration is not open');
}
const passwordHash = await hash(dto.password, 12);
return this.db.transaction(async (tx) => {
const existingUsername = await tx.query.users.findFirst({ where: eq(schema.users.username, dto.username) });
if (existingUsername) throw new ConflictException('Username already taken');
if (dto.email) {
const existingEmail = await tx.query.users.findFirst({ where: eq(schema.users.email, dto.email) });
if (existingEmail) throw new ConflictException('Email already in use');
}
const [user] = await tx
.insert(schema.users)
.values({
username: dto.username,
name: dto.name,
email: dto.email,
passwordHash,
isDefaultPassword: false,
})
.returning({ id: schema.users.id, username: schema.users.username, name: schema.users.name });
this.auditEvents.emit(AUDIT_EVENT, {
userId: user.id,
actorUsername: user.username,
action: AuditAction.AuthRegister,
description: `User '${user.username}' registered`,
});
return user;
});
}
async setupStatus(): Promise<{ needsSetup: boolean }> {
const count = await this.db.$count(schema.users);
return { needsSetup: count === 0 };
}
async setup(dto: SetupDto, setupToken: string | undefined, reply: FastifyReply) {
this.assertSetupToken(setupToken);
const passwordHash = await hash(dto.password, 12);
const created = await this.db.transaction(async (tx) => {
const [setupMarker] = await tx
.insert(schema.appSettings)
.values({ key: INITIAL_SETUP_KEY, value: new Date().toISOString() })
.onConflictDoNothing({ target: schema.appSettings.key })
.returning({ id: schema.appSettings.id });
if (!setupMarker) {
throw new ConflictException('Setup already completed');
}
const [{ total }] = await tx.select({ total: count() }).from(schema.users);
if (Number(total) > 0) {
throw new ConflictException('Setup already completed');
}
const existingUsername = await tx.query.users.findFirst({ where: eq(schema.users.username, dto.username) });
if (existingUsername) {
throw new ConflictException('Username already taken');
}
const existingEmail = await tx.query.users.findFirst({ where: eq(schema.users.email, dto.email) });
if (existingEmail) {
throw new ConflictException('Email already in use');
}
const [user] = await tx
.insert(schema.users)
.values({
username: dto.username,
name: dto.name,
email: dto.email,
passwordHash,
isDefaultPassword: false,
isSuperuser: true,
})
.returning({
id: schema.users.id,
tokenVersion: schema.users.tokenVersion,
});
return user;
});
return this.issueTokensForUser(created.id, reply);
}
async login(dto: LoginDto, reply: FastifyReply, ip?: string) {
const user = await this.userService.findByUsername(dto.username);
if (!user || !user.active || !(await compare(dto.password, user.passwordHash))) {
this.auditEvents.emit(AUDIT_EVENT, {
userId: null,
actorUsername: 'system',
action: AuditAction.AuthLoginFailed,
description: `Failed login attempt for username '${dto.username}'`,
ip,
meta: { attemptedUsername: dto.username },
});
throw new UnauthorizedException('Invalid credentials');
}
const fullUser = await this.userService.findByIdWithPermissions(user.id);
const { accessToken, rawRefreshToken } = await this.issueTokenPair(user.id, user.tokenVersion);
this.setRefreshCookie(reply, rawRefreshToken);
this.setAccessCookie(reply, accessToken);
this.auditEvents.emit(AUDIT_EVENT, {
userId: user.id,
actorUsername: user.username,
action: AuditAction.AuthLogin,
description: `User '${user.username}' logged in`,
ip,
});
return { accessToken, user: this.buildUserResponse(fullUser!) };
}
buildUserResponse(user: RequestUser) {
return {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
active: user.active,
isSuperuser: user.isSuperuser,
isDefaultPassword: user.isDefaultPassword,
settings: user.settings,
avatarUrl: user.avatarUrl,
provisioningMethod: user.provisioningMethod,
permissions: user.isSuperuser ? ['*'] : user.permissions,
};
}
async issueTokensForUser(userId: number, reply: FastifyReply) {
const user = await this.userService.findByIdWithPermissions(userId);
if (!user || !user.active) throw new UnauthorizedException();
const { accessToken, rawRefreshToken } = await this.issueTokenPair(userId, user.tokenVersion);
this.setRefreshCookie(reply, rawRefreshToken);
this.setAccessCookie(reply, accessToken);
return { accessToken, user: this.buildUserResponse(user) };
}
async refresh(req: FastifyRequest, reply: FastifyReply) {
const rawToken = req.cookies?.refresh_token;
if (!rawToken) throw new UnauthorizedException();
const tokenHash = sha256(rawToken);
const row = await this.db.query.refreshTokens.findFirst({
where: eq(schema.refreshTokens.tokenHash, tokenHash),
});
if (!row) throw new UnauthorizedException();
// Reuse of a revoked token → theft signal, revoke all user sessions
if (row.revokedAt) {
await this.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, row.userId));
this.clearRefreshCookie(reply);
throw new UnauthorizedException();
}
if (row.expiresAt < new Date()) {
this.clearRefreshCookie(reply);
throw new UnauthorizedException();
}
// Rotate: revoke old, issue new
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, row.id));
const userForToken = await this.db.query.users.findFirst({ where: eq(schema.users.id, row.userId) });
if (!userForToken) throw new UnauthorizedException();
if (!userForToken.active) {
this.clearRefreshCookie(reply);
this.clearAccessCookie(reply);
throw new UnauthorizedException('Account disabled');
}
const { accessToken, rawRefreshToken } = await this.issueTokenPair(row.userId, userForToken.tokenVersion);
this.setRefreshCookie(reply, rawRefreshToken);
this.setAccessCookie(reply, accessToken);
return { accessToken };
}
async logout(req: FastifyRequest, reply: FastifyReply): Promise<{ logoutUrl?: string }> {
let userId: number | undefined;
const rawToken: string | undefined = req.cookies?.refresh_token;
if (rawToken) {
const tokenHash = sha256(rawToken);
const row = await this.db.query.refreshTokens.findFirst({ where: eq(schema.refreshTokens.tokenHash, tokenHash) });
if (row) {
userId = row.userId;
await Promise.all([
this.userService.incrementTokenVersion(row.userId),
this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, row.id)),
]);
}
}
this.clearRefreshCookie(reply);
this.clearAccessCookie(reply);
if (userId) {
const loggedOutUser = await this.db.query.users.findFirst({ where: eq(schema.users.id, userId) });
if (loggedOutUser) {
this.auditEvents.emit(AUDIT_EVENT, {
userId,
actorUsername: loggedOutUser.username,
action: AuditAction.AuthLogout,
description: `User '${loggedOutUser.username}' logged out`,
ip: req.ip,
});
}
}
if (!userId) return {};
const oidcSession = await this.oidcSessionRepo.findActiveByUserId(userId);
if (!oidcSession?.idTokenHint) return {};
try {
const config = await this.appSettings.getOidcConfig();
if (!config.enabled) return {};
const disc = await this.oidcDiscovery.getDiscoveryDoc(config.issuerUri);
if (!disc.endSessionEndpoint) return {};
const origin = req.headers['origin'] ?? req.headers['referer'];
const postLogoutUri = origin ? new URL('/login', origin).toString() : undefined;
const params = new URLSearchParams({ id_token_hint: oidcSession.idTokenHint });
if (postLogoutUri) params.set('post_logout_redirect_uri', postLogoutUri);
await this.oidcSessionRepo.revokeByUserId(userId);
return { logoutUrl: `${disc.endSessionEndpoint}?${params.toString()}` };
} catch {
return {};
}
}
async validateUser(userId: number, tokenVersion: number) {
const user = await this.userService.findByIdWithPermissions(userId);
if (!user || !user.active) throw new UnauthorizedException();
if (user.tokenVersion !== tokenVersion) throw new UnauthorizedException();
return user;
}
async getSessions(userId: number) {
const rows = await this.db.query.refreshTokens.findMany({
where: and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date())),
});
return rows.map(({ id, createdAt, expiresAt }) => ({ id, createdAt, expiresAt }));
}
async revokeSession(userId: number, sessionId: number) {
const row = await this.db.query.refreshTokens.findFirst({
where: eq(schema.refreshTokens.id, sessionId),
});
if (!row) throw new UnauthorizedException();
if (row.userId !== userId) throw new ForbiddenException('You do not have access to this session');
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, sessionId));
}
async forgotPassword(dto: ForgotPasswordDto, ip?: string): Promise<void> {
if (!(await this.systemMailService.isConfigured())) {
throw new ServiceUnavailableException('Self-service password reset is not configured. Contact your administrator.');
}
void this.processPasswordResetAsync(dto.email, ip).catch((err) => this.logger.error('Unhandled error during password reset processing', err));
}
private async processPasswordResetAsync(email: string, ip?: string): Promise<void> {
const user = await this.userService.findByEmail(email);
if (!user || !user.email) {
this.logger.log(`Password reset requested for unknown email: ${email}`);
return;
}
if (!user.active) {
this.logger.log(`Password reset requested for inactive account: ${email}`);
return;
}
if (user.provisioningMethod === 'oidc') {
this.logger.log(`Password reset requested for OIDC account: ${email}`);
return;
}
const rawToken = await this.userService.generatePasswordResetToken(user.id);
await this.systemMailService.sendPasswordReset(user.email, user.name, rawToken);
this.auditEvents.emit(AUDIT_EVENT, {
userId: user.id,
actorUsername: user.username,
action: AuditAction.AuthPasswordResetRequest,
description: `Password reset email sent to ${user.email}`,
ip,
});
}
async resetPassword(dto: ResetPasswordDto, ip?: string): Promise<void> {
const tokenHash = sha256(dto.token);
const row = await this.db.query.passwordResetTokens.findFirst({
where: eq(schema.passwordResetTokens.tokenHash, tokenHash),
});
if (!row || row.expiresAt < new Date() || row.usedAt) {
throw new BadRequestException('Invalid or expired reset token');
}
const user = await this.db.query.users.findFirst({ where: eq(schema.users.id, row.userId) });
if (!user || !user.active || user.provisioningMethod === 'oidc') {
throw new BadRequestException('Invalid or expired reset token');
}
const passwordHash = await hash(dto.newPassword, 12);
await this.db.transaction(async (tx) => {
await tx
.update(schema.users)
.set({ passwordHash, isDefaultPassword: false, tokenVersion: sql`${schema.users.tokenVersion} + 1` })
.where(eq(schema.users.id, row.userId));
await tx.update(schema.passwordResetTokens).set({ usedAt: new Date() }).where(eq(schema.passwordResetTokens.id, row.id));
await tx
.update(schema.refreshTokens)
.set({ revokedAt: new Date() })
.where(and(eq(schema.refreshTokens.userId, row.userId), isNull(schema.refreshTokens.revokedAt)));
});
this.auditEvents.emit(AUDIT_EVENT, {
userId: user.id,
actorUsername: user.username,
action: AuditAction.AuthPasswordReset,
description: `Password reset completed for user '${user.username}'`,
ip,
});
}
async changePassword(userId: number, dto: ChangePasswordDto, reply: FastifyReply, ip?: string) {
const user = await this.db.query.users.findFirst({
where: eq(schema.users.id, userId),
});
if (!user) throw new UnauthorizedException();
if (user.provisioningMethod === 'oidc') {
throw new BadRequestException('OIDC accounts cannot change their password here');
}
const valid = await compare(dto.currentPassword, user.passwordHash);
if (!valid) throw new UnauthorizedException('Current password is incorrect');
const passwordHash = await hash(dto.newPassword, 12);
await this.db.transaction(async (tx) => {
await tx
.update(schema.users)
.set({ passwordHash, isDefaultPassword: false, tokenVersion: sql`${schema.users.tokenVersion} + 1` })
.where(eq(schema.users.id, userId));
await tx
.update(schema.refreshTokens)
.set({ revokedAt: new Date() })
.where(and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt)));
});
this.clearRefreshCookie(reply);
this.clearAccessCookie(reply);
this.auditEvents.emit(AUDIT_EVENT, {
userId,
actorUsername: user.username,
action: AuditAction.AuthPasswordChange,
description: `User '${user.username}' changed their password`,
ip,
});
}
private async issueTokenPair(userId: number, tokenVersion: number) {
const accessToken = this.jwtService.sign({ sub: userId, ver: tokenVersion });
const rawRefreshToken = randomBytes(32).toString('hex');
const tokenHash = sha256(rawRefreshToken);
const refreshTtlMs = parseDurationMs(this.config.get<string>('auth.jwtRefreshExpiresIn') ?? '7d');
const expiresAt = new Date(Date.now() + refreshTtlMs);
await this.db.insert(schema.refreshTokens).values({ userId, tokenHash, expiresAt });
return { accessToken, rawRefreshToken };
}
private assertSetupToken(setupToken: string | undefined) {
const isProduction = this.config.get<string>('app.nodeEnv') === 'production';
if (!isProduction) return;
const expected = this.config.get<string>('auth.setupBootstrapToken') ?? '';
if (!expected || setupToken !== expected) {
throw new ForbiddenException('Invalid setup token');
}
}
private setRefreshCookie(reply: FastifyReply, rawToken: string) {
const ttlSeconds = parseDurationMs(this.config.get<string>('auth.jwtRefreshExpiresIn') ?? '7d') / 1000;
reply.setCookie('refresh_token', rawToken, {
httpOnly: true,
sameSite: 'strict',
path: '/api/v1/auth',
maxAge: ttlSeconds,
secure: this.config.get('app.nodeEnv') === 'production',
});
}
private clearRefreshCookie(reply: FastifyReply) {
reply.setCookie('refresh_token', '', {
httpOnly: true,
sameSite: 'strict',
path: '/api/v1/auth',
maxAge: 0,
secure: this.config.get('app.nodeEnv') === 'production',
});
}
private setAccessCookie(reply: FastifyReply, accessToken: string) {
const ttlSeconds = parseDurationMs(this.config.get<string>('auth.jwtExpiresIn') ?? '15m') / 1000;
reply.setCookie('access_token', accessToken, {
httpOnly: true,
sameSite: 'lax',
path: '/api',
maxAge: ttlSeconds,
secure: this.config.get('app.nodeEnv') === 'production',
});
}
private clearAccessCookie(reply: FastifyReply) {
reply.setCookie('access_token', '', {
httpOnly: true,
sameSite: 'lax',
path: '/api',
maxAge: 0,
secure: this.config.get('app.nodeEnv') === 'production',
});
}
}