Files
tessera-ctl/apps/api/src/auth/auth.service.ts
T
schalli 6e2a641d76 feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig
- mail: MailerModule-Fabrik und DB-Startpfad (findFirst beim Boot) ersatzlos
  entfernt; MailService baut je Versand einen nodemailer-Transport aus
  getDecryptedSmtpConfig(tenantId) des Empfaenger-Mandanten, Umgebungs-Kette
  (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) nur als Rueckfall; Fehler
  weiter verschluckt (T-02-12), close() im finally; neue mail.service.spec.ts
  (4 Tests, T-GWH-03 geschlossen)
- settings: Startpfad-Methode samt vier Spec-Tests geloescht;
  auth: requestPasswordReset reicht user.tenantId durch (Spec-Zusicherung)
- ldap: getAllActiveConfigs und Nachverschluesselung lesen ueber forSystem
  (zwei Zuweisungen), Schreibzeile je Altzeile ueber forTenant(config.tenantId);
  Tests 301/306 umgedreht, neuer Altzeilen-Test
- tender-digest: Kandidatenabfrage ueber forSystem, Schleife gebunden (+1 Test)
- tender-matching: Profilabfrage ueber forSystem, Katalog (D-03) ungebunden (+1 Test)
- tender-notifications.integration.spec: Mock um forSystem
- Werkzeug: LdapConfig (15 Spalten), LdapFieldMapping (6), TenderMatch (8),
  TenderSavedSearch (8) je neun Kennungen plus Relations-Kennung
  ldapconfig-systemkontext-include-fieldmappings-beider-mandanten
  -> Alle 253 Pruefungen bestanden
- Detektor: FORSYSTEM_ALLOWED_CALL_SITES auf 4 Dateien / 5 Aufrufe;
  Proben-Empfaenger sysPrisma (Gate-Zaehlung, Name nicht hartkodiert)
- Klassifikation: 6 Zeilen system-gebunden, settings/smtpConfig gebunden
- Falsifizierung durch Rueckbau ausgefuehrt und zurueckgenommen:
  (a) FOR SELECT bei TenderMatch entfernt -> 5 von 253 rot (Insert gelingt,
  cmd ALL); (b) Regel TenderSavedSearch aus der Datei entfernt -> 1 von 245
  rot (Extraktion), lebende DB bleibt bei 34; (c) local=false -> gruen, plus
  Reset entfernt -> 5 rot (Erben sichtbar); (d) Zahl 0 -> 2 rot, Fremddatei
  admin-seed -> 3 rot
- Baseline: 64 Dateien / 1054 Tests, tsc 0, Werkzeug 253

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 11:46:08 +02:00

445 lines
14 KiB
TypeScript

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 * as argon2 from 'argon2';
import { randomUUID } from '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';
/**
* 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;
role: string;
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<any> {
// Usernames are stored lowercase (case-insensitive login).
const rows = await this.prisma.$queryRaw<AuthLookupUserByUsernameRow[]>`
SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()})
`;
const user = rows[0];
if (!user || !user.isActive) {
return null;
}
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
// 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 || !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: any, response: Response) {
const payload = {
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<void> {
const rows = await this.prisma.$queryRaw<AuthLookupUserByEmailRow[]>`
SELECT * FROM auth_lookup_user_by_email(${email})
`;
const user = rows[0];
// Always return success to prevent email enumeration (T-02-12)
if (!user || !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) as any;
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<void> {
const rows = await this.prisma.$queryRaw<AuthLookupResetTokenRow[]>`
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) as any;
// 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) as any;
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,
},
});
if (!user) {
return null;
}
const { passwordHash, ldapDn, avatarPath, ...publicFields } = user;
return {
...publicFields,
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<void> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const user = await tenantPrisma.user.findUnique({
where: { id: userId },
});
if (!user || !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 = {
sub: user.id,
username: user.username,
role: user.role,
tenantId: user.tenantId,
mustChangePassword: false,
};
const token = this.jwtService.sign(payload);
(response as any).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<void> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
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}`);
}
}