Compare commits

...
10 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Sonnet 5 33fa768062 fix: Mindestlaenge fuer JWT_ACCESS_SECRET beim Start erzwungen
Backend Image / build-and-push (push) Successful in 2m13s
Frontend Image / build-and-push (push) Successful in 1m59s
JWT_ACCESS_SECRET war zwar als Pflicht-Env-Var geprueft, aber ohne
Mindestlaenge - ein kurzer/erratbarer Wert haette nicht nur das
Faelschen von Access-/MFA-/SSO-Tokens fuer jeden Nutzer erlaubt,
sondern auch (ueber mfa-crypto.util.ts, das den AES-Schluessel fuer
gespeicherte TOTP-Secrets aus genau diesem Wert ableitet) das
Entschluesseln aller gespeicherten MFA-Secrets.

main.ts bricht den Start jetzt mit klarer Fehlermeldung ab, wenn
JWT_ACCESS_SECRET kuerzer als 32 Zeichen ist. .env.example und das
K8s-Secret-Template zeigen jetzt einen ausreichend langen Platzhalter
mit Erzeugungshinweis (openssl rand -hex 32).

Betrifft nur main.ts's bootstrap() - e2e-Tests bootstrappen ueber
test/utils/create-test-app.ts direkt via configureApp() und durchlaufen
diese Pruefung nicht, daher keine Testanpassung noetig.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 15:17:39 +02:00
Nick SzaunigandClaude Sonnet 5 4bd7e9791a fix: Robustheits-Luecken aus zweitem, unabhaengigem Sicherheitsaudit behoben
Ein zweiter, unabhaengiger Audit-Durchlauf (frischer Blick, keine
Vorkenntnis meiner eigenen Fixes) bestaetigte alle bisherigen Fixes als
korrekt verdrahtet und fand 3 LOW-severity Robustheitsluecken - keine
davon war ausnutzbar, aber alle sind jetzt behoben:

- HarvestsService.create/ExtractionsService.create pruefen jetzt, dass
  referenzierte colonyIds/harvestIds tatsaechlich existieren, bevor die
  Transaktion laeuft - vorher fuehrte eine unbekannte Id zu einem
  ungefangenen 500 mit rohem Prisma-Fehler statt einer sauberen 400.
- Etiketten-Bildelemente durchlaufen jetzt denselben Magic-Byte-Check
  wie Avatar-/Dokument-Uploads, statt pdfkits Bildparser ungepruefte
  Bytes zu uebergeben.
- Webhook-Secret-Redaktion im Audit-Log nutzt jetzt explizites delete
  statt sich auf Prismas JSON-Serialisierung zu verlassen, die
  undefined-Werte verwirft (fragil, falls sich die Serialisierung mal
  aendert).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 15:13:02 +02:00
Nick SzaunigandClaude Sonnet 5 f57cffe6a8 fix: Rechteausweitung ueber Rollen-Rechtezuweisung geschlossen
RolesService.create/update uebernahm eine explizit uebergebene
permissionKeys-Liste ungeprueft. Ein Nutzer mit nur roles:update
konnte damit JEDER Rolle - auch der eigenen, ohne users:assign-role -
beliebige weitere Rechte (bis hin zu allen 92 Katalog-Eintraegen)
hinzufuegen, da das Bearbeiten der eigenen Rollen-Rechte nicht ueber
User.roleId laeuft und daher vom bereits gefixten
users:assign-role-Check nicht erfasst wurde.

