Compare commits
10
Commits
c6941a797f
...
33fa768062
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33fa768062 | ||
|
|
4bd7e9791a | ||
|
|
f57cffe6a8 | ||
|
|
8b8616ce03 | ||
|
|
19d5e5d65c | ||
|
|
b7ff92abd3 | ||
|
|
1b16b49913 | ||
|
|
7bd2a3dc3a | ||
|
|
135738fdfc | ||
|
|
abe1f6e8cf |
No files matched your search
@@ -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
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
+67
-19
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user