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
This commit is contained in:
@@ -36,7 +36,7 @@ describe('AuthController.me', () => {
|
|||||||
authService.getMe.mockResolvedValue({ id: 'u1' });
|
authService.getMe.mockResolvedValue({ id: 'u1' });
|
||||||
const controller = new AuthController(authService, makeFakeUserService());
|
const controller = new AuthController(authService, makeFakeUserService());
|
||||||
|
|
||||||
await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' });
|
await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false });
|
||||||
|
|
||||||
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
|
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
|
||||||
});
|
});
|
||||||
@@ -46,7 +46,7 @@ describe('AuthController.me', () => {
|
|||||||
authService.getMe.mockResolvedValue({ id: 'u1' });
|
authService.getMe.mockResolvedValue({ id: 'u1' });
|
||||||
const controller = new AuthController(authService, makeFakeUserService());
|
const controller = new AuthController(authService, makeFakeUserService());
|
||||||
|
|
||||||
await controller.me({ id: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN });
|
await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false });
|
||||||
|
|
||||||
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
|
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
|
||||||
});
|
});
|
||||||
@@ -56,7 +56,7 @@ describe('AuthController.me', () => {
|
|||||||
authService.getMe.mockResolvedValue(null);
|
authService.getMe.mockResolvedValue(null);
|
||||||
const controller = new AuthController(authService, makeFakeUserService());
|
const controller = new AuthController(authService, makeFakeUserService());
|
||||||
|
|
||||||
const result = await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' });
|
const result = await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false });
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -69,7 +69,7 @@ describe('AuthController.changePassword', () => {
|
|||||||
const res = {} as any;
|
const res = {} as any;
|
||||||
|
|
||||||
const result = await controller.changePassword(
|
const result = await controller.changePassword(
|
||||||
{ id: 'u1', tenantId: 't1', role: 'USER' },
|
{ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false },
|
||||||
{ currentPassword: 'old', newPassword: 'new' } as any,
|
{ currentPassword: 'old', newPassword: 'new' } as any,
|
||||||
res,
|
res,
|
||||||
);
|
);
|
||||||
@@ -88,7 +88,7 @@ describe('AuthController.adminResetPassword', () => {
|
|||||||
const result = await controller.adminResetPassword(
|
const result = await controller.adminResetPassword(
|
||||||
'target',
|
'target',
|
||||||
{ newPassword: 'new-password' } as any,
|
{ newPassword: 'new-password' } as any,
|
||||||
{ id: 'admin-1', tenantId: 't1', role: Role.ADMIN },
|
{ id: 'admin-1', username: 'admin-1', tenantId: 't1', role: Role.ADMIN, mustChangePassword: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(authService.adminResetPassword).toHaveBeenCalledWith(
|
expect(authService.adminResetPassword).toHaveBeenCalledWith(
|
||||||
@@ -109,7 +109,7 @@ describe('AuthController.adminResetPassword', () => {
|
|||||||
await controller.adminResetPassword(
|
await controller.adminResetPassword(
|
||||||
'target',
|
'target',
|
||||||
{ newPassword: 'new-password', mustChangePassword: false } as any,
|
{ newPassword: 'new-password', mustChangePassword: false } as any,
|
||||||
{ id: 'admin-1', tenantId: 't1', role: Role.ADMIN },
|
{ id: 'admin-1', username: 'admin-1', tenantId: 't1', role: Role.ADMIN, mustChangePassword: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(authService.adminResetPassword).toHaveBeenCalledWith(
|
expect(authService.adminResetPassword).toHaveBeenCalledWith(
|
||||||
@@ -134,7 +134,7 @@ describe('AuthController.adminResetPassword', () => {
|
|||||||
await controller.adminResetPassword(
|
await controller.adminResetPassword(
|
||||||
'target',
|
'target',
|
||||||
{ newPassword: 'new-password' } as any,
|
{ newPassword: 'new-password' } as any,
|
||||||
{ id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN },
|
{ id: 'super-1', username: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledTimes(1);
|
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledTimes(1);
|
||||||
@@ -158,7 +158,7 @@ describe('AuthController.adminResetPassword', () => {
|
|||||||
controller.adminResetPassword(
|
controller.adminResetPassword(
|
||||||
'unknown',
|
'unknown',
|
||||||
{ newPassword: 'new-password' } as any,
|
{ newPassword: 'new-password' } as any,
|
||||||
{ id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN },
|
{ id: 'super-1', username: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false },
|
||||||
),
|
),
|
||||||
).rejects.toThrow(new BadRequestException('User not found'));
|
).rejects.toThrow(new BadRequestException('User not found'));
|
||||||
expect(authService.adminResetPassword).not.toHaveBeenCalled();
|
expect(authService.adminResetPassword).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
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 { Response } from 'express';
|
||||||
import { UserService } from '../user/user.service';
|
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';
|
||||||
@@ -22,6 +22,7 @@ import { AdminResetPasswordDto } from './dto/admin-reset-password.dto';
|
|||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
import { RequestResetDto, ResetPasswordDto } from './dto/reset-password.dto';
|
import { RequestResetDto, ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
import { RolesGuard } from './guards/roles.guard';
|
import { RolesGuard } from './guards/roles.guard';
|
||||||
|
import type { AuthUser, LocalAuthenticatedRequest } from './types/auth-user';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -42,7 +43,7 @@ export class AuthController {
|
|||||||
* Mandantenpruefung warf, damit ein API-Aufrufer denselben Statuscode
|
* Mandantenpruefung warf, damit ein API-Aufrufer denselben Statuscode
|
||||||
* sieht wie vor dieser Umstellung.
|
* sieht wie vor dieser Umstellung.
|
||||||
*/
|
*/
|
||||||
private async resolveTargetTenantId(currentUser: any, userId: string): Promise<string> {
|
private async resolveTargetTenantId(currentUser: AuthUser, userId: string): Promise<string> {
|
||||||
if (currentUser.role === Role.SUPER_ADMIN) {
|
if (currentUser.role === Role.SUPER_ADMIN) {
|
||||||
const target = await this.userService.findByIdForPlatformAdmin(userId);
|
const target = await this.userService.findByIdForPlatformAdmin(userId);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -62,7 +63,7 @@ export class AuthController {
|
|||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async login(
|
async login(
|
||||||
@Req() req: Request,
|
@Req() req: LocalAuthenticatedRequest,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
) {
|
) {
|
||||||
return this.authService.login(req.user, res);
|
return this.authService.login(req.user, res);
|
||||||
@@ -91,7 +92,7 @@ export class AuthController {
|
|||||||
* Befund C).
|
* Befund C).
|
||||||
*/
|
*/
|
||||||
@Get('me')
|
@Get('me')
|
||||||
async me(@CurrentUser() user: any) {
|
async me(@CurrentUser() user: AuthUser) {
|
||||||
return this.authService.getMe(user.tenantId, user.id);
|
return this.authService.getMe(user.tenantId, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ export class AuthController {
|
|||||||
@Post('change-password')
|
@Post('change-password')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async changePassword(
|
async changePassword(
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: AuthUser,
|
||||||
@Body() dto: ChangePasswordDto,
|
@Body() dto: ChangePasswordDto,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
) {
|
) {
|
||||||
@@ -163,7 +164,7 @@ export class AuthController {
|
|||||||
async adminResetPassword(
|
async adminResetPassword(
|
||||||
@Param('userId') userId: string,
|
@Param('userId') userId: string,
|
||||||
@Body() dto: AdminResetPasswordDto,
|
@Body() dto: AdminResetPasswordDto,
|
||||||
@CurrentUser() currentUser: any,
|
@CurrentUser() currentUser: AuthUser,
|
||||||
) {
|
) {
|
||||||
const tenantId = await this.resolveTargetTenantId(currentUser, userId);
|
const tenantId = await this.resolveTargetTenantId(currentUser, userId);
|
||||||
await this.authService.adminResetPassword(
|
await this.authService.adminResetPassword(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { LdapService } from '../ldap/ldap.service';
|
|||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import type { JwtPayload, LoginUser } from './types/auth-user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zeilenform der drei auth_lookup_*-Datenbankfunktionen
|
* Zeilenform der drei auth_lookup_*-Datenbankfunktionen
|
||||||
@@ -29,7 +30,12 @@ interface AuthLookupUserByUsernameRow {
|
|||||||
passwordHash: string | null;
|
passwordHash: string | null;
|
||||||
ldapDn: string | null;
|
ldapDn: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
role: string;
|
/**
|
||||||
|
* Die SQL-Funktion deklariert diese Spalte als `role "Role"` (Migration
|
||||||
|
* 20260909160000, Zeile 64) — `string` war hier eine weitere Fassung
|
||||||
|
* desselben Wertes, nicht seine Beschreibung (quick-260921-m34).
|
||||||
|
*/
|
||||||
|
role: Role;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
}
|
}
|
||||||
@@ -104,7 +110,10 @@ export class AuthService {
|
|||||||
* T-02-01: Returns null on any failure (never reveals which field is wrong).
|
* 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.
|
* Pitfall 6: Checks isActive to prevent deactivated users from logging in.
|
||||||
*/
|
*/
|
||||||
async validateUser(username: string, password: string): Promise<any> {
|
async validateUser(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<AuthLookupUserByUsernameRow | null> {
|
||||||
// Usernames are stored lowercase (case-insensitive login).
|
// Usernames are stored lowercase (case-insensitive login).
|
||||||
const rows = await this.prisma.$queryRaw<AuthLookupUserByUsernameRow[]>`
|
const rows = await this.prisma.$queryRaw<AuthLookupUserByUsernameRow[]>`
|
||||||
SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()})
|
SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()})
|
||||||
@@ -167,8 +176,8 @@ export class AuthService {
|
|||||||
* D-02: 30-day session.
|
* D-02: 30-day session.
|
||||||
* T-02-02: httpOnly + secure (prod) + sameSite=lax.
|
* T-02-02: httpOnly + secure (prod) + sameSite=lax.
|
||||||
*/
|
*/
|
||||||
async login(user: any, response: Response) {
|
async login(user: LoginUser, response: Response) {
|
||||||
const payload = {
|
const payload: JwtPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
@@ -373,7 +382,7 @@ export class AuthService {
|
|||||||
data: { passwordHash, mustChangePassword: false },
|
data: { passwordHash, mustChangePassword: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = {
|
const payload: JwtPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
import type { AuthenticatedRequest } from '../types/auth-user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global interceptor: forces users with mustChangePassword=true to change
|
* Global interceptor: forces users with mustChangePassword=true to change
|
||||||
@@ -30,7 +31,7 @@ const ALLOWED_ROUTES = Object.freeze([
|
|||||||
{ method: 'GET', path: '/auth/me' },
|
{ method: 'GET', path: '/auth/me' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function normalizePath(request: any): string {
|
function normalizePath(request: AuthenticatedRequest): string {
|
||||||
const raw = request.route?.path || request.url || '';
|
const raw = request.route?.path || request.url || '';
|
||||||
const withoutQuery = raw.split('?')[0];
|
const withoutQuery = raw.split('?')[0];
|
||||||
const withoutTrailingSlash = withoutQuery.replace(/\/+$/, '');
|
const withoutTrailingSlash = withoutQuery.replace(/\/+$/, '');
|
||||||
@@ -41,7 +42,7 @@ function normalizePath(request: any): string {
|
|||||||
export class ForcePasswordChangeInterceptor implements NestInterceptor {
|
export class ForcePasswordChangeInterceptor implements NestInterceptor {
|
||||||
constructor(private reflector: Reflector) {}
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
// Skip public routes (login, health, reset-password)
|
// Skip public routes (login, health, reset-password)
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
@@ -51,7 +52,7 @@ export class ForcePasswordChangeInterceptor implements NestInterceptor {
|
|||||||
return next.handle();
|
return next.handle();
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
|
|
||||||
// No user on request (shouldn't happen after auth guard, but be defensive)
|
// No user on request (shouldn't happen after auth guard, but be defensive)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Strategy } from 'passport-jwt';
|
import { Strategy } from 'passport-jwt';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
import type { AuthUser, JwtPayload } from '../types/auth-user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom extractor that reads JWT from the httpOnly "session" cookie.
|
* Custom extractor that reads JWT from the httpOnly "session" cookie.
|
||||||
@@ -24,7 +25,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: any) {
|
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||||
return {
|
return {
|
||||||
id: payload.sub,
|
id: payload.sub,
|
||||||
username: payload.username,
|
username: payload.username,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Strategy } from 'passport-local';
|
import { Strategy } from 'passport-local';
|
||||||
import { AuthService } from '../auth.service';
|
import { AuthService } from '../auth.service';
|
||||||
|
import type { LoginUser } from '../types/auth-user';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||||
@@ -9,7 +10,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
|||||||
super({ usernameField: 'username' });
|
super({ usernameField: 'username' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(username: string, password: string): Promise<any> {
|
async validate(username: string, password: string): Promise<LoginUser> {
|
||||||
const user = await this.authService.validateUser(username, password);
|
const user = await this.authService.validateUser(username, password);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// T-02-01: Generic error message - never reveal whether username or password is wrong
|
// T-02-01: Generic error message - never reveal whether username or password is wrong
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { Role } from '@prisma/client';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Beschreibung des Aufrufers (quick-260921-m34, Aufgabe 2).
|
||||||
|
*
|
||||||
|
* Zweck: an diesem Objekt haengt jede Mandanten- und Rollenentscheidung
|
||||||
|
* der API. Vor dieser Datei war es an rund 70 Stellen `any` — jede
|
||||||
|
* Berechtigungspruefung lief also ohne Begleitung durch den Compiler.
|
||||||
|
*
|
||||||
|
* Herkunft jedes Feldes, nicht gewaehlt sondern abgelesen: die einzige
|
||||||
|
* Stelle, die dieses Objekt erzeugt, ist `JwtStrategy.validate()`
|
||||||
|
* (`../strategies/jwt.strategy.ts`, Zeile 27-38). Sie liest die Ansprueche
|
||||||
|
* eines Tokens, das ausschliesslich an zwei Stellen signiert wird:
|
||||||
|
* `AuthService.login()` (`../auth.service.ts`, Zeile ~171) und
|
||||||
|
* `AuthService.changePassword()` (ebenda, Zeile ~376). Was dort nicht
|
||||||
|
* geschrieben wird, gibt es hier nicht.
|
||||||
|
*/
|
||||||
|
export interface AuthUser {
|
||||||
|
/** `payload.sub` — an beiden Signierstellen `user.id`. */
|
||||||
|
id: string;
|
||||||
|
/** An beiden Signierstellen `user.username`; Spalte `User.username` ist Pflicht. */
|
||||||
|
username: string;
|
||||||
|
/**
|
||||||
|
* An beiden Signierstellen der Spaltenwert `User.role`. Die Spalte ist in
|
||||||
|
* `apps/api/prisma/schema.prisma` als Aufzaehlung `Role` deklariert, die
|
||||||
|
* SQL-Funktion `auth_lookup_user_by_username` gibt sie als `"Role"` zurueck
|
||||||
|
* (Migration 20260909160000). Deshalb ist `Role` der ehrliche Typ und nicht
|
||||||
|
* `string`: ein Vergleich gegen eine Zeichenkette ausserhalb der
|
||||||
|
* Aufzaehlung ist ein Fehler und soll einer sein (T-M34-02).
|
||||||
|
*/
|
||||||
|
role: Role;
|
||||||
|
/**
|
||||||
|
* SICHERHEITSRELEVANTE ENTSCHEIDUNG (T-M34-01) — hergeleitet, nicht gewaehlt.
|
||||||
|
*
|
||||||
|
* Beleg 1: `apps/api/prisma/schema.prisma` deklariert `User.tenantId String`
|
||||||
|
* OHNE `?`. Die Spalte ist Pflicht, jede Benutzerzeile hat einen Mandanten.
|
||||||
|
* Beide Signierstellen schreiben genau diesen Spaltenwert, seit dem ersten
|
||||||
|
* Commit des Anmeldedienstes (6190f3d) — es gibt keine Token-Generation
|
||||||
|
* ohne diesen Anspruch.
|
||||||
|
* Beleg 2: der Bestand beschreibt dasselbe Objekt in `SessionUser`
|
||||||
|
* (`../../bug-reports/bug-reports.service.ts`) bereits als `tenantId: string`.
|
||||||
|
* Diese Datei zieht `SessionUser` auf `AuthUser` zurueck; eine zweite,
|
||||||
|
* abweichende Beschreibung desselben Objekts soll es nicht geben.
|
||||||
|
* Beleg 3: `TenantGuard` (`../../tenant/tenant.guard.ts`) haelt fuer
|
||||||
|
* SUPER_ADMIN einen Zweig ohne Mandanten vor und setzt dort
|
||||||
|
* `req.tenantId = null`.
|
||||||
|
*
|
||||||
|
* Beleg 3 spricht NICHT gegen `string`, und das ist der Punkt, an dem hier
|
||||||
|
* nicht nach Bequemlichkeit entschieden wurde: der Zweig in `TenantGuard`
|
||||||
|
* ist eine Tiefenverteidigung gegen ein Token OHNE diesen Anspruch, und er
|
||||||
|
* liest `AuthUser` gar nicht — der Waechter holt sein Anfrageobjekt
|
||||||
|
* ungetypt. Dieser Typ kann den Zweig also nicht zu totem Code machen.
|
||||||
|
*
|
||||||
|
* ACHTUNG fuer spaetere Leser: der SUPER_ADMIN-Zweig in `TenantGuard` ist
|
||||||
|
* ein Schutzzweig und darf NICHT entfernt oder wegtypisiert werden, auch
|
||||||
|
* wenn er unter diesem Typ unerreichbar aussieht. Dass es ihn gibt, steht
|
||||||
|
* unten in `AuthenticatedRequest.tenantId` als `null` weiterhin im Typsystem.
|
||||||
|
*/
|
||||||
|
tenantId: string;
|
||||||
|
/**
|
||||||
|
* `JwtStrategy.validate()` bildet diesen Wert mit `=== true` — ein aelteres
|
||||||
|
* Token ohne den Anspruch ergibt `false` (260921-fi3, D-01: keine
|
||||||
|
* Aussperrwelle). Das Ergebnis ist deshalb immer ein Wahrheitswert.
|
||||||
|
*/
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anfrageobjekt nach den beiden globalen Waechtern.
|
||||||
|
*
|
||||||
|
* `user` ist ABSICHTLICH wahlfrei: `JwtAuthGuard` laesst oeffentliche Wege
|
||||||
|
* (`@Public()`: Anmeldung, Gesundheitspruefung, Kennwort-Ruecksetzung) ohne
|
||||||
|
* Aufrufer durch, und `TenantGuard` beginnt mit genau dieser Pruefung
|
||||||
|
* (`if (!user) return true`). Die abwehrenden Pruefungen in den Controllern
|
||||||
|
* ("No user context") bleiben damit lebendiger Code.
|
||||||
|
*
|
||||||
|
* `tenantId` setzt `TenantGuard`: eine Zeichenkette, ODER `null` fuer einen
|
||||||
|
* SUPER_ADMIN ohne Mandantenbezug — und gar nicht auf oeffentlichen Wegen,
|
||||||
|
* auf denen der Waechter vorzeitig zurueckkehrt. Alle drei Faelle stehen
|
||||||
|
* hier, weil alle drei vorkommen koennen.
|
||||||
|
*/
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
user?: AuthUser;
|
||||||
|
tenantId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldeanfrage nach `AuthGuard('local')`.
|
||||||
|
*
|
||||||
|
* Hier ist `user` NICHT wahlfrei: die lokale Passport-Strategie wirft
|
||||||
|
* `UnauthorizedException`, wenn `validateUser()` nichts liefert — der Rumpf
|
||||||
|
* der Route laeuft nur mit gesetztem Aufrufer.
|
||||||
|
*/
|
||||||
|
export interface LocalAuthenticatedRequest extends Request {
|
||||||
|
user: LoginUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was `AuthService.login()` aus dem angemeldeten Benutzer liest — nicht mehr.
|
||||||
|
* Geliefert wird das von `validateUser()` als Zeile der SQL-Funktion
|
||||||
|
* `auth_lookup_user_by_username`; `displayName` ist der einzige Unterschied
|
||||||
|
* zu `AuthUser` und der Grund, warum `login()` nicht `AuthUser` nehmen kann:
|
||||||
|
* der Anzeigename steht in der Anmeldeantwort, aber in keinem Token.
|
||||||
|
*/
|
||||||
|
export interface LoginUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: Role;
|
||||||
|
tenantId: string;
|
||||||
|
/** Spalte `User.displayName` ist wahlfrei (`String?` in schema.prisma). */
|
||||||
|
displayName: string | null;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Ansprueche des Sitzungstokens.
|
||||||
|
*
|
||||||
|
* Doppelrolle: dieselbe Form wird an den beiden Signierstellen geschrieben
|
||||||
|
* und in `JwtStrategy.validate()` gelesen. Deshalb ist
|
||||||
|
* `mustChangePassword` wahlfrei — geschrieben wird es immer, aber ein vor
|
||||||
|
* 260921-fi3 ausgestelltes Token traegt es nicht, und die Pruefung
|
||||||
|
* `=== true` in `validate()` haengt daran. Wer dieses Feld hier zur Pflicht
|
||||||
|
* macht, laesst jene Pruefung ueberfluessig aussehen.
|
||||||
|
*/
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
role: Role;
|
||||||
|
tenantId: string;
|
||||||
|
mustChangePassword?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine hochgeladene Datei, so weit der Code sie liest.
|
||||||
|
*
|
||||||
|
* `Express.Multer.File` gibt es in diesem Baum nicht (`@types/multer` ist
|
||||||
|
* nicht installiert, gemessen), und Nachinstallieren ist in diesem Lauf
|
||||||
|
* ausgeschlossen. Diese Schnittstelle ist trotzdem keine Behauptung, sondern
|
||||||
|
* belegt: KEIN Aufruf von `FileInterceptor`/`FilesInterceptor` in
|
||||||
|
* `apps/api/src` setzt eine `storage`-Option (nachgezaehlt: sechs Aufrufe,
|
||||||
|
* alle nur mit `limits`). Damit gilt multers Voreinstellung memoryStorage,
|
||||||
|
* und damit ist `buffer` ein Buffer und keine Vermutung.
|
||||||
|
*
|
||||||
|
* Die Schnittstelle ersetzt keine Pruefung: die Groessengrenzen bleiben in
|
||||||
|
* den Interceptor-Optionen, die PNG-Signaturpruefung bleibt in
|
||||||
|
* `bug-reports.service.ts` (T-M34-04).
|
||||||
|
*/
|
||||||
|
export interface UploadedFileLike {
|
||||||
|
buffer: Buffer;
|
||||||
|
originalname: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { BugReportsService } from './bug-reports.service';
|
import { BugReportsService } from './bug-reports.service';
|
||||||
import { BugReportDto } from './dto/bug-report.dto';
|
import { BugReportDto } from './dto/bug-report.dto';
|
||||||
|
|
||||||
@@ -30,9 +31,9 @@ export class BugReportsController {
|
|||||||
FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }),
|
FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }),
|
||||||
)
|
)
|
||||||
async submit(
|
async submit(
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: AuthUser,
|
||||||
@Body() dto: BugReportDto,
|
@Body() dto: BugReportDto,
|
||||||
@UploadedFile() file?: any,
|
@UploadedFile() file?: UploadedFileLike,
|
||||||
) {
|
) {
|
||||||
return this.service.submit(user, dto, file);
|
return this.service.submit(user, dto, file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
HttpException,
|
HttpException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { BugReportsService } from './bug-reports.service';
|
import { BugReportsService } from './bug-reports.service';
|
||||||
@@ -37,7 +38,7 @@ const PNG_1x1 = Buffer.from(
|
|||||||
'base64',
|
'base64',
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionUser = { id: 'u1', username: 'anna', role: 'USER', tenantId: 't1' };
|
const sessionUser = { id: 'u1', username: 'anna', role: Role.USER, tenantId: 't1' };
|
||||||
|
|
||||||
const baseDto = {
|
const baseDto = {
|
||||||
page: '/admin/users?tab=x',
|
page: '/admin/users?tab=x',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SettingsService } from '../settings/settings.service';
|
import { SettingsService } from '../settings/settings.service';
|
||||||
import { BugReportDto } from './dto/bug-report.dto';
|
import { BugReportDto } from './dto/bug-report.dto';
|
||||||
|
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { describeOrigin } from './origin';
|
import { describeOrigin } from './origin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,18 +59,22 @@ const WINDOW_MS = 10 * 60 * 1000;
|
|||||||
const MAX_PER_WINDOW = 5;
|
const MAX_PER_WINDOW = 5;
|
||||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
interface SessionUser {
|
/**
|
||||||
id: string;
|
* Der Aufrufer, so weit dieser Dienst ihn liest. Seit quick-260921-m34 aus
|
||||||
username: string;
|
* `AuthUser` abgeleitet statt danebengestellt: es soll nicht zwei
|
||||||
role: string;
|
* Beschreibungen desselben Sitzungsobjekts geben, die auseinanderlaufen
|
||||||
tenantId: string;
|
* koennen. Ausgelassen wird `mustChangePassword` — dieser Dienst liest es
|
||||||
}
|
* nicht.
|
||||||
|
*/
|
||||||
|
type SessionUser = Pick<AuthUser, 'id' | 'username' | 'role' | 'tenantId'>;
|
||||||
|
|
||||||
interface UploadedPng {
|
/**
|
||||||
buffer: Buffer;
|
* Das Bildschirmfoto, so weit dieser Dienst es liest — abgeleitet aus
|
||||||
size: number;
|
* `UploadedFileLike`, damit die Hochladewege eine gemeinsame Beschreibung
|
||||||
mimetype?: string;
|
* haben. `originalname` fehlt bewusst: der Anhangname ist hier fest
|
||||||
}
|
* vorgegeben (T-M97-04), der eingereichte Name wird nie benutzt.
|
||||||
|
*/
|
||||||
|
type UploadedPng = Pick<UploadedFileLike, 'buffer' | 'size' | 'mimetype'>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BugReportsService {
|
export class BugReportsService {
|
||||||
|
|||||||
Reference in New Issue
Block a user