Neue Regel (Standard-RBAC-Prinzip "keine Rechte ueber das eigene
Rechte-Set hinaus vergeben"): create/update pruefen jetzt, dass der
aufrufende Nutzer jedes zu vergebende Recht selbst bereits besitzt,
bevor die Rolle angelegt/geaendert wird.

roles.service.ts hatte bislang keine Unit-Tests; neue Spec-Datei deckt
den Fix sowie die uebrigen Methoden (create/update/deactivate/
reactivate) ab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 14:58:44 +02:00
Nick SzaunigandClaude Sonnet 5 8b8616ce03 fix: Etiketten werden beim Drucken abgeschnitten, wenn Spalten/Zeilen zu gross gewaehlt sind
Eine explizit im Etiketten-Designer eingetragene Spalten-/Zeilenzahl
wurde beim PDF-Rendern ungeprueft uebernommen. Passt sie nicht auf ein
A4-Blatt (z.B. 90mm breite Etiketten x 4 Spalten bei 5mm Rand), ragen
die hinteren Spalten/Zeilen ueber den Blattrand hinaus - der Drucker
schneidet sie dann einfach ab, statt sie zu drucken.

- Backend (label-templates.service.ts): Spalten/Zeilen werden jetzt auf
  das tatsaechlich auf A4 passende Maximum begrenzt, bevor gerendert
  wird - betrifft auch bereits gespeicherte Vorlagen mit zu grossen
  Werten.
- Frontend (label-designer.tsx): zeigt die maximal passende Spalten-/
  Zeilenzahl direkt im Seiten-Layout-Panel an und warnt inline, wenn
  der eingetragene Wert das Blatt sprengen wuerde.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 14:53:15 +02:00
Nick SzaunigandClaude Sonnet 5 19d5e5d65c fix: PermissionsGuard schliesst jetzt statt bei fehlendem Decorator zu oeffnen
PermissionsGuard erlaubte bisher jede Route ohne @RequirePermission
automatisch fuer jeden eingeloggten Nutzer (fail-open) und verliess sich
allein auf test/authorization-coverage.e2e-spec.ts, um eine vergessene
Decorator-Angabe vor dem Deploy zu erwischen - der Guard selbst hatte
kein Default-Deny.

Neuer Decorator @AnyAuthenticatedUser() macht das bisher stillschweigend
erlaubte Verhalten explizit fuer die 12 Routen, die es tatsaechlich
brauchen (ProfileController komplett, NotificationsController komplett,
AuthController.me - alle bereits vorher auf der Allowlist des e2e-Tests).
PermissionsGuard verweigert jetzt jede Route, die weder
@RequirePermission noch @AnyAuthenticatedUser traegt - eine vergessene
Decorator-Angabe schlaegt jetzt zur Laufzeit fehl (403), nicht mehr nur
im Test. Der e2e-Test prueft jetzt denselben Decorator-Vertrag statt
einer separat gepflegten Allowlist, kann also nicht mehr vom
Guard-Verhalten abweichen.

Neue Unit-Tests fuer PermissionsGuard decken beide Codepfade und das
Fail-Closed-Verhalten ab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 14:44:28 +02:00
Nick SzaunigandClaude Sonnet 5 b7ff92abd3 fix: SSO-MFA-Umgehung, Datei-Magic-Bytes und oeffentliches Swagger geschlossen
Weitere vom Sicherheitsaudit gemeldete Luecken behoben, die bisher als
niedrige Prioritaet offen gelassen wurden:

- SSO-Login (Entra ID) prüft jetzt lokale MFA genauso wie der
  Passwort-Login: ist mfaEnabled gesetzt, liefert /auth/sso/complete
  denselben mfaRequired-Codepfad wie /auth/login, bevor Tokens
  ausgestellt werden. Frontend (Login-Seite, AuthContext) zeigt in dem
  Fall den bestehenden Code-Eingabe-Schritt statt die Anmeldung
  stillschweigend abzuschliessen.
- Avatar- und Dokument-Uploads pruefen jetzt die tatsaechlichen
  Datei-Bytes (Magic Numbers fuer PNG/JPEG/WEBP/PDF) statt nur dem
  client-angegebenen MIME-Typ/data:-URL-Praefix zu vertrauen.
- Swagger-UI (/api/v1/docs) ist jetzt standardmaessig deaktiviert
  (ENABLE_SWAGGER=true erforderlich), da sie zuvor das komplette
  API-Schema ungeschuetzt oeffentlich preisgab.

npm-audit-Warnungen (deepmerge-ts/mysql2 unter prisma) bewusst nicht
gefixt: kein stabiles Prisma-Release loest sie bislang auf, nur eine
Release-Candidate-Hauptversion - das Risiko eines Downgrades/RC-Upgrades
der Datenbankschicht uebersteigt den Nutzen fuer eine Kette, die zur
Laufzeit ohnehin nicht erreichbar ist (nur Prisma-CLI, nur MySQL-Treiber,
dieses Projekt nutzt Postgres).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 09:09:58 +02:00
Nick SzaunigandClaude Sonnet 5 1b16b49913 fix: Rechteausweitung ueber Rollenzuweisung und weitere Auth-Haertung
Ein Sicherheitsaudit deckte auf, dass jede Rolle mit users:update sich
selbst per PATCH /users/:id auf eine Admin-Rolle heraufstufen konnte,
da roleId ohne eigene Berechtigung und ohne Selbstbezug-Pruefung
akzeptiert wurde. Ausserdem lag ein fest im Code hinterlegtes
Standard-Admin-Passwort vor, das bei fehlender SEED_ADMIN_PASSWORD-Env
greift.

- Neue Berechtigung users:assign-role, getrennt von users:create/update,
  fuer jede Rollenzuweisung erforderlich
- Selbstaenderung der eigenen Rolle serverseitig blockiert (auch fuer
  Admins), ebenso Selbst-Reset der eigenen MFA ueber die Admin-Route
- Seed erzeugt bei fehlender Env ein zufaelliges statt eines fixen
  Passworts
- Wiederverwendung eines bereits rotierten Refresh-Tokens (Diebstahl-
  Indiz) widerruft nun alle Sessions des betroffenen Nutzers
- Rate-Limit auf MFA-Enrollment-Bestaetigung ergaenzt

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 08:57:42 +02:00
Nick Szaunig 7bd2a3dc3a feat: Sicherheits-Header fuer das Frontend ergaenzt
apicore.encry.de lieferte bisher gar keine Security-Header aus (per
curl bestaetigt) - CSP, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, HSTS fehlten komplett, waehrend das Backend das
laengst per helmet() macht. Ergaenzt fuer alle Routen. Bewusst ohne
CSP-Nonce (haette dynamic rendering fuer jede Seite erzwungen und
damit die bestehende statische Optimierung gekostet) - stattdessen
script-src/style-src 'unsafe-inline', aber default-src/connect-src/
img-src/object-src/frame-ancestors/form-action/base-uri strikt
eingeschraenkt. Lokal mit dem echten Production-Build (standalone
server + next.config.ts) verifiziert: Login-Seite rendert/hydratisiert
sauber, keine CSP-Verstoesse in der Konsole, Header per curl bestaetigt.
2026-09-02 08:45:56 +02:00
Nick Szaunig 135738fdfc fix: Profilbild-Upload auf JSON/Base64 umgestellt statt multipart
POST /profile/avatar schlug live mit net::ERR_FAILED fehl (Verbindung
abgebrochen, keine Antwort) - selbst bei einer winzigen Testdatei.
CORS-Preflight lief sauber durch, nur der eigentliche multipart-POST
nicht; passt zum bereits dokumentierten Cloudflare-Verhalten bei
multipart-Uploads (siehe .gitea/workflows/*-image.yml). Umgangen durch
Umstellung auf denselben JSON-Content-Type, der fuer alle anderen
Endpunkte nachweislich zuverlaessig funktioniert: Bild wird
clientseitig als data:-URL kodiert und im JSON-Body gesendet.
Globales JSON-Body-Limit auf 5mb angehoben (deckt 2MB-Bild plus
Base64-Overhead).
2026-09-02 08:32:08 +02:00
Nick Szaunig abe1f6e8cf fix: Etiketten-Import zeigt konkreten Grund statt generischem Fehler
Eine KI-erzeugte JSON ohne Kenntnis des echten Schemas (z.B. eigene
Feldnamen wie xMm/yMm oder erfundene Elementtypen wie rectangle/svg)
scheiterte bisher mit "Datei enthält keine gültige Etikettenvorlage"
ohne Hinweis, was falsch ist. Jetzt wird pro Element benannt, was
fehlt oder welcher Typ nicht unterstützt wird.
2026-09-01 20:13:36 +02:00
42 changed files with 1259 additions and 142 deletions

No files matched your search

+9 -1
View File
@@ -2,7 +2,10 @@
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
JWT_ACCESS_SECRET="change-me-access-secret"
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
# Erzeugen z.B. mit: openssl rand -hex 32
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
JWT_REFRESH_SECRET="change-me-refresh-secret"
JWT_ACCESS_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d"
@@ -28,3 +31,8 @@ OIDC_ISSUER_URL="https://login.microsoftonline.com/<tenant-id>/v2.0"
OIDC_CLIENT_ID="change-me-entra-client-id"
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
# Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
ENABLE_SWAGGER=false
+10 -2
View File
@@ -1,11 +1,19 @@
import 'dotenv/config';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'node:crypto';
import { PrismaPg } from '@prisma/adapter-pg';
import { Prisma, PrismaClient } from '../generated/prisma';
const BCRYPT_SALT_ROUNDS = 12;
const DEFAULT_ADMIN_EMAIL = '[email protected]';
const DEFAULT_ADMIN_PASSWORD = 'ChangeMe123!';
// No fixed fallback password: a fixed literal shipped in source would be a
// well-known, guessable credential for any deployment that forgets to set
// SEED_ADMIN_PASSWORD. A random one is generated instead and printed once
// below, same as an operator-supplied one would be.
function generateRandomPassword(): string {
return crypto.randomBytes(18).toString('base64url');
}
// Only printed the moment the account is actually created (first deploy on
// an empty database), never on later restarts: by then the real admin may
@@ -28,7 +36,7 @@ async function main(): Promise<void> {
});
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
const password = process.env.SEED_ADMIN_PASSWORD ?? DEFAULT_ADMIN_PASSWORD;
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
console.log(`Seed: admin user ${email} already exists, skipping.`);
+5
View File
@@ -10,6 +10,11 @@ export function configureApp(app: NestExpressApplication): void {
// real client IP from X-Forwarded-For — required for per-client throttling
// to work correctly rather than rate-limiting all traffic together.
app.set('trust proxy', 1);
// Default 100kb JSON body limit is too small for the avatar upload, which
// sends the image as a base64 data: URL in the JSON body (not multipart -
// see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers
// the 2MB decoded size cap plus base64 overhead.
app.useBodyParser('json', { limit: '5mb' });
app.use(helmet());
app.use(cookieParser());
app.use(requestLoggerMiddleware);
+3 -1
View File
@@ -12,6 +12,7 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from './decorators/public.decorator';
import { AnyAuthenticatedUser } from './decorators/any-authenticated-user.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import type { AuthenticatedUser } from './types/authenticated-user';
import { LoginDto } from './dto/login.dto';
@@ -25,7 +26,7 @@ import {
setRefreshTokenCookie,
} from './util/refresh-token-cookie';
interface MfaRequiredResponse {
export interface MfaRequiredResponse {
mfaRequired: true;
mfaToken: string;
}
@@ -92,6 +93,7 @@ export class AuthController {
}
@ApiBearerAuth()
@AnyAuthenticatedUser()
@Get('me')
me(@CurrentUser() user: AuthenticatedUser): Promise<CurrentUserProfile> {
return this.authService.getCurrentUserProfile(user.id);
+39 -2
View File
@@ -19,7 +19,10 @@ describe('AuthService', () => {
};
let jwtService: { sign: jest.Mock };
let auditLog: { record: jest.Mock };
let permissionResolver: { getPermissions: jest.Mock };
let permissionResolver: {
getPermissions: jest.Mock;
invalidateUser: jest.Mock;
};
let mfaService: {
signChallengeToken: jest.Mock;
verifyChallengeToken: jest.Mock;
@@ -52,6 +55,7 @@ describe('AuthService', () => {
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = {
getPermissions: jest.fn().mockResolvedValue(new Set()),
invalidateUser: jest.fn(),
};
mfaService = {
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
@@ -236,6 +240,9 @@ describe('AuthService', () => {
const result = await authService.issueTokensForSsoUser(activeUser.id);
if (result.mfaRequired) {
throw new Error('expected mfaRequired to be false');
}
expect(result.accessToken).toBe('signed.jwt.token');
expect(prisma.refreshToken.create).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
@@ -243,6 +250,26 @@ describe('AuthService', () => {
);
});
it('returns an MFA challenge instead of tokens when the SSO user has local MFA enabled', async () => {
usersService.findById.mockResolvedValue({
...activeUser,
mfaEnabled: true,
mfaSecret: 'encrypted-secret',
});
const result = await authService.issueTokensForSsoUser(activeUser.id);
if (!result.mfaRequired) {
throw new Error('expected mfaRequired to be true');
}
expect(result.mfaToken).toBe('mfa-challenge-token');
expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id);
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN' }),
);
});
it('throws when the user is deactivated', async () => {
usersService.findById.mockResolvedValue({
...activeUser,
@@ -264,16 +291,26 @@ describe('AuthService', () => {
);
});
it('throws when the refresh token is revoked', async () => {
it('throws when the refresh token is revoked and revokes all other sessions for that user (reuse detection)', async () => {
prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1',
userId: activeUser.id,
revokedAt: new Date(),
expiresAt: new Date(Date.now() + 1000),
});
prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 });
await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: activeUser.id, revokedAt: null },
}),
);
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith(
activeUser.id,
);
});
it('throws when the refresh token is expired', async () => {
+34 -4
View File
@@ -144,12 +144,24 @@ export class AuthService {
return { mfaRequired: false, accessToken, refreshToken };
}
/** Issues our own access/refresh token pair for a user already authenticated via SSO. */
async issueTokensForSsoUser(userId: string): Promise<AuthTokens> {
/**
* Issues our own access/refresh token pair for a user already
* authenticated via SSO. Local MFA is still enforced here, same as
* password login: SSO establishes identity, not the second factor, so a
* user who has explicitly enabled TOTP must complete it here too rather
* than getting it silently skipped by signing in via Microsoft instead.
*/
async issueTokensForSsoUser(userId: string): Promise<LoginResult> {
const user = await this.usersService.findById(userId);
if (!user.isActive) {
throw new UnauthorizedException('Invalid credentials');
}
if (user.mfaEnabled) {
return {
mfaRequired: true,
mfaToken: this.mfaService.signChallengeToken(user.id),
};
}
const accessToken = this.signAccessToken({
id: user.id,
email: user.email,
@@ -161,7 +173,7 @@ export class AuthService {
action: 'LOGIN',
userId: user.id,
});
return { accessToken, refreshToken };
return { mfaRequired: false, accessToken, refreshToken };
}
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
@@ -207,7 +219,25 @@ export class AuthService {
const stored = await this.prisma.refreshToken.findUnique({
where: { tokenHash },
});
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
if (!stored) {
throw new UnauthorizedException('Invalid refresh token');
}
if (stored.revokedAt) {
// Reuse of an already-rotated-out refresh token is a strong signal
// it was stolen (a legitimate client only ever presents the newest
// token) - revoke every other active session for this user rather
// than trusting rotation alone to have contained the leak.
await this.prisma.refreshToken.updateMany({
where: { userId: stored.userId, revokedAt: null },
data: { revokedAt: new Date() },
});
this.permissionResolver.invalidateUser(stored.userId);
this.logger.warn(
`Refresh token reuse detected for user ${stored.userId}; all sessions revoked`,
);
throw new UnauthorizedException('Invalid refresh token');
}
if (stored.expiresAt < new Date()) {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.usersService.findById(stored.userId);
@@ -0,0 +1,16 @@
import { SetMetadata } from '@nestjs/common';
export const ANY_AUTHENTICATED_USER_KEY = 'any-authenticated-user';
/**
* Explicit opt-out of permission checking for a route that is meant to be
* reachable by any logged-in user regardless of role - it acts only on the
* caller's own data (profile, own notifications) or is the auth bootstrap
* endpoint every authenticated user must reach. Required by
* PermissionsGuard, which fails closed on any route carrying neither this
* nor @RequirePermission: a route is either explicitly public, explicitly
* permission-gated, or explicitly opted into "any authenticated user" -
* there is no fourth, silent option.
*/
export const AnyAuthenticatedUser = () =>
SetMetadata(ANY_AUTHENTICATED_USER_KEY, true);
@@ -0,0 +1,88 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsGuard } from './permissions.guard';
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
import { ANY_AUTHENTICATED_USER_KEY } from '../decorators/any-authenticated-user.decorator';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
describe('PermissionsGuard', () => {
let guard: PermissionsGuard;
let reflectorValues: Map<string, unknown>;
let reflector: { getAllAndOverride: jest.Mock };
let permissionResolver: { hasAny: jest.Mock };
function contextWithUser(user: { id: string } | undefined): ExecutionContext {
const request = { user };
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
beforeEach(() => {
reflectorValues = new Map();
reflector = {
getAllAndOverride: jest.fn((key: string) => reflectorValues.get(key)),
};
permissionResolver = { hasAny: jest.fn() };
guard = new PermissionsGuard(
reflector as unknown as Reflector,
permissionResolver as unknown as PermissionResolverService,
);
});
it('denies a route that carries neither @RequirePermission nor @AnyAuthenticatedUser (fails closed)', async () => {
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(false);
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
});
it('checks the caller permission set when @RequirePermission is present', async () => {
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
permissionResolver.hasAny.mockResolvedValue(true);
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(true);
expect(permissionResolver.hasAny).toHaveBeenCalledWith('user-1', [
'apiaries:read',
]);
});
it('denies when the caller lacks all required permissions', async () => {
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
permissionResolver.hasAny.mockResolvedValue(false);
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(false);
});
it('denies a @RequirePermission route when there is no authenticated user on the request', async () => {
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
false,
);
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
});
it('allows an authenticated user through a route marked @AnyAuthenticatedUser', async () => {
reflectorValues.set(ANY_AUTHENTICATED_USER_KEY, true);
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(true);
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
});
it('denies @AnyAuthenticatedUser when there is no user on the request', async () => {
reflectorValues.set(ANY_AUTHENTICATED_USER_KEY, true);
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
false,
);
});
});
@@ -1,5 +1,6 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ANY_AUTHENTICATED_USER_KEY } from '../decorators/any-authenticated-user.decorator';
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
import { PermissionKey } from '../permissions/permission-catalog';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
@@ -10,14 +11,15 @@ interface RequestWithUser {
}
/**
* Fail-open by decorator absence: a route with no @RequirePermission is
* allowed through untouched (any authenticated user can reach it), only
* routes that carry the decorator are enforced against the caller's live
* permission set. There is no default-deny here, so
* test/authorization-coverage.e2e-spec.ts — which fails the build if any
* non-public route lacks a catalog-valid @RequirePermission — is the sole
* safety net for "someone added a route and forgot the decorator", not a
* defense-in-depth nicety.
* Fails closed by decorator absence: a non-public route reaches this guard
* only after JwtAuthGuard confirms a valid, active session, and from here it
* is denied unless it carries either @RequirePermission (checked against the
* caller's live permission set) or the explicit @AnyAuthenticatedUser opt-out
* for routes that are meant to be reachable by any logged-in user regardless
* of role. A route with neither is a bug - forgetting to decorate a new
* route now fails shut at runtime instead of silently granting access to
* every authenticated user and relying solely on
* test/authorization-coverage.e2e-spec.ts to catch it before deploy.
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
@@ -30,14 +32,20 @@ export class PermissionsGuard implements CanActivate {
const requiredPermissions = this.reflector.getAllAndOverride<
PermissionKey[]
>(REQUIRE_PERMISSION_KEY, [context.getHandler(), context.getClass()]);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (requiredPermissions && requiredPermissions.length > 0) {
if (!user) {
return false;
}
return this.permissionResolver.hasAny(user.id, requiredPermissions);
}
const isAnyAuthenticatedUser = this.reflector.getAllAndOverride<boolean>(
ANY_AUTHENTICATED_USER_KEY,
[context.getHandler(), context.getClass()],
);
return Boolean(isAnyAuthenticatedUser && user);
}
}
@@ -255,6 +255,7 @@ export const PERMISSION_CATALOG = [
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
['assign-role', 'Rolle zuweisen'],
],
2300,
),
@@ -13,6 +13,7 @@ import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from '../decorators/public.decorator';
import { AuthService } from '../auth.service';
import { MfaRequiredResponse } from '../auth.controller';
import { AccessTokenDto } from '../dto/access-token.dto';
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
@@ -121,7 +122,7 @@ export class SsoController {
async complete(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
): Promise<AccessTokenDto | MfaRequiredResponse> {
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
SSO_CODE_COOKIE
];
@@ -132,10 +133,12 @@ export class SsoController {
);
}
const { userId } = this.ssoService.verifySsoCode(ssoCode);
const { accessToken, refreshToken } =
await this.authService.issueTokensForSsoUser(userId);
setRefreshTokenCookie(res, refreshToken);
return { accessToken };
const result = await this.authService.issueTokensForSsoUser(userId);
if (result.mfaRequired) {
return { mfaRequired: true, mfaToken: result.mfaToken };
}
setRefreshTokenCookie(res, result.refreshToken);
return { accessToken: result.accessToken };
}
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
@@ -0,0 +1,49 @@
import { matchesFileSignature } from './file-signature';
describe('matchesFileSignature', () => {
it('accepts a buffer starting with the PNG signature', () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00,
]);
expect(matchesFileSignature(png, 'image/png')).toBe(true);
});
it('accepts a buffer starting with the JPEG signature', () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
expect(matchesFileSignature(jpeg, 'image/jpeg')).toBe(true);
});
it('accepts a RIFF/WEBP container', () => {
const webp = Buffer.concat([
Buffer.from('RIFF', 'ascii'),
Buffer.from([0, 0, 0, 0]),
Buffer.from('WEBP', 'ascii'),
]);
expect(matchesFileSignature(webp, 'image/webp')).toBe(true);
});
it('accepts a PDF header', () => {
const pdf = Buffer.from('%PDF-1.7\n', 'ascii');
expect(matchesFileSignature(pdf, 'application/pdf')).toBe(true);
});
it('rejects a buffer whose bytes do not match the declared type', () => {
const plainText = Buffer.from('this is not an image', 'ascii');
expect(matchesFileSignature(plainText, 'image/png')).toBe(false);
expect(matchesFileSignature(plainText, 'image/jpeg')).toBe(false);
expect(matchesFileSignature(plainText, 'image/webp')).toBe(false);
expect(matchesFileSignature(plainText, 'application/pdf')).toBe(false);
});
it('rejects a buffer shorter than the signature it claims to have', () => {
expect(matchesFileSignature(Buffer.from([0xff, 0xd8]), 'image/jpeg')).toBe(
false,
);
});
it('allows any bytes through for a mime type with no known signature', () => {
expect(matchesFileSignature(Buffer.from('anything'), 'text/plain')).toBe(
true,
);
});
});
@@ -0,0 +1,35 @@
const SIGNATURE_CHECKS: Record<string, (buffer: Buffer) => boolean> = {
'image/png': (buffer) =>
buffer.length >= 8 &&
buffer
.subarray(0, 8)
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
'image/jpeg': (buffer) =>
buffer.length >= 3 &&
buffer[0] === 0xff &&
buffer[1] === 0xd8 &&
buffer[2] === 0xff,
'image/webp': (buffer) =>
buffer.length >= 12 &&
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP',
'application/pdf': (buffer) =>
buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-',
};
/**
* Verifies a file's actual bytes match its declared MIME type instead of
* trusting the client-supplied value alone (a multipart Content-Type header
* or a data: URL prefix are both attacker-controlled). Used at every upload
* path that persists the bytes to disk under a server-chosen name/extension,
* so a mislabeled file can't be dropped onto the filesystem as a type it
* isn't. Types with no signature defined here are not checked (returns
* true) - callers still gate on their own MIME allowlist first.
*/
export function matchesFileSignature(
buffer: Buffer,
declaredMimeType: string,
): boolean {
const check = SIGNATURE_CHECKS[declaredMimeType];
return check ? check(buffer) : true;
}
@@ -79,7 +79,7 @@ describe('DocumentsService', () => {
describe('create', () => {
const file = {
buffer: Buffer.from('binary'),
buffer: Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01, 0x02]),
originalname: 'bild.jpg',
mimetype: 'image/jpeg',
size: 1234,
@@ -95,6 +95,14 @@ describe('DocumentsService', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a file whose bytes do not match the declared mime type', async () => {
const mislabeled = { ...file, buffer: Buffer.from('not-a-jpeg') };
await expect(
service.create(mislabeled, 'COLONY', 'colony-1', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('throws NotFoundException when the referenced entity does not exist', async () => {
prisma.colony.findUnique.mockResolvedValue(null);
await expect(
@@ -8,6 +8,7 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { matchesFileSignature } from '../common/file-signature';
import { Document, DocumentEntityType } from '../../generated/prisma';
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
@@ -86,6 +87,11 @@ export class DocumentsService {
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
throw new BadRequestException('Nicht unterstützter Dateityp');
}
if (!matchesFileSignature(file.buffer, file.mimetype)) {
throw new BadRequestException(
'Datei entspricht nicht dem angegebenen Format',
);
}
await this.assertEntityExists(entityType, entityId);
const extension = ALLOWED_DOCUMENT_MIME_TYPES[file.mimetype];
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExtractionsService } from './extractions.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -7,6 +7,7 @@ describe('ExtractionsService', () => {
let service: ExtractionsService;
let prisma: {
extraction: { findUnique: jest.Mock; update: jest.Mock };
harvest: { findMany: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
@@ -24,6 +25,11 @@ describe('ExtractionsService', () => {
};
prisma = {
extraction: { findUnique: jest.fn(), update: jest.fn() },
harvest: {
findMany: jest
.fn()
.mockResolvedValue([{ id: 'harvest-1' }, { id: 'harvest-2' }]),
},
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
@@ -59,6 +65,18 @@ describe('ExtractionsService', () => {
]);
});
it('create rejects an unknown harvest id instead of failing on the DB foreign-key constraint', async () => {
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
await expect(
service.create(
{ date: '2026-07-05', harvestIds: ['harvest-1', 'missing-harvest'] },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('update writes an audit log entry with before and after data', async () => {
const updated = { ...extraction, notes: 'geändert' };
prisma.extraction.findUnique
@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { CreateExtractionDto } from './dto/create-extraction.dto';
@@ -37,7 +41,22 @@ export class ExtractionsService {
return extraction;
}
private async assertHarvestsExist(harvestIds: string[]): Promise<void> {
const found = await this.prisma.harvest.findMany({
where: { id: { in: harvestIds } },
select: { id: true },
});
const foundIds = new Set(found.map((harvest) => harvest.id));
const missing = harvestIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new BadRequestException(
`Unknown harvest id(s): ${missing.join(', ')}`,
);
}
}
async create(dto: CreateExtractionDto, actingUserId: string) {
await this.assertHarvestsExist(dto.harvestIds);
const extraction = await this.prisma.$transaction(async (tx) => {
const created = await tx.extraction.create({
data: {
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { HarvestsService } from './harvests.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -8,6 +8,7 @@ describe('HarvestsService', () => {
let service: HarvestsService;
let prisma: {
harvest: { findUnique: jest.Mock; update: jest.Mock };
colony: { findMany: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
@@ -25,6 +26,11 @@ describe('HarvestsService', () => {
};
prisma = {
harvest: { findUnique: jest.fn(), update: jest.fn() },
colony: {
findMany: jest
.fn()
.mockResolvedValue([{ id: 'colony-1' }, { id: 'colony-2' }]),
},
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
@@ -66,6 +72,22 @@ describe('HarvestsService', () => {
]);
});
it('create rejects an unknown colony id instead of failing on the DB foreign-key constraint', async () => {
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
await expect(
service.create(
{
apiaryId: 'apiary-1',
date: '2026-07-01',
colonyIds: ['colony-1', 'missing-colony'],
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('update writes an audit log entry with before and after data', async () => {
const updated = { ...harvest, notes: 'geändert' };
prisma.harvest.findUnique
@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
@@ -35,7 +39,22 @@ export class HarvestsService {
return harvest;
}
private async assertColoniesExist(colonyIds: string[]): Promise<void> {
const found = await this.prisma.colony.findMany({
where: { id: { in: colonyIds } },
select: { id: true },
});
const foundIds = new Set(found.map((colony) => colony.id));
const missing = colonyIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new BadRequestException(
`Unknown colony id(s): ${missing.join(', ')}`,
);
}
}
async create(dto: CreateHarvestDto, actingUserId: string) {
await this.assertColoniesExist(dto.colonyIds);
const harvest = await this.prisma.$transaction(async (tx) => {
const created = await tx.harvest.create({
data: {
@@ -220,6 +220,75 @@ describe('LabelTemplatesService', () => {
expect(pdf.toString('latin1')).toContain('/Image');
});
it('embeds an image element whose bytes match the declared PNG mime type', async () => {
// Minimal valid 1x1 transparent PNG, widely used as a test fixture.
const onePixelPng =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const imageElement: LabelElement = {
id: 'el-img',
type: 'image',
x: 5,
y: 5,
width: 10,
height: 10,
src: `data:image/png;base64,${onePixelPng}`,
};
prisma.labelTemplate.findUnique.mockResolvedValue({
...template,
elements: [imageElement],
});
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1',
jarSize: 500,
jarCount: 1,
lotNumber: 'L-001',
date: new Date('2026-06-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
// pdfkit always lists /ImageB /ImageC /ImageI in a page's ProcSet
// regardless of whether anything is actually embedded, so a plain
// "/Image" substring check would pass either way - /Subtype /Image is
// the actual embedded-XObject marker.
expect(pdf.toString('latin1')).toMatch(/\/Subtype\s*\/Image\b/);
});
it('silently skips an image element whose bytes do not match the declared mime type', async () => {
const mislabeled = Buffer.from('not-a-png').toString('base64');
const imageElement: LabelElement = {
id: 'el-img',
type: 'image',
x: 5,
y: 5,
width: 10,
height: 10,
src: `data:image/png;base64,${mislabeled}`,
};
prisma.labelTemplate.findUnique.mockResolvedValue({
...template,
elements: [imageElement],
});
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1',
jarSize: 500,
jarCount: 1,
lotNumber: 'L-001',
date: new Date('2026-06-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
expect(pdf.toString('latin1')).not.toMatch(/\/Subtype\s*\/Image\b/);
});
function countPages(pdf: Buffer): number {
const matches = pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g);
return matches ? matches.length : 0;
@@ -247,6 +316,33 @@ describe('LabelTemplatesService', () => {
expect(countPages(pdf)).toBe(3);
});
it('clamps an explicit columns/rows override that would run labels off the A4 page', async () => {
// widthMm:90, marginLeftMm:5, gapXMm:2 only fit 2 columns on A4 - an
// explicit 99 would place columns 3+ past the page's right edge,
// where a printer just clips them instead of printing anything.
prisma.labelTemplate.findUnique.mockResolvedValue({
...template,
columns: 99,
rows: 1,
});
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1',
jarSize: 500,
jarCount: 3,
lotNumber: 'L-001',
date: new Date('2026-06-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
// Clamped to the real max of 2 columns x 1 row = 2 per page, so 3
// copies still need 2 pages - same as the explicit columns:2 test.
expect(countPages(pdf)).toBe(2);
});
it('skips already-used slots on the first sheet via startPosition, but not later sheets', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue({
...template,
@@ -3,6 +3,7 @@ import PDFDocument from 'pdfkit';
import * as QRCode from 'qrcode';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { matchesFileSignature } from '../common/file-signature';
import { LabelTemplate } from '../../generated/prisma';
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
@@ -62,7 +63,13 @@ function dataUrlToBuffer(dataUrl: string): Buffer | null {
if (!match) {
return null;
}
return Buffer.from(match[2], 'base64');
const mimeType = match[1] === 'png' ? 'image/png' : 'image/jpeg';
const buffer = Buffer.from(match[2], 'base64');
// Same magic-byte check as every other upload path (avatar, documents) -
// a template's embedded image is only ever entered by a label-template
// editor (label-templates:create/update), but there's no reason to hand
// pdfkit's image parser bytes that don't match what they claim to be.
return matchesFileSignature(buffer, mimeType) ? buffer : null;
}
@Injectable()
@@ -237,15 +244,21 @@ export class LabelTemplatesService {
layout;
const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm;
const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm;
const columns =
layout.columns ??
Math.max(1, Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm)));
const rows =
layout.rows ??
Math.max(
const maxColumns = Math.max(
1,
Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm)),
);
const maxRows = Math.max(
1,
Math.floor((usableHeightMm + gapYMm) / (heightMm + gapYMm)),
);
// An explicit columns/rows value from the sheet-layout designer is
// clamped to what actually fits the A4 page - without this, a value
// typed in by the user (or already saved from before this fix) would
// place labels past the page's right/bottom edge, where printers just
// clip them instead of printing anything.
const columns = Math.min(layout.columns ?? maxColumns, maxColumns);
const rows = Math.min(layout.rows ?? maxRows, maxRows);
const perPage = columns * rows;
// startPosition only skips ahead on the first sheet (partially-used
// physical sheet, e.g. a few labels already peeled off) - every sheet
+22
View File
@@ -5,6 +5,15 @@ import { AppModule } from './app.module';
import { configureApp } from './app.config';
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
// JWT_ACCESS_SECRET is the root of trust for both authentication (every
// access/MFA-challenge/SSO-completion token is signed with it) and MFA
// secret confidentiality (mfa-crypto.util.ts derives the AES key that
// encrypts stored TOTP secrets from it) - a short or guessable value here
// would let an attacker forge tokens for any user AND decrypt everyone's
// stored TOTP secret, not just guess a password. 32 chars matches the
// output of "openssl rand -hex 32" / "-base64 32", the standard way to
// generate one.
const MIN_JWT_SECRET_LENGTH = 32;
function assertRequiredEnvVars(): void {
const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]);
@@ -13,6 +22,12 @@ function assertRequiredEnvVars(): void {
`Missing required environment variable(s): ${missing.join(', ')}`,
);
}
const jwtSecret = process.env.JWT_ACCESS_SECRET as string;
if (jwtSecret.length < MIN_JWT_SECRET_LENGTH) {
throw new Error(
`JWT_ACCESS_SECRET must be at least ${MIN_JWT_SECRET_LENGTH} characters (got ${jwtSecret.length}) - generate one with e.g. "openssl rand -hex 32"`,
);
}
}
async function bootstrap() {
@@ -23,6 +38,12 @@ async function bootstrap() {
configureApp(app);
// Swagger is mounted as raw Express middleware outside Nest's routing, so
// it is never covered by the global JwtAuthGuard - anyone reaching the
// host could otherwise read the full API schema (every endpoint and DTO
// shape) with no authentication at all. Off by default; an operator who
// wants it in a given environment opts in explicitly.
if (process.env.ENABLE_SWAGGER === 'true') {
const swaggerConfig = new DocumentBuilder()
.setTitle('ApiCore API')
.setDescription('Digitale Imkereiverwaltung – API-Dokumentation')
@@ -31,6 +52,7 @@ async function bootstrap() {
.build();
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/v1/docs', app, swaggerDocument);
}
await app.listen(process.env.PORT ?? 3001);
}
@@ -1,11 +1,13 @@
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AnyAuthenticatedUser } from '../auth/decorators/any-authenticated-user.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { NotificationsService } from './notifications.service';
@ApiTags('notifications')
@ApiBearerAuth()
@AnyAuthenticatedUser()
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@@ -1,3 +1,5 @@
export const MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024;
export const ALLOWED_AVATAR_MIME_TYPES: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MaxLength } from 'class-validator';
// Base64 data: URL instead of multipart/form-data - some reverse-proxy
// setups mishandle multipart uploads (seen with this deployment's own
// container registry pushes, see .gitea/workflows/*-image.yml), while plain
// JSON POSTs have proven reliable end to end. 3_000_000 chars covers the
// 2 MB size cap (MAX_AVATAR_SIZE_BYTES) plus base64's ~33% overhead and the
// data: URL prefix, with headroom.
export class UploadAvatarDto {
@ApiProperty({
description:
'Bild als data: URL, z.B. "data:image/png;base64,...". Erlaubt: image/jpeg, image/png, image/webp.',
})
@IsString()
@MaxLength(3_000_000)
data!: string;
}
@@ -1,5 +1,4 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -8,26 +7,22 @@ import {
Patch,
Post,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Response } from 'express';
import { AnyAuthenticatedUser } from '../auth/decorators/any-authenticated-user.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle';
import { isAllowedAvatarMimeType } from './avatar-mime-types';
import { ChangePasswordDto } from './dto/change-password.dto';
import { DeactivateAccountDto } from './dto/deactivate-account.dto';
import { DisableMfaDto } from './dto/disable-mfa.dto';
import { MfaConfirmDto } from './dto/mfa-confirm.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { UploadAvatarDto } from './dto/upload-avatar.dto';
import { ProfileService } from './profile.service';
const MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024;
/**
* Deliberately its own top-level path, not nested under /users/:id -
* "/users/me" would collide with UsersController's PATCH/GET ":id" route
@@ -39,6 +34,7 @@ const MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024;
*/
@ApiTags('profile')
@ApiBearerAuth()
@AnyAuthenticatedUser()
@Controller('profile')
export class ProfileController {
constructor(private readonly profileService: ProfileService) {}
@@ -76,31 +72,12 @@ export class ProfileController {
}
@Post('avatar')
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: MAX_AVATAR_SIZE_BYTES },
fileFilter: (_req, file, callback) => {
if (!isAllowedAvatarMimeType(file.mimetype)) {
callback(
new BadRequestException('Nicht unterstützter Dateityp'),
false,
);
return;
}
callback(null, true);
},
}),
)
@HttpCode(204)
uploadAvatar(
@UploadedFile() file: Express.Multer.File | undefined,
@Body() dto: UploadAvatarDto,
@CurrentUser() actor: AuthenticatedUser,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.profileService.uploadAvatar(actor.id, file);
return this.profileService.uploadAvatar(actor.id, dto.data);
}
@Delete('avatar')
@@ -125,6 +102,7 @@ export class ProfileController {
return this.profileService.enrollMfa(actor.id, actor.email);
}
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('mfa/enroll/confirm')
@HttpCode(204)
confirmMfaEnrollment(
@@ -178,22 +178,37 @@ describe('ProfileService', () => {
});
describe('uploadAvatar', () => {
const file = {
buffer: Buffer.from('binary'),
originalname: 'me.jpg',
mimetype: 'image/jpeg',
size: 1234,
} as Express.Multer.File;
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01, 0x02]);
const dataUrl = `data:image/jpeg;base64,${jpegBytes.toString('base64')}`;
it('rejects a value that is not a data: URL', async () => {
await expect(
service.uploadAvatar('user-1', 'not-a-data-url'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects an unsupported mime type', async () => {
const zipDataUrl = dataUrl.replace('image/jpeg', 'application/zip');
await expect(
service.uploadAvatar('user-1', {
...file,
mimetype: 'application/zip',
}),
service.uploadAvatar('user-1', zipDataUrl),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a file larger than the 2 MB cap', async () => {
const oversized = `data:image/jpeg;base64,${Buffer.alloc(3 * 1024 * 1024).toString('base64')}`;
await expect(
service.uploadAvatar('user-1', oversized),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects bytes that do not match the declared mime type', async () => {
const mislabeled = `data:image/jpeg;base64,${Buffer.from('not-a-jpeg').toString('base64')}`;
await expect(
service.uploadAvatar('user-1', mislabeled),
).rejects.toBeInstanceOf(BadRequestException);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('writes the file, updates avatarPath, and deletes the old file if present', async () => {
prisma.user.findUnique.mockResolvedValue({
...baseUser,
@@ -201,7 +216,7 @@ describe('ProfileService', () => {
});
prisma.user.update.mockResolvedValue({});
await service.uploadAvatar('user-1', file);
await service.uploadAvatar('user-1', dataUrl);
expect(fs.writeFile).toHaveBeenCalled();
expect(fs.unlink).toHaveBeenCalledWith('/uploads/avatars/old.jpg');
+20 -4
View File
@@ -11,11 +11,13 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { matchesFileSignature } from '../common/file-signature';
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { UsersService } from '../users/users.service';
import {
ALLOWED_AVATAR_MIME_TYPES,
MAX_AVATAR_SIZE_BYTES,
mimeTypeForExtension,
} from './avatar-mime-types';
import { UpdateProfileDto } from './dto/update-profile.dto';
@@ -141,19 +143,33 @@ export class ProfileService {
await this.usersService.deactivate(userId, userId);
}
async uploadAvatar(userId: string, file: Express.Multer.File): Promise<void> {
if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) {
async uploadAvatar(userId: string, dataUrl: string): Promise<void> {
const match = /^data:(image\/(?:jpeg|png|webp));base64,(.+)$/.exec(dataUrl);
if (!match) {
throw new BadRequestException('Ungültiges Bildformat');
}
const [, mimeType, base64] = match;
if (!(mimeType in ALLOWED_AVATAR_MIME_TYPES)) {
throw new BadRequestException('Nicht unterstützter Dateityp');
}
const buffer = Buffer.from(base64, 'base64');
if (buffer.length > MAX_AVATAR_SIZE_BYTES) {
throw new BadRequestException('Datei zu groß (max. 2 MB)');
}
if (!matchesFileSignature(buffer, mimeType)) {
throw new BadRequestException(
'Datei entspricht nicht dem angegebenen Format',
);
}
const user = await this.requireUser(userId);
const extension = ALLOWED_AVATAR_MIME_TYPES[file.mimetype];
const extension = ALLOWED_AVATAR_MIME_TYPES[mimeType];
const directory = avatarStorageRoot();
await fs.mkdir(directory, { recursive: true });
const avatarPath = path.join(
directory,
`${crypto.randomUUID()}${extension}`,
);
await fs.writeFile(avatarPath, file.buffer);
await fs.writeFile(avatarPath, buffer);
if (user.avatarPath) {
await fs.unlink(user.avatarPath).catch(() => undefined);
}
@@ -0,0 +1,288 @@
import {
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { RolesService } from './roles.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('RolesService', () => {
let service: RolesService;
let tx: {
role: {
update: jest.Mock;
findUniqueOrThrow: jest.Mock;
create: jest.Mock;
};
rolePermission: { deleteMany: jest.Mock; createMany: jest.Mock };
permission: { findMany: jest.Mock };
};
let prisma: {
role: { findUnique: jest.Mock; findMany: jest.Mock; update: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let permissionResolver: {
invalidateAll: jest.Mock;
getPermissions: jest.Mock;
};
const staffRole = {
id: 'role-staff',
key: 'STAFF',
name: 'Mitarbeiter',
description: null,
isSystem: false,
status: 'ACTIVE',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
deactivatedAt: null as Date | null,
permissions: [{ permission: { key: 'apiaries:read' } }],
_count: { users: 0 },
};
const adminRole = {
...staffRole,
id: 'role-admin',
key: 'ADMIN',
isSystem: true,
};
beforeEach(() => {
tx = {
role: {
update: jest.fn().mockResolvedValue({}),
findUniqueOrThrow: jest.fn(),
create: jest.fn(),
},
rolePermission: {
deleteMany: jest.fn().mockResolvedValue({}),
createMany: jest.fn().mockResolvedValue({}),
},
permission: {
findMany: jest.fn().mockResolvedValue([{ id: 'perm-1' }]),
},
};
prisma = {
role: {
findUnique: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = {
invalidateAll: jest.fn(),
getPermissions: jest.fn().mockResolvedValue(new Set(['apiaries:read'])),
};
service = new RolesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
);
});
describe('findById', () => {
it('throws NotFoundException when the role does not exist', async () => {
prisma.role.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('create', () => {
it('throws ConflictException when the key is already in use', async () => {
prisma.role.findUnique.mockResolvedValue(staffRole);
await expect(
service.create(
{ key: 'STAFF', name: 'x', permissionKeys: [] },
'user-1',
),
).rejects.toBeInstanceOf(ConflictException);
});
it('throws ForbiddenException when granting a permission the caller does not hold', async () => {
prisma.role.findUnique.mockResolvedValue(null);
permissionResolver.getPermissions.mockResolvedValue(
new Set(['apiaries:read']),
);
await expect(
service.create(
{
key: 'MANAGER',
name: 'Manager',
permissionKeys: ['apiaries:read', 'users:assign-role'],
},
'user-1',
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(tx.rolePermission.createMany).not.toHaveBeenCalled();
});
it('creates the role and grants only permissions the caller already holds', async () => {
prisma.role.findUnique.mockResolvedValue(null);
permissionResolver.getPermissions.mockResolvedValue(
new Set(['apiaries:read']),
);
tx.role.create.mockResolvedValue(staffRole);
tx.role.findUniqueOrThrow.mockResolvedValue(staffRole);
const result = await service.create(
{
key: 'STAFF',
name: 'Mitarbeiter',
permissionKeys: ['apiaries:read'],
},
'user-1',
);
expect(result.key).toBe('STAFF');
expect(permissionResolver.invalidateAll).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Role', action: 'CREATE' }),
);
});
it('skips the permission-holding check when there is no acting user', async () => {
prisma.role.findUnique.mockResolvedValue(null);
tx.role.create.mockResolvedValue(staffRole);
tx.role.findUniqueOrThrow.mockResolvedValue(staffRole);
await expect(
service.create({
key: 'STAFF',
name: 'Mitarbeiter',
permissionKeys: ['users:assign-role'],
}),
).resolves.toBeDefined();
expect(permissionResolver.getPermissions).not.toHaveBeenCalled();
});
});
describe('update', () => {
it('throws ForbiddenException when the role is a system role', async () => {
prisma.role.findUnique.mockResolvedValue(adminRole);
await expect(
service.update('role-admin', { name: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('throws ForbiddenException when granting a permission the caller does not hold, even for their own role', async () => {
prisma.role.findUnique.mockResolvedValue(staffRole);
permissionResolver.getPermissions.mockResolvedValue(
new Set(['apiaries:read']),
);
await expect(
service.update(
'role-staff',
{
permissionKeys: [
'apiaries:read',
'roles:update',
'users:assign-role',
],
},
'user-1',
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(tx.role.update).not.toHaveBeenCalled();
});
it('updates name/description without triggering the permission check when permissionKeys is absent', async () => {
prisma.role.findUnique.mockResolvedValue(staffRole);
tx.role.findUniqueOrThrow.mockResolvedValue(staffRole);
await service.update('role-staff', { name: 'Neuer Name' }, 'user-1');
expect(permissionResolver.getPermissions).not.toHaveBeenCalled();
expect(tx.rolePermission.deleteMany).not.toHaveBeenCalled();
});
it('replaces permissions and invalidates the cache when the caller holds everything granted', async () => {
prisma.role.findUnique.mockResolvedValue(staffRole);
permissionResolver.getPermissions.mockResolvedValue(
new Set(['apiaries:read']),
);
tx.role.findUniqueOrThrow.mockResolvedValue(staffRole);
const result = await service.update(
'role-staff',
{ permissionKeys: ['apiaries:read'] },
'user-1',
);
expect(result.key).toBe('STAFF');
expect(tx.rolePermission.deleteMany).toHaveBeenCalledWith({
where: { roleId: 'role-staff' },
});
expect(permissionResolver.invalidateAll).toHaveBeenCalled();
});
});
describe('deactivate', () => {
it('throws ForbiddenException for a system role', async () => {
prisma.role.findUnique.mockResolvedValue(adminRole);
await expect(
service.deactivate('role-admin', 'user-1'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('throws ConflictException when users are still assigned', async () => {
prisma.role.findUnique.mockResolvedValue({
...staffRole,
_count: { users: 2 },
});
await expect(
service.deactivate('role-staff', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('deactivates an unused role', async () => {
prisma.role.findUnique.mockResolvedValue(staffRole);
prisma.role.update.mockResolvedValue({
...staffRole,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
const result = await service.deactivate('role-staff', 'user-1');
expect(result.status).toBe('INACTIVE');
expect(permissionResolver.invalidateAll).toHaveBeenCalled();
});
});
describe('reactivate', () => {
it('throws ConflictException when already active', async () => {
prisma.role.findUnique.mockResolvedValue(staffRole);
await expect(
service.reactivate('role-staff', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('reactivates an inactive role', async () => {
prisma.role.findUnique.mockResolvedValue({
...staffRole,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
prisma.role.update.mockResolvedValue({
...staffRole,
status: 'ACTIVE',
deactivatedAt: null,
});
const result = await service.reactivate('role-staff', 'user-1');
expect(result.status).toBe('ACTIVE');
});
});
});
@@ -81,6 +81,31 @@ export class RolesService {
return this.toSummary(await this.findById(id));
}
/**
* Nobody can grant a role a permission they do not hold themselves - a
* caller with only roles:update could otherwise turn any role (including
* one they belong to) into a shadow-admin by adding every catalog key, no
* users:assign-role needed at all since editing a role's own grants
* doesn't touch User.roleId. This is the standard "can't escalate beyond
* what you have" RBAC rule.
*/
private async assertCanGrantPermissions(
permissionKeys: string[],
actingUserId?: string,
): Promise<void> {
if (!actingUserId) {
return;
}
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
const missing = permissionKeys.filter((key) => !callerPermissions.has(key));
if (missing.length > 0) {
throw new ForbiddenException(
`Cannot grant permissions you do not hold yourself: ${missing.join(', ')}`,
);
}
}
async create(
dto: CreateRoleDto,
actingUserId?: string,
@@ -91,6 +116,7 @@ export class RolesService {
if (existing) {
throw new ConflictException('Role key already in use');
}
await this.assertCanGrantPermissions(dto.permissionKeys, actingUserId);
const role = await this.prisma.$transaction(async (tx) => {
const created = await tx.role.create({
data: {
@@ -126,6 +152,9 @@ export class RolesService {
if (before.isSystem) {
throw new ForbiddenException('System roles cannot be edited');
}
if (dto.permissionKeys) {
await this.assertCanGrantPermissions(dto.permissionKeys, actingUserId);
}
const role = await this.prisma.$transaction(async (tx) => {
await tx.role.update({
where: { id },
@@ -1,4 +1,8 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import {
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -22,6 +26,7 @@ describe('UsersService', () => {
let permissionResolver: {
invalidateUser: jest.Mock;
invalidateAll: jest.Mock;
hasAny: jest.Mock;
};
const staffRole = {
@@ -77,6 +82,7 @@ describe('UsersService', () => {
permissionResolver = {
invalidateUser: jest.fn(),
invalidateAll: jest.fn(),
hasAny: jest.fn().mockResolvedValue(true),
};
usersService = new UsersService(
prisma as unknown as PrismaService,
@@ -324,4 +330,53 @@ describe('UsersService', () => {
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('update throws ForbiddenException when the caller tries to change their own role', async () => {
prisma.user.findUnique.mockResolvedValue(adminUser);
await expect(
usersService.update(adminUser.id, { roleId: staffRole.id }, adminUser.id),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('update throws ForbiddenException when the caller lacks users:assign-role', async () => {
prisma.user.findUnique.mockResolvedValue(existingUser);
prisma.role.findUnique.mockResolvedValue(adminRole);
permissionResolver.hasAny.mockResolvedValue(false);
await expect(
usersService.update(
existingUser.id,
{ roleId: adminRole.id },
adminUser.id,
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('create throws ForbiddenException when the caller lacks users:assign-role', async () => {
prisma.user.findUnique.mockResolvedValue(null);
permissionResolver.hasAny.mockResolvedValue(false);
await expect(
usersService.create(
{
email: '[email protected]',
password: 'password123',
name: 'New User',
roleId: staffRole.id,
},
adminUser.id,
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('resetMfa throws ForbiddenException when targeting your own account', async () => {
await expect(
usersService.resetMfa(adminUser.id, adminUser.id),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+43 -1
View File
@@ -1,5 +1,6 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -55,6 +56,27 @@ export class UsersService {
};
}
/**
* Assigning a role is gated by its own permission, separate from
* users:create/users:update - otherwise any role holding those (e.g. a
* minimal "user manager" role) could hand out any role, including a
* system/admin one, to itself or a colluding account. actingUserId is
* only absent for non-HTTP callers (there are none today, and tests that
* exercise the service directly without an actor), where there is no
* caller to check permissions against.
*/
private async assertCanAssignRole(actingUserId?: string): Promise<void> {
if (!actingUserId) {
return;
}
const allowed = await this.permissionResolver.hasAny(actingUserId, [
'users:assign-role',
]);
if (!allowed) {
throw new ForbiddenException('Missing permission to assign roles');
}
}
private async assertActiveRole(roleId: string): Promise<void> {
const role = await this.prisma.role.findUnique({ where: { id: roleId } });
if (!role) {
@@ -127,6 +149,7 @@ export class UsersService {
throw new ConflictException('Email already in use');
}
await this.assertActiveRole(dto.roleId);
await this.assertCanAssignRole(actingUserId);
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
const user = await this.prisma.user.create({
data: {
@@ -158,6 +181,13 @@ export class UsersService {
await this.assertActiveRole(dto.roleId);
}
if (roleChanged) {
// Role changes for yourself always require a second party, regardless
// of what permission the caller holds - otherwise anyone who can
// reach this endpoint at all could grant themselves admin.
if (actingUserId && id === actingUserId) {
throw new ForbiddenException('Cannot change your own role');
}
await this.assertCanAssignRole(actingUserId);
await this.assertNotLastSystemRoleUser(id);
}
const user = await this.prisma.user.update({
@@ -202,8 +232,20 @@ export class UsersService {
return this.toSummary(user);
}
/** Recovery path for a user who lost their MFA device (no email-reset flow exists in this project). */
/**
* Recovery path for a user who lost their MFA device (no email-reset flow
* exists in this project). Deliberately blocked for the caller's own
* account: unlike resetting someone else's MFA, self-service MFA removal
* belongs behind a password check (POST /profile/mfa/disable) - otherwise
* a stolen access token alone (no password) would be enough to strip the
* victim's own MFA protection via this admin route.
*/
async resetMfa(id: string, actingUserId?: string): Promise<UserSummary> {
if (actingUserId && id === actingUserId) {
throw new ForbiddenException(
'Use /profile/mfa/disable to disable your own MFA',
);
}
await this.findById(id);
const user = await this.prisma.user.update({
where: { id },
@@ -52,7 +52,7 @@ export class WebhooksService {
entityId: subscription.id,
action: 'CREATE',
userId: actingUserId,
afterData: { ...subscription, secret: undefined },
afterData: this.omitSecret(subscription),
});
return subscription;
}
@@ -70,10 +70,22 @@ export class WebhooksService {
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: { ...subscription, secret: undefined },
beforeData: this.omitSecret(subscription),
});
}
// Explicit deletion rather than `{ ...subscription, secret: undefined }`:
// that pattern only works because Prisma's JSON serialization happens to
// drop undefined-valued keys today - an explicit delete doesn't depend on
// that being true forever.
private omitSecret(
subscription: WebhookSubscription,
): Record<string, unknown> {
const redacted: Partial<WebhookSubscription> = { ...subscription };
delete redacted.secret;
return redacted;
}
async dispatch(event: WebhookEvent, payload: unknown): Promise<void> {
const subscriptions = await this.prisma.webhookSubscription.findMany({
where: { active: true, events: { has: event } },
@@ -9,28 +9,20 @@ import { PATH_METADATA } from '@nestjs/common/constants';
import { AppModule } from '../src/app.module';
import { IS_PUBLIC_KEY } from '../src/auth/decorators/public.decorator';
import { REQUIRE_PERMISSION_KEY } from '../src/auth/decorators/require-permission.decorator';
import { ANY_AUTHENTICATED_USER_KEY } from '../src/auth/decorators/any-authenticated-user.decorator';
import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
// Routes intentionally left without a role/permission check: they act on the
// caller's own data (notifications) or are the auth bootstrap endpoint that
// every authenticated user must be able to reach regardless of permissions.
const ALLOWLIST = new Set([
'NotificationsController.findAll',
'NotificationsController.markRead',
'AuthController.me',
'ProfileController.updateProfile',
'ProfileController.changePassword',
'ProfileController.deactivateAccount',
'ProfileController.uploadAvatar',
'ProfileController.deleteAvatar',
'ProfileController.getAvatar',
'ProfileController.enrollMfa',
'ProfileController.confirmMfaEnrollment',
'ProfileController.disableMfa',
]);
/**
* Mirrors PermissionsGuard's own runtime contract exactly (see
* src/auth/guards/permissions.guard.ts): every non-public route must carry
* either @RequirePermission or @AnyAuthenticatedUser, or the guard denies it
* at runtime regardless of what this test says. This test exists to catch
* the mistake at build time - with a clear message pointing at the missing
* decorator - rather than as a live production 403 discovered by a user, but
* a route that fails it would also fail closed live, not open.
*/
describe('Authorization coverage (e2e)', () => {
it('every non-public route requires a catalog-valid permission', async () => {
it('every non-public route requires a permission or an explicit any-authenticated-user opt-in', async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, DiscoveryModule],
}).compile();
@@ -68,7 +60,7 @@ describe('Authorization coverage (e2e)', () => {
const routeKey = `${controllerName}.${methodName}`;
const isPublicMethod =
reflector.get<boolean>(IS_PUBLIC_KEY, handler) === true;
if (isPublicClass || isPublicMethod || ALLOWLIST.has(routeKey)) {
if (isPublicClass || isPublicMethod) {
continue;
}
@@ -76,10 +68,15 @@ describe('Authorization coverage (e2e)', () => {
REQUIRE_PERMISSION_KEY,
[handler, constructor],
);
const hasPermissions =
Array.isArray(permissions) && permissions.length > 0;
if (!hasPermissions) {
const isAnyAuthenticatedUser =
reflector.getAllAndOverride<boolean>(ANY_AUTHENTICATED_USER_KEY, [
handler,
constructor,
]) === true;
if (!hasPermissions && !isAnyAuthenticatedUser) {
uncoveredRoutes.push(routeKey);
}
if (hasPermissions) {
+49
View File
@@ -1,5 +1,44 @@
import type { NextConfig } from "next";
// Runs when the Node server process starts inside the container, so this
// correctly reads the deployment's actual runtime API_URL (see
// src/lib/config/runtime-config.ts for why API_URL is a runtime env var
// rather than baked into the client bundle at build time) - this is server
// startup, not the browser bundle, so it isn't subject to that constraint.
function apiOrigin(): string {
try {
return new URL(process.env.API_URL ?? "http://localhost:3001").origin;
} catch {
return "";
}
}
// No CSP nonce here on purpose: nonce-based CSP requires every page to be
// dynamically rendered (Next.js CSP guide, "Static vs Dynamic Rendering"),
// which would drop this app's static optimization across the board. This
// policy still blocks the things that matter for a mostly-same-origin app -
// cross-origin exfiltration, clickjacking, base-tag/form hijacking - via
// 'unsafe-inline' only for script/style (needed for Next's own hydration
// data and Tailwind's inline styles), matching the same trade-off the
// backend's own helmet CSP already makes for style-src.
function cspHeader(): string {
return `
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
connect-src 'self' ${apiOrigin()};
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self';
upgrade-insecure-requests;
`
.replace(/\s{2,}/g, " ")
.trim();
}
const nextConfig: NextConfig = {
output: "standalone",
async headers() {
@@ -13,6 +52,16 @@ const nextConfig: NextConfig = {
source: "/sw.js",
headers: [{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }],
},
{
source: "/:path*",
headers: [
{ key: "Content-Security-Policy", value: cspHeader() },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "no-referrer" },
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
],
},
];
},
};
@@ -35,6 +35,8 @@ import { LabelCanvas } from "./label-canvas";
import { ElementPropertiesPanel } from "./element-properties-panel";
const ELEMENT_TYPES = ["text", "qrCode", "image", "line"] as const;
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const ZOOM_STEPS: number[] = [0.5, 0.75, 1, 1.25, 1.5, 2];
const ZOOM_MIN = ZOOM_STEPS[0];
const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1];
@@ -86,21 +88,36 @@ export interface LabelDesignerValue {
// Loose runtime check for imported JSON (own export, or hand/AI-authored) -
// only verifies the shape is plausible enough to load into the canvas;
// the backend's own DTO validation is still the real boundary check on save.
function isLabelElementArray(value: unknown): value is LabelElement[] {
return (
Array.isArray(value) &&
value.every(
(item) =>
typeof item === "object" &&
item !== null &&
"id" in item &&
"type" in item &&
"x" in item &&
"y" in item &&
ELEMENT_TYPES.includes((item as { type: unknown }).type as (typeof ELEMENT_TYPES)[number]),
)
// Returns a specific reason per bad element instead of a flat true/false, so
// an AI-authored file that guessed the wrong schema tells the user exactly
// what to fix rather than a generic "invalid file".
function validateImportedElements(value: unknown): { elements: LabelElement[] } | { errors: string[] } {
if (!Array.isArray(value)) {
return { errors: ['Die Datei enthält kein "elements"-Array.'] };
}
const errors: string[] = [];
value.forEach((item, index) => {
const label = typeof item === "object" && item && "id" in item ? String((item as { id: unknown }).id) : `#${index + 1}`;
if (typeof item !== "object" || item === null) {
errors.push(`Element ${label}: kein Objekt.`);
return;
}
if (!("x" in item) || !("y" in item)) {
errors.push(`Element ${label}: "x"/"y" fehlen (nicht "xMm"/"yMm").`);
return;
}
const type = (item as { type?: unknown }).type;
if (!ELEMENT_TYPES.includes(type as (typeof ELEMENT_TYPES)[number])) {
errors.push(
`Element ${label}: Typ "${String(type)}" wird nicht unterstützt (nur ${ELEMENT_TYPES.join(", ")}).`,
);
}
});
if (errors.length > 0) {
return { errors };
}
return { elements: value as LabelElement[] };
}
function slugify(name: string): string {
return (
@@ -137,6 +154,21 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
const selectedElement = elements.find((element) => element.id === selectedId) ?? null;
// Mirrors the backend's clamp in label-templates.service.ts exactly, so
// the hint shown here always matches what actually prints - a value typed
// in above this is silently capped server-side rather than running labels
// off the physical A4 page.
const maxColumns = Math.max(
1,
Math.floor((A4_WIDTH_MM - 2 * marginLeftMm + gapXMm) / (widthMm + gapXMm)),
);
const maxRows = Math.max(
1,
Math.floor((A4_HEIGHT_MM - 2 * marginTopMm + gapYMm) / (heightMm + gapYMm)),
);
const columnsExceedsMax = columns !== null && columns > maxColumns;
const rowsExceedsMax = rows !== null && rows > maxRows;
function addElement(element: LabelElement) {
setElements((current) => [...current, element]);
setSelectedId(element.id);
@@ -235,11 +267,15 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
const importedElements = Array.isArray(parsed)
? parsed
: (parsed as { elements?: unknown })?.elements;
if (!isLabelElementArray(importedElements)) {
toast.error("Datei enthält keine gültige Etikettenvorlage.");
const result = validateImportedElements(importedElements);
if ("errors" in result) {
toast.error("Vorlage konnte nicht importiert werden", {
description: result.errors.slice(0, 5).join(" · "),
duration: 15000,
});
return;
}
setElements(importedElements.map((element) => ({ ...element, id: newId() })));
setElements(result.elements.map((element) => ({ ...element, id: newId() })));
if (!Array.isArray(parsed)) {
const value = parsed as Partial<LabelDesignerValue>;
if (typeof value.name === "string" && value.name && !name) setName(value.name);
@@ -346,9 +382,11 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">
Spalten/Zeilen leer lassen, um automatisch so viele einzuplanen wie passen. Feste
Werte eintragen, um exakt zum vorgedruckten Raster eines Etikettenbogens (z.B.
Avery-Vorlage) zu passen.
Spalten/Zeilen leer lassen, um automatisch so viele einzuplanen wie passen (aktuell{" "}
{maxColumns} × {maxRows}). Feste Werte eintragen, um exakt zum vorgedruckten Raster
eines Etikettenbogens (z.B. Avery-Vorlage) zu passen - mehr als {maxColumns} Spalten
bzw. {maxRows} Zeilen passen bei dieser Etikettengröße und diesen Rändern nicht auf
ein A4-Blatt und werden beim Drucken abgeschnitten.
</p>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
@@ -363,6 +401,11 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
setColumns(event.target.value === "" ? null : Number(event.target.value))
}
/>
{columnsExceedsMax ? (
<p className="text-xs text-destructive">
Passt nicht auf A4 - maximal {maxColumns} Spalte{maxColumns === 1 ? "" : "n"}.
</p>
) : null}
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="designer-rows">Zeilen</Label>
@@ -376,6 +419,11 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
setRows(event.target.value === "" ? null : Number(event.target.value))
}
/>
{rowsExceedsMax ? (
<p className="text-xs text-destructive">
Passt nicht auf A4 - maximal {maxRows} Zeile{maxRows === 1 ? "" : "n"}.
</p>
) : null}
</div>
</div>
<div className="flex gap-3">
@@ -111,11 +111,25 @@ describe("LoginPage", () => {
it("completes SSO login and redirects to the dashboard when returning with ssoPending", async () => {
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
completeSsoMock.mockResolvedValueOnce(undefined);
completeSsoMock.mockResolvedValueOnce({ mfaRequired: false });
render(<LoginPage />);
expect(await screen.findByText(/microsoft-anmeldung wird abgeschlossen/i)).toBeInTheDocument();
await vi.waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/dashboard"));
expect(completeSsoMock).toHaveBeenCalled();
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
});
it("shows the MFA code step when SSO completion still requires local MFA", async () => {
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
completeSsoMock.mockResolvedValueOnce({ mfaRequired: true, mfaToken: "sso-mfa-token" });
verifyMfaMock.mockResolvedValueOnce(undefined);
render(<LoginPage />);
const codeInput = await screen.findByLabelText("Code");
expect(replaceMock).toHaveBeenCalledWith("/login");
await userEvent.type(codeInput, "123456");
await userEvent.click(screen.getByRole("button", { name: /bestätigen/i }));
expect(verifyMfaMock).toHaveBeenCalledWith("sso-mfa-token", "123456");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
+15 -2
View File
@@ -51,7 +51,11 @@ function LoginPageContent() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [ssoEnabled, setSsoEnabled] = useState(false);
const [ssoLoginHref, setSsoLoginHref] = useState<string | null>(null);
const isCompletingSso = ssoPending;
// Its own state rather than deriving straight from the ssoPending query
// param: once completeSso() resolves with an MFA challenge, the MFA form
// must show immediately (same render pass as setMfaToken), without
// waiting on a router.replace() round trip to first clear the param.
const [isCompletingSso, setIsCompletingSso] = useState(ssoPending);
useEffect(() => {
ssoStatusRequest().then(setSsoEnabled);
@@ -66,8 +70,17 @@ function LoginPageContent() {
useEffect(() => {
if (ssoPending) {
completeSso()
.then(() => router.replace("/dashboard"))
.then((outcome) => {
setIsCompletingSso(false);
if (outcome.mfaRequired) {
setMfaToken(outcome.mfaToken);
router.replace("/login");
} else {
router.replace("/dashboard");
}
})
.catch(() => {
setIsCompletingSso(false);
setError("SSO-Anmeldung fehlgeschlagen, bitte erneut versuchen.");
router.replace("/login");
});
+5 -2
View File
@@ -93,7 +93,10 @@ export async function ssoLoginUrl(): Promise<string> {
// The one-time completion code lives only in an httpOnly cookie the callback
// set (never in the URL or a request body) - the browser sends it automatically.
export async function ssoCompleteRequest(): Promise<AccessTokenResponse> {
// Returns the same union as loginRequest: SSO establishes identity, not the
// second factor, so a user with local MFA enabled still has to complete the
// same challenge as a password login before tokens are issued.
export async function ssoCompleteRequest(): Promise<LoginResponse> {
const apiUrl = await getApiUrl();
const response = await fetch(`${apiUrl}/auth/sso/complete`, {
method: 'POST',
@@ -102,7 +105,7 @@ export async function ssoCompleteRequest(): Promise<AccessTokenResponse> {
if (!response.ok) {
throw new Error('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.');
}
return response.json() as Promise<AccessTokenResponse>;
return response.json() as Promise<LoginResponse>;
}
export async function logoutRequest(): Promise<void> {
@@ -30,7 +30,7 @@ interface AuthContextValue {
isLoading: boolean;
login: (email: string, password: string) => Promise<LoginOutcome>;
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
completeSso: () => Promise<void>;
completeSso: () => Promise<LoginOutcome>;
logout: () => Promise<void>;
hasPermission: (permission: string) => boolean;
refreshUser: () => Promise<void>;
@@ -78,9 +78,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(await applyAccessToken(result.accessToken));
}, []);
const completeSso = useCallback(async () => {
const completeSso = useCallback(async (): Promise<LoginOutcome> => {
const result = await ssoCompleteRequest();
if (isMfaRequired(result)) {
return { mfaRequired: true, mfaToken: result.mfaToken };
}
setUser(await applyAccessToken(result.accessToken));
return { mfaRequired: false };
}, []);
const logout = useCallback(async () => {
+21 -5
View File
@@ -1,4 +1,4 @@
import { apiFetch, apiFetchBlob, apiFetchFormData } from "@/lib/auth/api-client";
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { MfaEnrollment, ProfileSummary, UpdateProfileInput } from "./types";
export function updateProfile(input: UpdateProfileInput): Promise<ProfileSummary> {
@@ -22,10 +22,26 @@ export function deactivateAccount(currentPassword: string): Promise<void> {
});
}
export function uploadAvatar(file: File): Promise<void> {
const formData = new FormData();
formData.append("file", file);
return apiFetchFormData<void>("/profile/avatar", formData);
// Sent as a base64 data: URL in a plain JSON body rather than
// multipart/form-data: this deployment's Cloudflare proxy has a known
// history of mishandling multipart uploads regardless of size (see the
// registry-push workaround in .gitea/workflows/*-image.yml), while plain
// JSON POSTs to this same backend have been reliable all along.
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export async function uploadAvatar(file: File): Promise<void> {
const data = await fileToDataUrl(file);
return apiFetch<void>("/profile/avatar", {
method: "POST",
body: JSON.stringify({ data }),
});
}
export function deleteAvatar(): Promise<void> {
+4 -1
View File
@@ -10,7 +10,10 @@ stringData:
# Anderer Namespace als das Backend? Dann FQDN nutzen:
# apicore-postgres.<namespace>.svc.cluster.local
DATABASE_URL: "postgresql://apicore:change-me-postgres-password@apicore-postgres:5432/apicore?schema=public"
JWT_ACCESS_SECRET: "change-me-access-secret"
# Mindestens 32 Zeichen (server-seitig erzwungen, Start bricht sonst ab) -
# signiert jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte
# TOTP-Secrets. Erzeugen z.B. mit: openssl rand -hex 32
JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long"
JWT_REFRESH_SECRET: "change-me-refresh-secret"
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI.