import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException, } from '@nestjs/common'; import { SignupInput } from '@dorfteich/shared'; import { User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { MailService } from '../mail/mail.service'; import { PrismaService } from '../prisma/prisma.service'; import { RateLimitService } from '../rate-limit/rate-limit.service'; import { UsersService } from '../users/users.service'; import { AuthTokensService } from './auth-tokens.service'; import { SessionsService } from './sessions.service'; const VERIFY_TTL_SECONDS = 24 * 60 * 60; const RESET_TTL_SECONDS = 60 * 60; // Account-scoped login backoff: 5 failures per 15 minutes, reset on success. const LOGIN_BACKOFF = { limit: 5, windowSeconds: 15 * 60 }; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly users: UsersService, private readonly tokens: AuthTokensService, private readonly sessions: SessionsService, private readonly mail: MailService, private readonly rateLimits: RateLimitService, private readonly config: AppConfig, private readonly logger: PinoLogger, ) { this.logger.setContext(AuthService.name); } async signup(input: SignupInput): Promise { if ((await this.registrationMode()) === 'closed') { throw new ForbiddenException({ code: 'registration_closed' }); } const user = await this.users.createUser(input); await this.sendVerificationMail(user); this.logger.info({ userId: user.id }, 'audit: user signed up'); } async verifyEmail(token: string): Promise { const userId = await this.tokens.consume(token, 'EMAIL_VERIFICATION'); if (!userId) throw new BadRequestException({ code: 'token_invalid' }); const user = await this.users.findById(userId); if (!user) throw new BadRequestException({ code: 'token_invalid' }); if (user.status === 'PENDING_VERIFICATION') { await this.users.markEmailVerified(userId); this.logger.info({ userId }, 'audit: e-mail verified'); } } /** Always succeeds outwardly — never reveals whether the address exists. */ async resendVerification(email: string): Promise { const user = await this.users.findByEmail(email); if (user?.status === 'PENDING_VERIFICATION') { await this.sendVerificationMail(user); } } async login( usernameOrEmail: string, password: string, userAgent: string | undefined, ): Promise<{ sessionToken: string; user: User }> { const user = await this.users.findByUsernameOrEmail(usernameOrEmail); // Backoff before the (expensive) hash check; keyed by account so a // distributed guesser cannot sidestep it by rotating IPs. if (user) { const backoff = await this.rateLimits.hit( 'login-account', user.id, LOGIN_BACKOFF.limit, LOGIN_BACKOFF.windowSeconds, ); if (!backoff.allowed) { throw new UnauthorizedException({ code: 'login_backoff' }); } } const passwordOk = user ? await this.users.checkPassword(user.id, password) : false; if (!user || !passwordOk) { // Same generic error for unknown user and wrong password. this.logger.info({ userId: user?.id ?? null }, 'audit: login failed'); throw new UnauthorizedException({ code: 'login_failed' }); } if (user.status === 'DISABLED') { throw new ForbiddenException({ code: 'account_disabled' }); } if (user.status === 'PENDING_VERIFICATION') { throw new ForbiddenException({ code: 'email_unverified' }); } await this.rateLimits.reset('login-account', user.id); const sessionToken = await this.sessions.create(user.id, userAgent); await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); this.logger.info({ userId: user.id }, 'audit: login succeeded'); return { sessionToken, user }; } /** Always succeeds outwardly — never reveals whether the address exists. */ async forgotPassword(email: string): Promise { const user = await this.users.findByEmail(email); if (!user || user.status === 'DISABLED') return; const token = await this.tokens.issue(user.id, 'PASSWORD_RESET', RESET_TTL_SECONDS); await this.mail.enqueue( user.email, 'resetPassword', { displayName: user.displayName, link: `${this.config.env.APP_BASE_URL}/reset-password?token=${token}`, }, asLocale(user.locale), ); } async resetPassword(token: string, password: string): Promise { const userId = await this.tokens.consume(token, 'PASSWORD_RESET'); if (!userId) throw new BadRequestException({ code: 'token_invalid' }); await this.users.setPassword(userId, password); // Whoever held old sessions (possibly an attacker) is logged out. await this.sessions.destroyAllForUser(userId); this.logger.info({ userId }, 'audit: password reset'); } private async sendVerificationMail(user: User): Promise { const token = await this.tokens.issue(user.id, 'EMAIL_VERIFICATION', VERIFY_TTL_SECONDS); await this.mail.enqueue( user.email, 'verifyEmail', { displayName: user.displayName, link: `${this.config.env.APP_BASE_URL}/verify-email?token=${token}`, }, asLocale(user.locale), ); } /** * Registration mode straight from instance_settings; the typed * InstanceSettingsService (issue #19) will replace this direct read. */ private async registrationMode(): Promise<'open' | 'closed'> { const row = await this.prisma.instanceSetting.findUnique({ where: { key: 'auth.registrationMode' }, }); return row?.value === 'closed' ? 'closed' : 'open'; } } function asLocale(locale: string): 'de' | 'en' { return locale === 'de' ? 'de' : 'en'; }