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:
2026-09-09 10:56:41 +02:00
parent bbf179503c
commit de50297467
5 changed files with 692 additions and 25 deletions
+199 -6
View File
@@ -1,17 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from './auth.service';
// forTenant() gibt in diesen Tests denselben Client zurueck (tenant scoping
// ist hier nicht die Pruefung) — dasselbe Muster wie in
// ldap.service.spec.ts.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
/**
* Baut eine Tagged-Template-Attrappe fuer prisma.$queryRaw, die die
* uebergebenen SQL-Textstuecke und interpolierten Werte aufzeichnet und ein
* konfigurierbares Ergebnis liefert — ohne laufende Datenbank.
*/
function fakeQueryRaw(resultsByCall: unknown[][]) {
let callIndex = 0;
const calls: { strings: TemplateStringsArray; values: unknown[] }[] = [];
const fn = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
calls.push({ strings, values });
const result = resultsByCall[callIndex] ?? [];
callIndex += 1;
return Promise.resolve(result);
});
return { fn, calls };
}
/**
* validateUser — LDAP login path (AUTH-06 follow-up): users imported from LDAP
* have no local passwordHash and must be authenticated by binding as their own
* DN against the tenant's directory. These tests cover that branch; the local
* password path (argon2) is unchanged and exercised elsewhere.
*
* Aufgabe 2 (260909-eor): validateUser sucht ab jetzt ueber
* auth_lookup_user_by_username() via $queryRaw statt this.prisma.user.findUnique
* — die Tests hier zeichnen $queryRaw statt user.findUnique auf.
*/
describe('AuthService.validateUser — LDAP login', () => {
let service: AuthService;
let prisma: any;
let ldapService: any;
let ldapConfigService: any;
let queryRaw: ReturnType<typeof fakeQueryRaw>;
const ldapUser = {
id: 'u1',
@@ -24,9 +53,10 @@ describe('AuthService.validateUser — LDAP login', () => {
beforeEach(() => {
vi.clearAllMocks();
queryRaw = fakeQueryRaw([[ldapUser]]);
prisma = {
$queryRaw: queryRaw.fn,
user: {
findUnique: vi.fn().mockResolvedValue(ldapUser),
update: vi.fn().mockResolvedValue({}),
},
};
@@ -87,10 +117,8 @@ describe('AuthService.validateUser — LDAP login', () => {
});
it('rejects a passwordless user that has no ldapDn (never binds)', async () => {
prisma.user.findUnique.mockResolvedValue({
...ldapUser,
ldapDn: null,
});
queryRaw = fakeQueryRaw([[{ ...ldapUser, ldapDn: null }]]);
prisma.$queryRaw = queryRaw.fn;
const result = await service.validateUser('alice', 'pw');
@@ -99,11 +127,176 @@ describe('AuthService.validateUser — LDAP login', () => {
});
it('rejects an inactive LDAP user before any bind', async () => {
prisma.user.findUnique.mockResolvedValue({ ...ldapUser, isActive: false });
queryRaw = fakeQueryRaw([[{ ...ldapUser, isActive: false }]]);
prisma.$queryRaw = queryRaw.fn;
const result = await service.validateUser('alice', 'pw');
expect(result).toBeNull();
expect(ldapService.verifyUserCredentials).not.toHaveBeenCalled();
});
it('lowercases the username before calling auth_lookup_user_by_username', async () => {
ldapService.verifyUserCredentials.mockResolvedValue(true);
await service.validateUser('Alice', 'ad-password');
expect(queryRaw.calls).toHaveLength(1);
expect(queryRaw.calls[0].values).toEqual(['alice']);
});
it('returns null (never throws) when auth_lookup_user_by_username finds nothing', async () => {
queryRaw = fakeQueryRaw([[]]);
prisma.$queryRaw = queryRaw.fn;
const result = await service.validateUser('unknown', 'pw');
expect(result).toBeNull();
});
});
/**
* Lokaler Kennwort-Anmeldeweg (argon2) sowie requestPasswordReset/
* resetPassword — decken die Aufgabe-2-Verhaltensfaelle ab: Anmeldesuche
* findet den Benutzer weiterhin, Schreibzugriffe laufen nach gefundenem
* Benutzer mandantengebunden.
*/
describe('AuthService.validateUser — lokales Kennwort', () => {
let service: AuthService;
let prisma: any;
let queryRaw: ReturnType<typeof fakeQueryRaw>;
let localUser: {
id: string;
tenantId: string;
username: string;
passwordHash: string;
ldapDn: null;
isActive: boolean;
};
beforeEach(async () => {
vi.clearAllMocks();
// Echter argon2-Hash statt Mock — argon2.verify laesst sich in ESM
// nicht ueber vi.spyOn ersetzen (nicht konfigurierbarer Modul-Export).
const argon2 = await import('argon2');
localUser = {
id: 'u2',
tenantId: 't1',
username: 'bob',
passwordHash: await argon2.hash('correct-password'),
ldapDn: null,
isActive: true,
};
queryRaw = fakeQueryRaw([[localUser]]);
prisma = {
$queryRaw: queryRaw.fn,
user: { update: vi.fn().mockResolvedValue({}) },
};
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
});
it('findet den Benutzer weiterhin und aktualisiert lastLoginAt mandantengebunden', async () => {
const result = await service.validateUser('bob', 'correct-password');
expect(result).toEqual(localUser);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'u2' },
data: { lastLoginAt: expect.any(Date) },
});
});
});
describe('AuthService.requestPasswordReset', () => {
let service: AuthService;
let prisma: any;
let mailService: any;
let queryRaw: ReturnType<typeof fakeQueryRaw>;
const emailUser = { id: 'u3', tenantId: 't1', email: 'bob@example.com', isActive: true };
beforeEach(() => {
vi.clearAllMocks();
queryRaw = fakeQueryRaw([[emailUser]]);
prisma = {
$queryRaw: queryRaw.fn,
passwordResetToken: { create: vi.fn().mockResolvedValue({}) },
};
mailService = { sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined) };
service = new AuthService(prisma, {} as any, {} as any, mailService, {} as any, {} as any);
});
it('legt das Rueckstell-Token mandantengebunden an, sobald der Benutzer gefunden ist', async () => {
await service.requestPasswordReset('bob@example.com');
expect(prisma.passwordResetToken.create).toHaveBeenCalledWith({
data: {
token: expect.any(String),
userId: 'u3',
expiresAt: expect.any(Date),
},
});
expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith(
'bob@example.com',
expect.any(String),
);
});
it('kehrt bei unbekannter E-Mail wortlos zurueck (T-02-12, keine Enumeration)', async () => {
queryRaw = fakeQueryRaw([[]]);
prisma.$queryRaw = queryRaw.fn;
await service.requestPasswordReset('unknown@example.com');
expect(prisma.passwordResetToken.create).not.toHaveBeenCalled();
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
});
});
describe('AuthService.resetPassword', () => {
let service: AuthService;
let prisma: any;
let queryRaw: ReturnType<typeof fakeQueryRaw>;
const resetTokenRow = {
id: 'rt1',
token: 'a-uuid-token',
userId: 'u4',
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
usedAt: null,
tenantId: 't1',
};
beforeEach(() => {
vi.clearAllMocks();
queryRaw = fakeQueryRaw([[resetTokenRow]]);
prisma = {
$queryRaw: queryRaw.fn,
user: { update: vi.fn().mockResolvedValue({}) },
passwordResetToken: { update: vi.fn().mockResolvedValue({}) },
};
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
});
it('findet den passenden Rueckstell-Datensatz und aktualisiert Kennwort und Token mandantengebunden', async () => {
await service.resetPassword('a-uuid-token', 'new-password');
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'u4' },
data: { passwordHash: expect.any(String), mustChangePassword: false },
});
expect(prisma.passwordResetToken.update).toHaveBeenCalledWith({
where: { id: 'rt1' },
data: { usedAt: expect.any(Date) },
});
});
it('wirft bei unbekanntem Token', async () => {
queryRaw = fakeQueryRaw([[]]);
prisma.$queryRaw = queryRaw.fn;
await expect(service.resetPassword('unknown', 'pw')).rejects.toThrow(
'Invalid or expired reset token',
);
});
});
+68 -18
View File
@@ -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() },
});