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 { InvitationsService } from '../invitations/invitations.service'; import { MailService } from '../mail/mail.service'; import { PondsService } from '../ponds/ponds.service'; import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { RateLimitService } from '../rate-limit/rate-limit.service'; import { InstanceSettingsService } from '../settings/instance-settings.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 ponds: PondsService, private readonly invitations: InvitationsService, private readonly rateLimits: RateLimitService, private readonly audit: AuditService, private readonly config: AppConfig, private readonly settings: InstanceSettingsService, private readonly logger: PinoLogger, ) { this.logger.setContext(AuthService.name); } async signup(input: SignupInput): Promise { // An invitation token (issue #332) lets exactly one signup through a // closed registration. Claimed atomically BEFORE the account exists; // rolled back if the signup fails (duplicate username), so the invitee // can retry with the same link. const invitation = input.invitationToken ? await this.invitations.redeem(input.invitationToken) : null; if (input.invitationToken && !invitation) { throw new BadRequestException({ code: 'token_invalid' }); } if (!invitation && (await this.settings.get('auth.registrationMode')) === 'closed') { throw new ForbiddenException({ code: 'registration_closed' }); } let user: User; try { user = await this.users.createUser(input); } catch (error) { if (invitation) await this.invitations.unredeem(invitation.id); throw error; } if (invitation) { await this.invitations.markAccepted(invitation.id, user.id); await this.audit.record({ action: 'invitation.accepted', actorId: user.id, targetType: 'invitation', targetId: invitation.id, }); } // The invite link proves nothing about the mailbox (it can be // forwarded), so the usual verification mail still applies. await this.sendVerificationMail(user); await this.audit.record({ action: 'auth.signup', actorId: user.id }); } 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); await this.audit.record({ action: 'auth.email_verified', actorId: userId }); } // Every verified account owns a personal pond (issue #21). Idempotent, // so re-verification attempts and races cannot create duplicates. await this.ponds.ensurePersonalPond(user); } /** 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. await this.audit.record({ action: 'auth.login_failed', actorId: user?.id ?? null }); 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() } }); await this.audit.record({ action: 'auth.login_succeeded', actorId: user.id }); 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); await this.audit.record({ action: 'auth.password_reset', actorId: userId }); } 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), ); } } function asLocale(locale: string): 'de' | 'en' { return locale === 'de' ? 'de' : 'en'; }