Files
tessera-ctl/apps/api/src/auth/auth.controller.ts
T
schalli f2fc39f51c refactor(quick-260921-m34): Aufgabe 2a - gemeinsamer Aufrufer-Typ, aus den Signierstellen abgeleitet
apps/api/src/auth/types/auth-user.ts angelegt: AuthUser, AuthenticatedRequest,
LocalAuthenticatedRequest, LoginUser, JwtPayload, UploadedFileLike. Jedes Feld
traegt seine Herkunft als Kommentar.

tenantId ist string, hergeleitet und nicht gewaehlt: die Spalte User.tenantId
ist in schema.prisma Pflicht, beide Signierstellen schreiben genau sie, und
der Bestand beschreibt dasselbe Objekt in SessionUser schon so. Der
SUPER_ADMIN-Zweig in TenantGuard spricht nicht dagegen - der Waechter liest
AuthUser gar nicht, und dass es den Zweig gibt, steht als null in
AuthenticatedRequest.tenantId weiter im Typsystem. tenant.guard.ts bleibt
unberuehrt.

role ist die Aufzaehlung Role: schema.prisma deklariert die Spalte so, die
SQL-Funktion auth_lookup_user_by_username gibt sie als "Role" zurueck. Die
Handannotation role: string in AuthLookupUserByUsernameRow war eine zweite
Fassung desselben Wertes und faellt damit weg.

SessionUser und UploadedPng in bug-reports.service.ts sind jetzt Pick<> der
neuen Typen statt eigener Beschreibungen.

Fixtures in auth.controller.spec.ts ergaenzt: sie uebergaben einen Aufrufer
ohne username und ohne mustChangePassword - eine Form, die JwtStrategy nie
erzeugt. Testzahlen unveraendert.

noExplicitAny in apps/api/src: 149 -> 137. type-check 4/4, lint 5/5,
apps/api 72/1143, apps/web 73/531.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:03:24 +02:00

180 lines
5.8 KiB
TypeScript

import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Role } from '@prisma/client';
import { 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';
import { Roles } from './decorators/roles.decorator';
import { AdminResetPasswordDto } from './dto/admin-reset-password.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { RequestResetDto, ResetPasswordDto } from './dto/reset-password.dto';
import { RolesGuard } from './guards/roles.guard';
import type { AuthUser, LocalAuthenticatedRequest } from './types/auth-user';
@Controller('auth')
export class AuthController {
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: AuthUser, 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
* Validates credentials via Passport local strategy, then issues JWT cookie.
*/
@Public()
@UseGuards(AuthGuard('local'))
@Post('login')
@HttpCode(200)
async login(
@Req() req: LocalAuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
) {
return this.authService.login(req.user, res);
}
/**
* POST /auth/logout
* Clears the session cookie.
*/
@Post('logout')
@HttpCode(200)
logout(@Res({ passthrough: true }) res: Response) {
this.authService.logout(res);
return { message: 'Logged out' };
}
/**
* 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: AuthUser) {
return this.authService.getMe(user.tenantId, user.id);
}
/**
* POST /auth/request-reset
* Request a password reset email (D-03 self-service).
* @Public() -- no authentication required.
* T-02-12: Always returns 200 regardless of email existence.
*/
@Public()
@Post('request-reset')
@HttpCode(200)
async requestReset(@Body() dto: RequestResetDto) {
await this.authService.requestPasswordReset(dto.email);
return { message: 'If an account with this email exists, a reset link has been sent.' };
}
/**
* POST /auth/reset-password
* Reset password using a valid token (D-03 self-service).
* @Public() -- no authentication required (uses token for verification).
*/
@Public()
@Post('reset-password')
@HttpCode(200)
async resetPassword(@Body() dto: ResetPasswordDto) {
await this.authService.resetPassword(dto.token, dto.newPassword);
return { message: 'Password has been reset successfully.' };
}
/**
* POST /auth/change-password
* Change password for the currently logged-in user.
* Requires authentication (not @Public).
*/
@Post('change-password')
@HttpCode(200)
async changePassword(
@CurrentUser() user: AuthUser,
@Body() dto: ChangePasswordDto,
@Res({ passthrough: true }) res: Response,
) {
await this.authService.changePassword(
user.tenantId,
user.id,
dto.currentPassword,
dto.newPassword,
res,
);
return { message: 'Password changed successfully.' };
}
/**
* 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)
@UseGuards(RolesGuard)
@HttpCode(200)
async adminResetPassword(
@Param('userId') userId: string,
@Body() dto: AdminResetPasswordDto,
@CurrentUser() currentUser: AuthUser,
) {
const tenantId = await this.resolveTargetTenantId(currentUser, userId);
await this.authService.adminResetPassword(
tenantId,
currentUser.role,
userId,
dto.newPassword,
dto.mustChangePassword ?? true,
);
return { message: 'User password has been reset.' };
}
}