import { BadRequestException, ForbiddenException, Injectable, Logger, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Role } from '@prisma/client'; import { parseDashboardBackground } from '@tessera/shared'; import * as argon2 from 'argon2'; import { randomUUID } from 'node:crypto'; import { Response } from 'express'; import { LdapConfigService } from '../ldap/ldap-config.service'; import { LdapService } from '../ldap/ldap.service'; import { MailService } from '../mail/mail.service'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import type { JwtPayload, LoginUser } from './types/auth-user'; /** * Zeilenform der drei auth_lookup_*-Datenbankfunktionen * (20260909160000_auth_lookup_functions). Siehe Kopf der Migration fuer die * Begruendung der schmalen Ausnahme (T-EOR-01/T-EOR-02). */ interface AuthLookupUserByUsernameRow { id: string; username: string; tenantId: string; passwordHash: string | null; ldapDn: string | null; isActive: boolean; /** * Die SQL-Funktion deklariert diese Spalte als `role "Role"` (Migration * 20260909160000, Zeile 64) — `string` war hier eine weitere Fassung * desselben Wertes, nicht seine Beschreibung (quick-260921-m34). */ role: Role; displayName: string | null; mustChangePassword: boolean; } interface AuthLookupUserByEmailRow { id: string; tenantId: string; email: string | null; isActive: boolean; } interface AuthLookupResetTokenRow { id: string; token: string; userId: string; expiresAt: Date; usedAt: Date | null; tenantId: string; } /** * Bindung an forTenant() (260911-fh9): dieser Bereich traegt die Grenze * der gesamten Mandantentrennung. `validateUser`, `requestPasswordReset`, * `resetPassword` suchen VOR bekanntem Mandanten — sie bleiben deshalb auf * dem ungebundenen Klienten und laufen ueber die drei * SECURITY-DEFINER-Funktionen aus `20260909160000_auth_lookup_functions` * (Etappe 1, 260909-eor). `getMe`, `changePassword`, `adminResetPassword` * laufen NACH der Anmeldung: der Mandant steht im signierten * Sitzungsnachweis (dem JWT-Claim `tenantId`, das `login()` aus der * Funktionszeile signiert) und wird je Methode ueber GENAU EINEN Klienten * `tenantPrisma` gebunden. Woher der Mandant der drei gebundenen Methoden * kommt: das Claim (`@CurrentUser().tenantId` im Controller) — NICHT die * Anfrageobjekt-Eigenschaft, die `TenantGuard` fuer die oberste Rolle per * Kopfzeile umschaltbar macht (die eigene Zeile liegt immer im eigenen * Mandanten, ein umgeschalteter SUPER_ADMIN muss sich selbst sehen). Fuer * die oberste Rolle bei `adminResetPassword` kommt der Mandant des ZIELS * stattdessen aus dem gebundenen Fan-out `UserService.findByIdForPlatformAdmin` * (Controller-seitig, Praezedenzfall `user.controller.ts` `resolveTargetUser`, * 260910-das). * * Etappe-3-Vorbehalt: sobald Anmeldenamen je Mandant eindeutig werden, * braucht der Anmeldeweg den Mandanten VOR der Suche — ein Umbau der drei * Funktionen (zwei Gleichheitsbedingungen statt einer, ENGER, nicht * weiter), nicht dieser Bereich. Die Bindung der drei Methoden hier haengt * ausschliesslich am Claim `tenantId` und an `User.id` (plattformweite * UUID) und bleibt davon unberuehrt. */ @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( private prisma: PrismaService, private jwtService: JwtService, private configService: ConfigService, private mailService: MailService, private ldapService: LdapService, private ldapConfigService: LdapConfigService, ) {} /** * Validate user credentials. Der Mandant ist vor dem Fund unbekannt, also * geht die Suche ueber auth_lookup_user_by_username() (SECURITY DEFINER, * 20260909160000_auth_lookup_functions) statt eines gewoehnlichen * "this dot prisma dot user dot findUnique" — unter der kuenftigen Rolle * ohne BYPASSRLS (tessera_app) liefert ein ungebundener SELECT auf "User" * null Zeilen. * Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle * Schreibzugriffe ueber forTenant(), gebunden an genau diesen Mandanten * (WINDOWS #20, Aufgabe 1). * * T-02-01: Returns null on any failure (never reveals which field is wrong). * Pitfall 6: Checks isActive to prevent deactivated users from logging in. */ async validateUser( username: string, password: string, ): Promise { // Usernames are stored lowercase (case-insensitive login). const rows = await this.prisma.$queryRaw` SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()}) `; const user = rows[0]; if (!user?.isActive) { return null; } const tenantPrisma = forTenant(this.prisma, user.tenantId); // LDAP users have no local password — authenticate them against the // directory by binding as their OWN DN with the password they entered. if (!user.passwordHash) { if (!user.ldapDn) { return null; } const config = await this.ldapConfigService.getConfig(user.tenantId); if (!config?.isActive) { return null; } const ok = await this.ldapService.verifyUserCredentials( { serverUrl: config.serverUrl, tlsRejectUnauthorized: config.tlsRejectUnauthorized, }, user.ldapDn, password, ); if (!ok) { return null; } await tenantPrisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); return user; } const isPasswordValid = await argon2.verify(user.passwordHash, password); if (!isPasswordValid) { return null; } // Update lastLoginAt await tenantPrisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); return user; } /** * Issue JWT in httpOnly cookie and return user info. * D-02: 30-day session. * T-02-02: httpOnly + secure (prod) + sameSite=lax. */ async login(user: LoginUser, response: Response) { const payload: JwtPayload = { sub: user.id, username: user.username, role: user.role, tenantId: user.tenantId, mustChangePassword: user.mustChangePassword, }; const token = this.jwtService.sign(payload); response.cookie('session', token, { httpOnly: true, secure: this.configService.get('NODE_ENV') === 'production', sameSite: 'lax', maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days path: '/', }); return { id: user.id, username: user.username, role: user.role, displayName: user.displayName, tenantId: user.tenantId, mustChangePassword: user.mustChangePassword, }; } /** * Clear the session cookie to log the user out. */ logout(response: Response) { response.clearCookie('session', { httpOnly: true, secure: this.configService.get('NODE_ENV') === 'production', sameSite: 'lax', path: '/', }); } /** * Request a password reset (D-03 self-service). * T-02-12: Always returns success, even if email not found (prevent enumeration). * T-02-13: Single-use token with 1-hour expiry. */ async requestPasswordReset(email: string): Promise { const rows = await this.prisma.$queryRaw` SELECT * FROM auth_lookup_user_by_email(${email}) `; const user = rows[0]; // Always return success to prevent email enumeration (T-02-12) if (!user?.isActive) { this.logger.log( `Password reset requested for unknown/inactive email: ${email}`, ); return; } // Generate a unique reset token const token = randomUUID(); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour // Create the reset token record — mandantengebunden, sobald der // Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1). const tenantPrisma = forTenant(this.prisma, user.tenantId); await tenantPrisma.passwordResetToken.create({ data: { token, userId: user.id, expiresAt, }, }); // Send the reset email (fire-and-forget, errors logged by MailService). // Der Mandant des Empfaengers entscheidet ueber den SMTP-Transport // (260914-eym, WINDOWS #30) — er ist hier bereits bekannt. await this.mailService.sendPasswordResetEmail(email, token, user.tenantId); } /** * Reset password using a valid token (D-03 self-service). * T-02-13: Validates token not expired, not used. Marks as used after success. */ async resetPassword(token: string, newPassword: string): Promise { const rows = await this.prisma.$queryRaw` SELECT * FROM auth_lookup_reset_token(${token}) `; const resetToken = rows[0]; if (!resetToken) { throw new BadRequestException('Invalid or expired reset token'); } // Check if token has already been used if (resetToken.usedAt) { throw new BadRequestException('Reset token has already been used'); } // Check if token has expired if (resetToken.expiresAt < new Date()) { throw new BadRequestException('Reset token has expired'); } // Mandant ist ab hier bekannt (aus der Funktion mitgeliefert) — beide // Schreibzugriffe laufen gebunden (WINDOWS #20, Aufgabe 1). const tenantPrisma = forTenant(this.prisma, resetToken.tenantId); // Hash the new password and update user const passwordHash = await argon2.hash(newPassword); await tenantPrisma.user.update({ where: { id: resetToken.userId }, data: { passwordHash, mustChangePassword: false, }, }); // Mark token as used (T-02-13) await tenantPrisma.passwordResetToken.update({ where: { id: resetToken.id }, data: { usedAt: new Date() }, }); this.logger.log(`Password reset completed for user ${resetToken.userId}`); } /** * Return enriched profile for the currently authenticated user. * T-gbh-03: Only public fields + isLocalUser/hasAvatar returned — never * passwordHash or ldapDn. * * Bindet an den Mandanten aus dem Sitzungsnachweis (260911-fh9): der * Aufrufer sucht seine EIGENE Zeile, die per Definition im eigenen * Mandanten liegt. Eine fremdmandantige Kennung (kann strukturell nicht * vorkommen, weil der Controller ausschliesslich `user.id` aus dem Claim * durchreicht) liefert unter dem gebundenen Klienten `null`, nicht die * Zeile. */ async getMe(tenantId: string, userId: string) { const tenantPrisma = forTenant(this.prisma, tenantId); const user = await tenantPrisma.user.findUnique({ where: { id: userId }, select: { id: true, username: true, displayName: true, role: true, tenantId: true, mustChangePassword: true, passwordHash: true, ldapDn: true, avatarPath: true, accentColor: true, dashboardBackground: true, }, }); if (!user) { return null; } const { passwordHash, ldapDn, avatarPath, dashboardBackground, ...publicFields } = user; return { ...publicFields, // quick-260928-ujj (T-ujj-01): auch beim Lesen durch die gemeinsame // Pruefregel — NULL oder ein ungueltiger Inhalt ergibt null. dashboardBackground: parseDashboardBackground(dashboardBackground), isLocalUser: !!passwordHash && !ldapDn, hasAvatar: !!avatarPath, }; } /** * Change password for the currently logged-in user. * Verifies current password before allowing change. * * Bindet an den Mandanten aus dem Sitzungsnachweis (260911-fh9), EIN * Klient `tenantPrisma` fuer Suche UND Schreiben — dieselbe Begruendung * wie bei getMe() oben: die eigene Zeile liegt im eigenen Mandanten. */ async changePassword( tenantId: string, userId: string, currentPassword: string, newPassword: string, response: Response, ): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const user = await tenantPrisma.user.findUnique({ where: { id: userId }, }); if (!user?.passwordHash) { throw new UnauthorizedException('User not found or has no local password'); } const isValid = await argon2.verify(user.passwordHash, currentPassword); if (!isValid) { throw new UnauthorizedException('Current password is incorrect'); } const passwordHash = await argon2.hash(newPassword); await tenantPrisma.user.update({ where: { id: userId }, data: { passwordHash, mustChangePassword: false }, }); const payload: JwtPayload = { sub: user.id, username: user.username, role: user.role, tenantId: user.tenantId, mustChangePassword: false, }; const token = this.jwtService.sign(payload); response.cookie('session', token, { httpOnly: true, secure: this.configService.get('NODE_ENV') === 'production', sameSite: 'lax', maxAge: 30 * 24 * 60 * 60 * 1000, path: '/', }); this.logger.log(`Password changed for user ${userId}`); } /** * Admin reset of a user's password (D-03 admin reset). * T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard. * * Bindet an den Mandanten des ZIELS (260911-fh9), EIN Klient * `tenantPrisma`: fuer einen ADMIN-Aufrufer ist das dessen eigener * Mandant aus dem Sitzungsnachweis, fuer SUPER_ADMIN der ueber den * gebundenen Fan-out (Controller, `UserService.findByIdForPlatformAdmin`) * aufgeloeste Mandant des Ziels — beide kommen als `tenantId`-Parameter * bereits fertig aufgeloest hier an. Ein fremdmandantiges Ziel ist unter * dem gebundenen Klienten unsichtbar (T-FH9-01); die * `BadRequestException` nennt weder Halter noch Mandanten. Der Riegel * unten schliesst zusaetzlich die Rechteausweitung INNERHALB des * Mandanten (T-FH9-04): ein Nicht-SUPER_ADMIN darf das Kennwort eines * SUPER_ADMIN nicht setzen. Die Schwesterwege `PATCH /users/:id` und * `DELETE /users/:id` tragen seit 260914-ebg (WINDOWS #29) denselben * Riegel in `UserController.update()`/`remove()`. */ async adminResetPassword( tenantId: string, callerRole: Role, userId: string, newPassword: string, mustChangePassword: boolean = true, ): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const user = await tenantPrisma.user.findUnique({ where: { id: userId }, }); if (!user) { throw new BadRequestException('User not found'); } if (user.role === Role.SUPER_ADMIN && callerRole !== Role.SUPER_ADMIN) { throw new ForbiddenException('Cannot reset password of a SUPER_ADMIN user'); } const passwordHash = await argon2.hash(newPassword); await tenantPrisma.user.update({ where: { id: userId }, data: { passwordHash, mustChangePassword, }, }); this.logger.log(`Admin reset password for user ${userId}`); } }