feat(260911-fh9): getMe/changePassword/adminResetPassword an den Mandanten aus dem Sitzungsnachweis binden

- auth.service.ts: die drei Nach-Anmeldungs-Methoden binden je ueber genau
  einen Klienten tenantPrisma; adminResetPassword verweigert einem
  Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04); die drei
  $queryRaw-Anmeldesuchen bleiben unveraendert auf dem ungebundenen Klienten
- auth.controller.ts: me/changePassword reichen user.tenantId aus dem Claim
  durch; adminResetPassword verzweigt ueber resolveTargetTenantId nach Rolle
  (ADMIN: eigener Mandant; SUPER_ADMIN: gebundener Fan-out
  UserService.findByIdForPlatformAdmin) — schliesst die Rechteausweitung
  ueber die Mandantengrenze (T-FH9-01)
- auth.module.ts: importiert UserModule, zyklusfrei gemessen
- auth.service.spec.ts: Identitaets-Attrappe ersetzt durch zwei
  unterscheidbare Klienten (__makeBoundClient); 29 Faelle, drei
  Falsifizierungsnachweise durchgefuehrt und zurueckgenommen

Bekannt und erwartet: rls-access-inventory.spec.ts ist nach diesem Commit
kurzzeitig rot (Bestandsaufnahme-Zeile auth.service.ts/user zeigt noch
"gemischt", gemessen ist jetzt "gebunden") — wird in Aufgabe 3 desselben
Plans geschlossen (927/928 Tests gruen, ein bekannter, in Aufgabe 3
behobener Fehlschlag, kein neuer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 11:56:14 +02:00
parent 9782bea1b2
commit 92aa8c403b
4 changed files with 624 additions and 48 deletions
+48 -2
View File
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Get,
@@ -12,6 +13,7 @@ import {
import { AuthGuard } from '@nestjs/passport';
import { Role } from '@prisma/client';
import { Request, Response } from 'express';
import { UserService } from '../user/user.service';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator';
@@ -23,7 +25,33 @@ import { RolesGuard } from './guards/roles.guard';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
constructor(
private authService: AuthService,
private userService: UserService,
) {}
/**
* Loest den Mandanten fuer `adminResetPassword` auf (260911-fh9,
* Praezedenzfall `user.controller.ts` `resolveTargetUser`, 260910-das):
* ein ADMIN wirkt auf seinen EIGENEN Mandanten (Claim), die oberste
* Rolle (SUPER_ADMIN) behaelt ihre uebergreifende Reichweite ueber den
* gebundenen Fan-out `UserService.findByIdForPlatformAdmin` — sonst
* saehe ein SUPER_ADMIN nur noch den eigenen Mandanten, eine stille
* Funktionsminderung (Befund D). Nicht gefunden: dieselbe
* `BadRequestException('User not found')`, die der Dienst bisher ohne
* Mandantenpruefung warf, damit ein API-Aufrufer denselben Statuscode
* sieht wie vor dieser Umstellung.
*/
private async resolveTargetTenantId(currentUser: any, userId: string): Promise<string> {
if (currentUser.role === Role.SUPER_ADMIN) {
const target = await this.userService.findByIdForPlatformAdmin(userId);
if (!target) {
throw new BadRequestException('User not found');
}
return target.tenantId;
}
return currentUser.tenantId;
}
/**
* POST /auth/login
@@ -55,10 +83,16 @@ export class AuthController {
* GET /auth/me
* Returns enriched user profile: public fields + isLocalUser + hasAvatar.
* T-gbh-03: passwordHash and ldapDn are never serialised in the response.
*
* Mandant kommt ausschliesslich aus dem Sitzungsnachweis (`@CurrentUser()`,
* das Claim), NICHT aus der Anfrageobjekt-Eigenschaft, die `TenantGuard`
* fuer die oberste Rolle per Kopfzeile umschaltbar macht — ein
* umgeschalteter SUPER_ADMIN muss sich selbst weiterhin sehen (260911-fh9,
* Befund C).
*/
@Get('me')
async me(@CurrentUser() user: any) {
return this.authService.getMe(user.id);
return this.authService.getMe(user.tenantId, user.id);
}
/**
@@ -101,6 +135,7 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
) {
await this.authService.changePassword(
user.tenantId,
user.id,
dto.currentPassword,
dto.newPassword,
@@ -113,6 +148,13 @@ export class AuthController {
* POST /auth/admin-reset-password/:userId
* Admin resets a user's password (D-03 admin reset).
* T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard.
*
* Der Mandant des Ziels kommt ausschliesslich aus dem Sitzungsnachweis
* des AUFRUFERS bzw. aus dem gebundenen Fan-out fuer die oberste Rolle
* (`resolveTargetTenantId` oben) — NICHT aus Pfad, Rumpf oder Kopfzeile
* (T-FH9-02). `AdminResetPasswordDto` traegt bewusst kein Mandantenfeld.
* Kein Frontend-Aufrufer (gemessen, 260911-fh9 Befund D); der
* Schwesterweg ist `PATCH /users/:id`.
*/
@Post('admin-reset-password/:userId')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@@ -121,8 +163,12 @@ export class AuthController {
async adminResetPassword(
@Param('userId') userId: string,
@Body() dto: AdminResetPasswordDto,
@CurrentUser() currentUser: any,
) {
const tenantId = await this.resolveTargetTenantId(currentUser, userId);
await this.authService.adminResetPassword(
tenantId,
currentUser.role,
userId,
dto.newPassword,
dto.mustChangePassword ?? true,
+14
View File
@@ -4,11 +4,24 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { LdapModule } from '../ldap/ldap.module';
import { MailModule } from '../mail/mail.module';
import { UserModule } from '../user/user.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
/**
* Importiert `UserModule` fuer `AuthController.resolveTargetTenantId`
* (260911-fh9): `adminResetPassword` loest den Mandanten der obersten
* Rolle (SUPER_ADMIN) ueber `UserService.findByIdForPlatformAdmin` auf.
* Zyklusfrei gemessen: `UserModule` importiert nur `GroupsModule`,
* `GroupsModule` importiert nichts (`grep -n "imports:"
* apps/api/src/groups/groups.module.ts`: null Treffer), und kein Modul
* ausser `AppModule` importiert `AuthModule` (`grep -rn "AuthModule"
* apps/api/src --include=*.module.ts`: nur `app.module.ts` und diese
* Datei selbst). `LdapModule`, das `AuthModule` bereits importiert,
* importiert `UserModule` unabhaengig davon selbst.
*/
@Module({
imports: [
PassportModule,
@@ -21,6 +34,7 @@ import { LocalStrategy } from './strategies/local.strategy';
}),
MailModule,
LdapModule,
UserModule,
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
+493 -40
View File
@@ -1,11 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Role } from '@prisma/client';
import { AuthService } from './auth.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
// forTenant() gibt in diesen Tests denselben Client zurueck (tenant scoping
// ist hier nicht die Pruefung) — dasselbe Muster wie in
// ldap.service.spec.ts.
/**
* Aufgabe 2 (260911-fh9): die Identitaets-Attrappe verschwindet. Der
* Nachbau bekommt zwei UNTERSCHEIDBARE Klienten, in der Form von
* `user.service.spec.ts`/`tenant.controller.spec.ts`: `forTenant()` wird
* auf `__makeBoundClient(tenantId)` umgeleitet. Der UNGEBUNDENE Nachbau
* (der ungebundene Basisclient selbst) hat `$queryRaw` und
* `__makeBoundClient` — aber KEIN `user`- und KEIN `passwordResetToken`-
* Modell: ein versehentlich ungebundener Modellzugriff scheitert mit
* "Cannot read properties of undefined" (die dkv-Form der Falsifizierung).
* Der GEBUNDENE Klient hat `user`/`passwordResetToken`, aber KEIN
* `$queryRaw`: eine gebundene Anmeldesuche scheitert ebenso hart.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
}));
/**
@@ -25,6 +36,122 @@ function fakeQueryRaw(resultsByCall: unknown[][]) {
return { fn, calls };
}
interface FakeUserRow {
id: string;
tenantId: string;
username: string;
email?: string | null;
passwordHash: string | null;
ldapDn: string | null;
isActive: boolean;
role: string;
displayName: string | null;
mustChangePassword: boolean;
avatarPath?: string | null;
accentColor?: string | null;
}
interface BoundCall {
tenantId: string;
model: 'user' | 'passwordResetToken';
method: string;
args: any;
}
/**
* Zwei-Klienten-Nachbau (Muster `user.service.spec.ts`): `users` und
* `resetTokens` sind das gemeinsame Gedaechtnis, der ungebundene Klient
* (`$queryRaw`) und der gebundene Klient (`__makeBoundClient`) greifen auf
* DIESELBEN Karten zu, protokollieren aber unterschiedlich — der
* ungebundene protokolliert nicht, der gebundene schon.
*/
function makeFakePrisma(userRows: FakeUserRow[] = [], queryRawResults: unknown[][] = [[]]) {
const users = new Map(userRows.map((u) => [u.id, { ...u }]));
const resetTokens: any[] = [];
const boundCallLog: BoundCall[] = [];
const queryRaw = fakeQueryRaw(queryRawResults);
function throwNotFound(): never {
const err: any = new Error('Record to update not found');
err.code = 'P2025';
throw err;
}
function makeScopedUser(tenantId: string) {
return {
findUnique: async ({ where, select }: any) => {
boundCallLog.push({ tenantId, model: 'user', method: 'findUnique', args: { where, select } });
const row = users.get(where.id);
if (!row || row.tenantId !== tenantId) return null;
if (!select) return { ...row };
const picked: any = {};
for (const key of Object.keys(select)) {
if (select[key]) picked[key] = (row as any)[key];
}
return picked;
},
update: async ({ where, data }: any) => {
boundCallLog.push({ tenantId, model: 'user', method: 'update', args: { where, data } });
const row = users.get(where.id);
if (!row || row.tenantId !== tenantId) throwNotFound();
const updated = { ...row, ...data };
users.set(where.id, updated);
return updated;
},
};
}
function makeScopedResetToken(tenantId: string) {
return {
create: async ({ data }: any) => {
boundCallLog.push({ tenantId, model: 'passwordResetToken', method: 'create', args: { data } });
const record = { id: `rt-${resetTokens.length + 1}`, usedAt: null, ...data };
resetTokens.push(record);
return record;
},
update: async ({ where, data }: any) => {
boundCallLog.push({ tenantId, model: 'passwordResetToken', method: 'update', args: { where, data } });
const idx = resetTokens.findIndex((t) => t.id === where.id);
if (idx === -1) throwNotFound();
resetTokens[idx] = { ...resetTokens[idx], ...data };
return resetTokens[idx];
},
};
}
const fake: any = {
$queryRaw: queryRaw.fn,
__users: users,
__resetTokens: resetTokens,
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string) {
return {
__isBoundClient: true,
__tenantId: tenantId,
user: makeScopedUser(tenantId),
passwordResetToken: makeScopedResetToken(tenantId),
};
},
};
return { prisma: fake, queryRaw };
}
function expectBoundCall(
prisma: any,
tenantId: string,
model: 'user' | 'passwordResetToken',
method: string,
) {
const found = prisma.__boundCallLog.some(
(c: BoundCall) => c.tenantId === tenantId && c.model === model && c.method === method,
);
expect(
found,
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(true);
}
/**
* 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
@@ -49,17 +176,16 @@ describe('AuthService.validateUser — LDAP login', () => {
passwordHash: null,
ldapDn: 'CN=alice,OU=Users,DC=ctl,DC=local',
isActive: true,
role: 'USER',
displayName: null,
mustChangePassword: false,
};
beforeEach(() => {
vi.clearAllMocks();
queryRaw = fakeQueryRaw([[ldapUser]]);
prisma = {
$queryRaw: queryRaw.fn,
user: {
update: vi.fn().mockResolvedValue({}),
},
};
const built = makeFakePrisma([ldapUser as FakeUserRow], [[ldapUser]]);
prisma = built.prisma;
queryRaw = built.queryRaw;
ldapService = { verifyUserCredentials: vi.fn() };
ldapConfigService = {
getConfig: vi.fn().mockResolvedValue({
@@ -92,10 +218,7 @@ describe('AuthService.validateUser — LDAP login', () => {
ldapUser.ldapDn,
'ad-password',
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'u1' },
data: { lastLoginAt: expect.any(Date) },
});
expectBoundCall(prisma, 't1', 'user', 'update');
});
it('rejects an LDAP user when the directory bind fails', async () => {
@@ -104,7 +227,7 @@ describe('AuthService.validateUser — LDAP login', () => {
const result = await service.validateUser('alice', 'wrong');
expect(result).toBeNull();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.__boundCallLog).toHaveLength(0);
});
it('rejects an LDAP user when no active LDAP config exists', async () => {
@@ -117,8 +240,8 @@ describe('AuthService.validateUser — LDAP login', () => {
});
it('rejects a passwordless user that has no ldapDn (never binds)', async () => {
queryRaw = fakeQueryRaw([[{ ...ldapUser, ldapDn: null }]]);
prisma.$queryRaw = queryRaw.fn;
const built = makeFakePrisma([{ ...ldapUser, ldapDn: null } as FakeUserRow], [[{ ...ldapUser, ldapDn: null }]]);
prisma.$queryRaw = built.queryRaw.fn;
const result = await service.validateUser('alice', 'pw');
@@ -153,6 +276,10 @@ describe('AuthService.validateUser — LDAP login', () => {
expect(result).toBeNull();
});
it('scheitert an "Cannot read properties of undefined", wenn die Suche versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => {
expect(prisma.user).toBeUndefined();
});
});
/**
@@ -172,6 +299,9 @@ describe('AuthService.validateUser — lokales Kennwort', () => {
passwordHash: string;
ldapDn: null;
isActive: boolean;
role: string;
displayName: null;
mustChangePassword: boolean;
};
beforeEach(async () => {
@@ -186,13 +316,14 @@ describe('AuthService.validateUser — lokales Kennwort', () => {
passwordHash: await argon2.hash('correct-password'),
ldapDn: null,
isActive: true,
role: 'USER',
displayName: null,
mustChangePassword: false,
};
queryRaw = fakeQueryRaw([[localUser]]);
prisma = {
$queryRaw: queryRaw.fn,
user: { update: vi.fn().mockResolvedValue({}) },
};
const built = makeFakePrisma([localUser as FakeUserRow], [[localUser]]);
prisma = built.prisma;
queryRaw = built.queryRaw;
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
});
@@ -200,11 +331,24 @@ describe('AuthService.validateUser — lokales Kennwort', () => {
const result = await service.validateUser('bob', 'correct-password');
expect(result).toEqual(localUser);
expect(prisma.user.update).toHaveBeenCalledWith({
expectBoundCall(prisma, 't1', 'user', 'update');
const updateCall = prisma.__boundCallLog.find(
(c: BoundCall) => c.model === 'user' && c.method === 'update',
);
expect(updateCall.args).toEqual({
where: { id: 'u2' },
data: { lastLoginAt: expect.any(Date) },
});
});
it('sucht die Anmeldedaten exakt EINMAL ungebunden ueber $queryRaw und schreibt lastLoginAt exakt EINMAL gebunden unter dem Mandanten der Funktionszeile — die Grenze zwischen Anmeldeweg und Nach-Anmeldung', async () => {
await service.validateUser('bob', 'correct-password');
expect(queryRaw.calls).toHaveLength(1);
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
expect(vi.mocked(forTenant).mock.calls[0][1]).toBe('t1');
expectBoundCall(prisma, 't1', 'user', 'update');
});
});
describe('AuthService.requestPasswordReset', () => {
@@ -213,15 +357,18 @@ describe('AuthService.requestPasswordReset', () => {
let mailService: any;
let queryRaw: ReturnType<typeof fakeQueryRaw>;
const emailUser = { id: 'u3', tenantId: 't1', email: 'bob@example.com', isActive: true };
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({}) },
};
const built = makeFakePrisma([], [[emailUser]]);
prisma = built.prisma;
queryRaw = built.queryRaw;
mailService = { sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined) };
service = new AuthService(prisma, {} as any, {} as any, mailService, {} as any, {} as any);
});
@@ -229,7 +376,11 @@ describe('AuthService.requestPasswordReset', () => {
it('legt das Rueckstell-Token mandantengebunden an, sobald der Benutzer gefunden ist', async () => {
await service.requestPasswordReset('bob@example.com');
expect(prisma.passwordResetToken.create).toHaveBeenCalledWith({
expectBoundCall(prisma, 't1', 'passwordResetToken', 'create');
const createCall = prisma.__boundCallLog.find(
(c: BoundCall) => c.model === 'passwordResetToken' && c.method === 'create',
);
expect(createCall.args).toEqual({
data: {
token: expect.any(String),
userId: 'u3',
@@ -248,7 +399,7 @@ describe('AuthService.requestPasswordReset', () => {
await service.requestPasswordReset('unknown@example.com');
expect(prisma.passwordResetToken.create).not.toHaveBeenCalled();
expect(prisma.__boundCallLog).toHaveLength(0);
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
});
});
@@ -269,23 +420,48 @@ describe('AuthService.resetPassword', () => {
beforeEach(() => {
vi.clearAllMocks();
queryRaw = fakeQueryRaw([[resetTokenRow]]);
prisma = {
$queryRaw: queryRaw.fn,
user: { update: vi.fn().mockResolvedValue({}) },
passwordResetToken: { update: vi.fn().mockResolvedValue({}) },
};
const built = makeFakePrisma(
[
{
id: 'u4',
tenantId: 't1',
username: 'dave',
passwordHash: 'old-hash',
ldapDn: null,
isActive: true,
role: 'USER',
displayName: null,
mustChangePassword: true,
},
],
[[resetTokenRow]],
);
prisma = built.prisma;
queryRaw = built.queryRaw;
// Das Token selbst existiert im gebundenen Nachbau nicht automatisch —
// fuer den Update-Zweig genuegt hier, dass das UPDATE ueber die
// Kennung `rt1` gelingt; deshalb wird der Datensatz vorab ueber die
// Anlage nachgebildet, bevor resetPassword() ihn aktualisiert.
prisma.__resetTokens.push({ id: 'rt1', token: 'a-uuid-token', usedAt: null });
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({
expectBoundCall(prisma, 't1', 'user', 'update');
expectBoundCall(prisma, 't1', 'passwordResetToken', 'update');
const userUpdate = prisma.__boundCallLog.find(
(c: BoundCall) => c.model === 'user' && c.method === 'update',
);
expect(userUpdate.args).toEqual({
where: { id: 'u4' },
data: { passwordHash: expect.any(String), mustChangePassword: false },
});
expect(prisma.passwordResetToken.update).toHaveBeenCalledWith({
const tokenUpdate = prisma.__boundCallLog.find(
(c: BoundCall) => c.model === 'passwordResetToken' && c.method === 'update',
);
expect(tokenUpdate.args).toEqual({
where: { id: 'rt1' },
data: { usedAt: expect.any(Date) },
});
@@ -300,3 +476,280 @@ describe('AuthService.resetPassword', () => {
);
});
});
/**
* getMe/changePassword/adminResetPassword — bisher OHNE einen einzigen
* Testfall (Befund F). Alle drei binden seit Aufgabe 2 an den Mandanten
* aus dem Sitzungsnachweis.
*/
describe('AuthService.getMe', () => {
let service: AuthService;
let prisma: any;
const localUserRow: FakeUserRow = {
id: 'u1',
tenantId: 't1',
username: 'alice',
passwordHash: 'a-hash',
ldapDn: null,
isActive: true,
role: 'USER',
displayName: 'Alice',
mustChangePassword: false,
avatarPath: 'avatars/u1.png',
accentColor: '#3b82f6',
};
const ldapUserRow: FakeUserRow = {
id: 'u2',
tenantId: 't1',
username: 'bob',
passwordHash: null,
ldapDn: 'CN=bob,OU=Users,DC=ctl,DC=local',
isActive: true,
role: 'USER',
displayName: 'Bob',
mustChangePassword: false,
avatarPath: null,
accentColor: null,
};
beforeEach(() => {
vi.clearAllMocks();
const built = makeFakePrisma([localUserRow, ldapUserRow]);
prisma = built.prisma;
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
});
it('eigener Mandant, lokaler Benutzer: liefert die oeffentlichen Felder, isLocalUser true, hasAvatar true — passwordHash/ldapDn/avatarPath fehlen (T-gbh-03)', async () => {
const result = await service.getMe('t1', 'u1');
expect(result).toMatchObject({
id: 'u1',
username: 'alice',
displayName: 'Alice',
role: 'USER',
tenantId: 't1',
mustChangePassword: false,
accentColor: '#3b82f6',
isLocalUser: true,
hasAvatar: true,
});
expect(result).not.toHaveProperty('passwordHash');
expect(result).not.toHaveProperty('ldapDn');
expect(result).not.toHaveProperty('avatarPath');
});
it('eigener Mandant, LDAP-Benutzer (kein Hash, ldapDn gesetzt): isLocalUser false', async () => {
const result = await service.getMe('t1', 'u2');
expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false });
});
it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => {
const result = await service.getMe('t2', 'u1');
expect(result).toBeNull();
});
it('erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
vi.mocked(forTenant).mockClear();
await service.getMe('t1', 'u1');
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
expect(vi.mocked(forTenant).mock.calls[0][1]).toBe('t1');
});
});
describe('AuthService.changePassword', () => {
let service: AuthService;
let prisma: any;
let jwtService: any;
let configService: any;
let response: any;
let localUserRow: FakeUserRow;
let ldapUserRow: FakeUserRow;
beforeEach(async () => {
vi.clearAllMocks();
const argon2 = await import('argon2');
localUserRow = {
id: 'u1',
tenantId: 't1',
username: 'alice',
passwordHash: await argon2.hash('current-password'),
ldapDn: null,
isActive: true,
role: 'USER',
displayName: 'Alice',
mustChangePassword: false,
};
ldapUserRow = {
id: 'u2',
tenantId: 't1',
username: 'bob',
passwordHash: null,
ldapDn: 'CN=bob,OU=Users,DC=ctl,DC=local',
isActive: true,
role: 'USER',
displayName: 'Bob',
mustChangePassword: false,
};
const built = makeFakePrisma([localUserRow, ldapUserRow]);
prisma = built.prisma;
jwtService = { sign: vi.fn().mockReturnValue('signed.jwt.token') };
configService = { get: vi.fn().mockReturnValue('development') };
response = { cookie: vi.fn() };
service = new AuthService(prisma, jwtService, configService, {} as any, {} as any, {} as any);
});
it('eigener Mandant, richtiges aktuelles Kennwort: gebundenes update traegt neuen Hash und mustChangePassword=false, JWT signiert mit tenantId/mustChangePassword, Cookie gesetzt', async () => {
const argon2 = await import('argon2');
await service.changePassword('t1', 'u1', 'current-password', 'brand-new-password', response);
const updateCall = prisma.__boundCallLog.find(
(c: BoundCall) => c.model === 'user' && c.method === 'update',
);
expect(updateCall).toBeDefined();
expect(updateCall.args.where).toEqual({ id: 'u1' });
expect(updateCall.args.data.mustChangePassword).toBe(false);
const isNewHashValid = await argon2.verify(
updateCall.args.data.passwordHash,
'brand-new-password',
);
expect(isNewHashValid).toBe(true);
expect(jwtService.sign).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 't1', mustChangePassword: false }),
);
expect(response.cookie).toHaveBeenCalledWith(
'session',
'signed.jwt.token',
expect.objectContaining({ httpOnly: true }),
);
});
it('FREMDER Mandant: UnauthorizedException, kein Schreibzugriff, kein Cookie', async () => {
await expect(
service.changePassword('t2', 'u1', 'current-password', 'new-password', response),
).rejects.toThrow('User not found or has no local password');
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
expect(response.cookie).not.toHaveBeenCalled();
});
it('falsches aktuelles Kennwort: Current password is incorrect, kein Schreibzugriff', async () => {
await expect(
service.changePassword('t1', 'u1', 'wrong-password', 'new-password', response),
).rejects.toThrow('Current password is incorrect');
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
});
it('LDAP-Benutzer (kein Hash): UnauthorizedException, kein Schreibzugriff', async () => {
await expect(
service.changePassword('t1', 'u2', 'anything', 'new-password', response),
).rejects.toThrow('User not found or has no local password');
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
});
it('erzeugt genau EINEN gebundenen Klienten je Aufruf (Suche und Schreiben auf demselben)', async () => {
vi.mocked(forTenant).mockClear();
await service.changePassword('t1', 'u1', 'current-password', 'new-password', response);
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
});
});
describe('AuthService.adminResetPassword', () => {
let service: AuthService;
let prisma: any;
let targetUserRow: FakeUserRow;
let superAdminTargetRow: FakeUserRow;
beforeEach(() => {
vi.clearAllMocks();
targetUserRow = {
id: 'target',
tenantId: 't1',
username: 'carol',
passwordHash: 'old-hash',
ldapDn: null,
isActive: true,
role: 'USER',
displayName: 'Carol',
mustChangePassword: false,
};
superAdminTargetRow = {
id: 'boss',
tenantId: 't1',
username: 'dora',
passwordHash: 'old-hash',
ldapDn: null,
isActive: true,
role: 'SUPER_ADMIN',
displayName: 'Dora',
mustChangePassword: false,
};
const built = makeFakePrisma([targetUserRow, superAdminTargetRow]);
prisma = built.prisma;
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
});
it('eigener Mandant, Aufrufer ADMIN, Ziel USER: gebundenes update mit neuem Hash, mustChangePassword TRUE (Vorgabe, Parameter weggelassen)', async () => {
const argon2 = await import('argon2');
await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password');
const updateCall = prisma.__boundCallLog.find(
(c: BoundCall) => c.model === 'user' && c.method === 'update',
);
expect(updateCall.args.where).toEqual({ id: 'target' });
expect(updateCall.args.data.mustChangePassword).toBe(true);
const ok = await argon2.verify(updateCall.args.data.passwordHash, 'fresh-password');
expect(ok).toBe(true);
});
it('mustChangePassword: false wird durchgereicht', async () => {
await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password', false);
const updateCall = prisma.__boundCallLog.find(
(c: BoundCall) => c.model === 'user' && c.method === 'update',
);
expect(updateCall.args.data.mustChangePassword).toBe(false);
});
it('FREMDER Mandant: BadRequestException, Meldung woertlich "User not found", KEIN Schreibzugriff — nennt weder Halter noch Mandanten', async () => {
await expect(
service.adminResetPassword('t2', Role.ADMIN, 'target', 'fresh-password'),
).rejects.toThrow('User not found');
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
});
it('Aufrufer ADMIN, Ziel SUPER_ADMIN im SELBEN Mandanten: ForbiddenException (T-FH9-04), KEIN Schreibzugriff', async () => {
await expect(
service.adminResetPassword('t1', Role.ADMIN, 'boss', 'fresh-password'),
).rejects.toThrow(); // ForbiddenException
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
});
it('Aufrufer SUPER_ADMIN, Ziel SUPER_ADMIN: gelingt', async () => {
await service.adminResetPassword('t1', Role.SUPER_ADMIN, 'boss', 'fresh-password');
expectBoundCall(prisma, 't1', 'user', 'update');
});
it('erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
vi.mocked(forTenant).mockClear();
await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password');
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
});
});
+69 -6
View File
@@ -1,11 +1,13 @@
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';
@@ -48,6 +50,33 @@ interface AuthLookupResetTokenRow {
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);
@@ -268,9 +297,17 @@ export class AuthService {
* 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(userId: string) {
const user = await this.prisma.user.findUnique({
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,
@@ -302,14 +339,20 @@ export class AuthService {
/**
* 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 user = await this.prisma.user.findUnique({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const user = await tenantPrisma.user.findUnique({
where: { id: userId },
});
@@ -323,7 +366,7 @@ export class AuthService {
}
const passwordHash = await argon2.hash(newPassword);
await this.prisma.user.update({
await tenantPrisma.user.update({
where: { id: userId },
data: { passwordHash, mustChangePassword: false },
});
@@ -350,13 +393,29 @@ export class AuthService {
/**
* 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. Der Schwesterweg `PATCH /users/:id` hat
* dieselbe Luecke nicht geschlossen — offener Ledger-Eintrag T-FH9-05.
*/
async adminResetPassword(
tenantId: string,
callerRole: Role,
userId: string,
newPassword: string,
mustChangePassword: boolean = true,
): Promise<void> {
const user = await this.prisma.user.findUnique({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const user = await tenantPrisma.user.findUnique({
where: { id: userId },
});
@@ -364,8 +423,12 @@ export class AuthService {
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 this.prisma.user.update({
await tenantPrisma.user.update({
where: { id: userId },
data: {
passwordHash,