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:
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { UserService } from '../user/user.service';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CurrentUser } from './decorators/current-user.decorator';
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
import { Public } from './decorators/public.decorator';
|
import { Public } from './decorators/public.decorator';
|
||||||
@@ -23,7 +25,33 @@ import { RolesGuard } from './guards/roles.guard';
|
|||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
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
|
* POST /auth/login
|
||||||
@@ -55,10 +83,16 @@ export class AuthController {
|
|||||||
* GET /auth/me
|
* GET /auth/me
|
||||||
* Returns enriched user profile: public fields + isLocalUser + hasAvatar.
|
* Returns enriched user profile: public fields + isLocalUser + hasAvatar.
|
||||||
* T-gbh-03: passwordHash and ldapDn are never serialised in the response.
|
* 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')
|
@Get('me')
|
||||||
async me(@CurrentUser() user: any) {
|
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,
|
@Res({ passthrough: true }) res: Response,
|
||||||
) {
|
) {
|
||||||
await this.authService.changePassword(
|
await this.authService.changePassword(
|
||||||
|
user.tenantId,
|
||||||
user.id,
|
user.id,
|
||||||
dto.currentPassword,
|
dto.currentPassword,
|
||||||
dto.newPassword,
|
dto.newPassword,
|
||||||
@@ -113,6 +148,13 @@ export class AuthController {
|
|||||||
* POST /auth/admin-reset-password/:userId
|
* POST /auth/admin-reset-password/:userId
|
||||||
* Admin resets a user's password (D-03 admin reset).
|
* Admin resets a user's password (D-03 admin reset).
|
||||||
* T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard.
|
* 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')
|
@Post('admin-reset-password/:userId')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
@@ -121,8 +163,12 @@ export class AuthController {
|
|||||||
async adminResetPassword(
|
async adminResetPassword(
|
||||||
@Param('userId') userId: string,
|
@Param('userId') userId: string,
|
||||||
@Body() dto: AdminResetPasswordDto,
|
@Body() dto: AdminResetPasswordDto,
|
||||||
|
@CurrentUser() currentUser: any,
|
||||||
) {
|
) {
|
||||||
|
const tenantId = await this.resolveTargetTenantId(currentUser, userId);
|
||||||
await this.authService.adminResetPassword(
|
await this.authService.adminResetPassword(
|
||||||
|
tenantId,
|
||||||
|
currentUser.role,
|
||||||
userId,
|
userId,
|
||||||
dto.newPassword,
|
dto.newPassword,
|
||||||
dto.mustChangePassword ?? true,
|
dto.mustChangePassword ?? true,
|
||||||
|
|||||||
@@ -4,11 +4,24 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { LdapModule } from '../ldap/ldap.module';
|
import { LdapModule } from '../ldap/ldap.module';
|
||||||
import { MailModule } from '../mail/mail.module';
|
import { MailModule } from '../mail/mail.module';
|
||||||
|
import { UserModule } from '../user/user.module';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { LocalStrategy } from './strategies/local.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PassportModule,
|
PassportModule,
|
||||||
@@ -21,6 +34,7 @@ import { LocalStrategy } from './strategies/local.strategy';
|
|||||||
}),
|
}),
|
||||||
MailModule,
|
MailModule,
|
||||||
LdapModule,
|
LdapModule,
|
||||||
|
UserModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
import { AuthService } from './auth.service';
|
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
|
* Aufgabe 2 (260911-fh9): die Identitaets-Attrappe verschwindet. Der
|
||||||
// ldap.service.spec.ts.
|
* 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', () => ({
|
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 };
|
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
|
* 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
|
* have no local passwordHash and must be authenticated by binding as their own
|
||||||
@@ -49,17 +176,16 @@ describe('AuthService.validateUser — LDAP login', () => {
|
|||||||
passwordHash: null,
|
passwordHash: null,
|
||||||
ldapDn: 'CN=alice,OU=Users,DC=ctl,DC=local',
|
ldapDn: 'CN=alice,OU=Users,DC=ctl,DC=local',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
role: 'USER',
|
||||||
|
displayName: null,
|
||||||
|
mustChangePassword: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
queryRaw = fakeQueryRaw([[ldapUser]]);
|
const built = makeFakePrisma([ldapUser as FakeUserRow], [[ldapUser]]);
|
||||||
prisma = {
|
prisma = built.prisma;
|
||||||
$queryRaw: queryRaw.fn,
|
queryRaw = built.queryRaw;
|
||||||
user: {
|
|
||||||
update: vi.fn().mockResolvedValue({}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
ldapService = { verifyUserCredentials: vi.fn() };
|
ldapService = { verifyUserCredentials: vi.fn() };
|
||||||
ldapConfigService = {
|
ldapConfigService = {
|
||||||
getConfig: vi.fn().mockResolvedValue({
|
getConfig: vi.fn().mockResolvedValue({
|
||||||
@@ -92,10 +218,7 @@ describe('AuthService.validateUser — LDAP login', () => {
|
|||||||
ldapUser.ldapDn,
|
ldapUser.ldapDn,
|
||||||
'ad-password',
|
'ad-password',
|
||||||
);
|
);
|
||||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||||
where: { id: 'u1' },
|
|
||||||
data: { lastLoginAt: expect.any(Date) },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an LDAP user when the directory bind fails', async () => {
|
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');
|
const result = await service.validateUser('alice', 'wrong');
|
||||||
|
|
||||||
expect(result).toBeNull();
|
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 () => {
|
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 () => {
|
it('rejects a passwordless user that has no ldapDn (never binds)', async () => {
|
||||||
queryRaw = fakeQueryRaw([[{ ...ldapUser, ldapDn: null }]]);
|
const built = makeFakePrisma([{ ...ldapUser, ldapDn: null } as FakeUserRow], [[{ ...ldapUser, ldapDn: null }]]);
|
||||||
prisma.$queryRaw = queryRaw.fn;
|
prisma.$queryRaw = built.queryRaw.fn;
|
||||||
|
|
||||||
const result = await service.validateUser('alice', 'pw');
|
const result = await service.validateUser('alice', 'pw');
|
||||||
|
|
||||||
@@ -153,6 +276,10 @@ describe('AuthService.validateUser — LDAP login', () => {
|
|||||||
|
|
||||||
expect(result).toBeNull();
|
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;
|
passwordHash: string;
|
||||||
ldapDn: null;
|
ldapDn: null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
role: string;
|
||||||
|
displayName: null;
|
||||||
|
mustChangePassword: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -186,13 +316,14 @@ describe('AuthService.validateUser — lokales Kennwort', () => {
|
|||||||
passwordHash: await argon2.hash('correct-password'),
|
passwordHash: await argon2.hash('correct-password'),
|
||||||
ldapDn: null,
|
ldapDn: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
role: 'USER',
|
||||||
|
displayName: null,
|
||||||
|
mustChangePassword: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
queryRaw = fakeQueryRaw([[localUser]]);
|
const built = makeFakePrisma([localUser as FakeUserRow], [[localUser]]);
|
||||||
prisma = {
|
prisma = built.prisma;
|
||||||
$queryRaw: queryRaw.fn,
|
queryRaw = built.queryRaw;
|
||||||
user: { update: vi.fn().mockResolvedValue({}) },
|
|
||||||
};
|
|
||||||
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
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');
|
const result = await service.validateUser('bob', 'correct-password');
|
||||||
|
|
||||||
expect(result).toEqual(localUser);
|
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' },
|
where: { id: 'u2' },
|
||||||
data: { lastLoginAt: expect.any(Date) },
|
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', () => {
|
describe('AuthService.requestPasswordReset', () => {
|
||||||
@@ -213,15 +357,18 @@ describe('AuthService.requestPasswordReset', () => {
|
|||||||
let mailService: any;
|
let mailService: any;
|
||||||
let queryRaw: ReturnType<typeof fakeQueryRaw>;
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
queryRaw = fakeQueryRaw([[emailUser]]);
|
const built = makeFakePrisma([], [[emailUser]]);
|
||||||
prisma = {
|
prisma = built.prisma;
|
||||||
$queryRaw: queryRaw.fn,
|
queryRaw = built.queryRaw;
|
||||||
passwordResetToken: { create: vi.fn().mockResolvedValue({}) },
|
|
||||||
};
|
|
||||||
mailService = { sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined) };
|
mailService = { sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined) };
|
||||||
service = new AuthService(prisma, {} as any, {} as any, mailService, {} as any, {} as any);
|
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 () => {
|
it('legt das Rueckstell-Token mandantengebunden an, sobald der Benutzer gefunden ist', async () => {
|
||||||
await service.requestPasswordReset('bob@example.com');
|
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: {
|
data: {
|
||||||
token: expect.any(String),
|
token: expect.any(String),
|
||||||
userId: 'u3',
|
userId: 'u3',
|
||||||
@@ -248,7 +399,7 @@ describe('AuthService.requestPasswordReset', () => {
|
|||||||
|
|
||||||
await service.requestPasswordReset('unknown@example.com');
|
await service.requestPasswordReset('unknown@example.com');
|
||||||
|
|
||||||
expect(prisma.passwordResetToken.create).not.toHaveBeenCalled();
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
|
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -269,23 +420,48 @@ describe('AuthService.resetPassword', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
queryRaw = fakeQueryRaw([[resetTokenRow]]);
|
const built = makeFakePrisma(
|
||||||
prisma = {
|
[
|
||||||
$queryRaw: queryRaw.fn,
|
{
|
||||||
user: { update: vi.fn().mockResolvedValue({}) },
|
id: 'u4',
|
||||||
passwordResetToken: { update: vi.fn().mockResolvedValue({}) },
|
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);
|
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 () => {
|
it('findet den passenden Rueckstell-Datensatz und aktualisiert Kennwort und Token mandantengebunden', async () => {
|
||||||
await service.resetPassword('a-uuid-token', 'new-password');
|
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' },
|
where: { id: 'u4' },
|
||||||
data: { passwordHash: expect.any(String), mustChangePassword: false },
|
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' },
|
where: { id: 'rt1' },
|
||||||
data: { usedAt: expect.any(Date) },
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
@@ -48,6 +50,33 @@ interface AuthLookupResetTokenRow {
|
|||||||
tenantId: string;
|
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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly logger = new Logger(AuthService.name);
|
private readonly logger = new Logger(AuthService.name);
|
||||||
@@ -268,9 +297,17 @@ export class AuthService {
|
|||||||
* Return enriched profile for the currently authenticated user.
|
* Return enriched profile for the currently authenticated user.
|
||||||
* T-gbh-03: Only public fields + isLocalUser/hasAvatar returned — never
|
* T-gbh-03: Only public fields + isLocalUser/hasAvatar returned — never
|
||||||
* passwordHash or ldapDn.
|
* 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) {
|
async getMe(tenantId: string, userId: string) {
|
||||||
const user = await this.prisma.user.findUnique({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const user = await tenantPrisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -302,14 +339,20 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Change password for the currently logged-in user.
|
* Change password for the currently logged-in user.
|
||||||
* Verifies current password before allowing change.
|
* 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(
|
async changePassword(
|
||||||
|
tenantId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
currentPassword: string,
|
currentPassword: string,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
response: Response,
|
response: Response,
|
||||||
): Promise<void> {
|
): 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 },
|
where: { id: userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -323,7 +366,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await argon2.hash(newPassword);
|
const passwordHash = await argon2.hash(newPassword);
|
||||||
await this.prisma.user.update({
|
await tenantPrisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { passwordHash, mustChangePassword: false },
|
data: { passwordHash, mustChangePassword: false },
|
||||||
});
|
});
|
||||||
@@ -350,13 +393,29 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Admin reset of a user's password (D-03 admin reset).
|
* Admin reset of a user's password (D-03 admin reset).
|
||||||
* T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard.
|
* 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(
|
async adminResetPassword(
|
||||||
|
tenantId: string,
|
||||||
|
callerRole: Role,
|
||||||
userId: string,
|
userId: string,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
mustChangePassword: boolean = true,
|
mustChangePassword: boolean = true,
|
||||||
): Promise<void> {
|
): 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 },
|
where: { id: userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -364,8 +423,12 @@ export class AuthService {
|
|||||||
throw new BadRequestException('User not found');
|
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);
|
const passwordHash = await argon2.hash(newPassword);
|
||||||
await this.prisma.user.update({
|
await tenantPrisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
passwordHash,
|
passwordHash,
|
||||||
|
|||||||
Reference in New Issue
Block a user