feat(quick-260909-eor): schmale SECURITY-DEFINER-Ausnahme fuer den Anmeldeweg
WINDOWS #18/#20, Aufgabe 2: der Anmeldeweg muss den passenden Benutzer finden, bevor sein Mandant bekannt ist — unter der kuenftigen Rolle ohne BYPASSRLS (tessera_app) wuerde ein gewoehnlicher SELECT auf "User" sonst null Zeilen liefern und die Anmeldung waere unmoeglich. Drei SECURITY-DEFINER-Funktionen (STABLE, fester Suchpfad public/pg_temp, fester Spaltensatz, LIMIT 1, Ausfuehrungsrecht ausschliesslich fuer tessera_app) ersetzen die drei pre-tenant Lesezugriffe in auth.service.ts: - auth_lookup_user_by_username (validateUser) - auth_lookup_user_by_email (requestPasswordReset) - auth_lookup_reset_token (resetPassword) Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle Schreibzugriffe (lastLoginAt, passwordHash, Reset-Token) ueber forTenant(), gebunden an genau diesen Mandanten (Aufgabe 1). getMe/changePassword/ adminResetPassword bleiben bewusst unangetastet — sie kennen den Mandanten bereits aus dem Sitzungsnachweis und gehoeren in Etappe 2. rls-scratch-check.mjs um einen zweiten Abschnitt erweitert: spielt die Migration in die Wegwerf-Datenbank ein und misst live unter der Rolle ohne BYPASSRLS — Anmeldesuche findet den Benutzer, unbekannter Name liefert nichts ohne zu werfen, gewoehnlicher SELECT auf "User" liefert null Zeilen. Alle 8 Pruefungen (5 aus Aufgabe 1 + 3 neue) bestehen gegen die lokale Datenbank. Volle Testsuite (695 Tests) und type-check bleiben gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
@@ -13,6 +13,40 @@ 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;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -28,22 +62,31 @@ export class AuthService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate user credentials. Uses unscoped Prisma (no tenant context)
|
||||
* because login must work across all tenants.
|
||||
* 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.prisma.user.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 user = await this.prisma.user.findUnique({
|
||||
where: { username: username.toLowerCase() },
|
||||
});
|
||||
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) {
|
||||
@@ -68,7 +111,7 @@ export class AuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
await tenantPrisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
@@ -81,7 +124,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
// Update lastLoginAt
|
||||
await this.prisma.user.update({
|
||||
await tenantPrisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
@@ -141,9 +184,10 @@ export class AuthService {
|
||||
* T-02-13: Single-use token with 1-hour expiry.
|
||||
*/
|
||||
async requestPasswordReset(email: string): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
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) {
|
||||
@@ -157,8 +201,10 @@ export class AuthService {
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
// Create the reset token record
|
||||
await this.prisma.passwordResetToken.create({
|
||||
// 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,
|
||||
@@ -175,10 +221,10 @@ export class AuthService {
|
||||
* T-02-13: Validates token not expired, not used. Marks as used after success.
|
||||
*/
|
||||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||||
const resetToken = await this.prisma.passwordResetToken.findUnique({
|
||||
where: { token },
|
||||
include: { user: true },
|
||||
});
|
||||
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');
|
||||
@@ -194,9 +240,13 @@ export class AuthService {
|
||||
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 this.prisma.user.update({
|
||||
await tenantPrisma.user.update({
|
||||
where: { id: resetToken.userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
@@ -205,7 +255,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
// Mark token as used (T-02-13)
|
||||
await this.prisma.passwordResetToken.update({
|
||||
await tenantPrisma.passwordResetToken.update({
|
||||
where: { id: resetToken.id },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user