Compare commits
10
Commits
| 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"
|
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_REFRESH_SECRET="change-me-refresh-secret"
|
||||||
JWT_ACCESS_EXPIRES_IN="15m"
|
JWT_ACCESS_EXPIRES_IN="15m"
|
||||||
JWT_REFRESH_EXPIRES_IN="7d"
|
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_ID="change-me-entra-client-id"
|
||||||
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
|
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
|
||||||
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
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 'dotenv/config';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { Prisma, PrismaClient } from '../generated/prisma';
|
import { Prisma, PrismaClient } from '../generated/prisma';
|
||||||
|
|
||||||
const BCRYPT_SALT_ROUNDS = 12;
|
const BCRYPT_SALT_ROUNDS = 12;
|
||||||
const DEFAULT_ADMIN_EMAIL = '[email protected]';
|
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
|
// 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
|
// 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 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 } });
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`Seed: admin user ${email} already exists, skipping.`);
|
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
|
// real client IP from X-Forwarded-For — required for per-client throttling
|
||||||
// to work correctly rather than rate-limiting all traffic together.
|
// to work correctly rather than rate-limiting all traffic together.
|
||||||
app.set('trust proxy', 1);
|
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(helmet());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(requestLoggerMiddleware);
|
app.use(requestLoggerMiddleware);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
|||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { Public } from './decorators/public.decorator';
|
import { Public } from './decorators/public.decorator';
|
||||||
|
import { AnyAuthenticatedUser } from './decorators/any-authenticated-user.decorator';
|
||||||
import { CurrentUser } from './decorators/current-user.decorator';
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
import type { AuthenticatedUser } from './types/authenticated-user';
|
import type { AuthenticatedUser } from './types/authenticated-user';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
@@ -25,7 +26,7 @@ import {
|
|||||||
setRefreshTokenCookie,
|
setRefreshTokenCookie,
|
||||||
} from './util/refresh-token-cookie';
|
} from './util/refresh-token-cookie';
|
||||||
|
|
||||||
interface MfaRequiredResponse {
|
export interface MfaRequiredResponse {
|
||||||
mfaRequired: true;
|
mfaRequired: true;
|
||||||
mfaToken: string;
|
mfaToken: string;
|
||||||
}
|
}
|
||||||
@@ -92,6 +93,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@AnyAuthenticatedUser()
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@CurrentUser() user: AuthenticatedUser): Promise<CurrentUserProfile> {
|
me(@CurrentUser() user: AuthenticatedUser): Promise<CurrentUserProfile> {
|
||||||
return this.authService.getCurrentUserProfile(user.id);
|
return this.authService.getCurrentUserProfile(user.id);
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ describe('AuthService', () => {
|
|||||||
};
|
};
|
||||||
let jwtService: { sign: jest.Mock };
|
let jwtService: { sign: jest.Mock };
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
let permissionResolver: { getPermissions: jest.Mock };
|
let permissionResolver: {
|
||||||
|
getPermissions: jest.Mock;
|
||||||
|
invalidateUser: jest.Mock;
|
||||||
|
};
|
||||||
let mfaService: {
|
let mfaService: {
|
||||||
signChallengeToken: jest.Mock;
|
signChallengeToken: jest.Mock;
|
||||||
verifyChallengeToken: jest.Mock;
|
verifyChallengeToken: jest.Mock;
|
||||||
@@ -52,6 +55,7 @@ describe('AuthService', () => {
|
|||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
permissionResolver = {
|
permissionResolver = {
|
||||||
getPermissions: jest.fn().mockResolvedValue(new Set()),
|
getPermissions: jest.fn().mockResolvedValue(new Set()),
|
||||||
|
invalidateUser: jest.fn(),
|
||||||
};
|
};
|
||||||
mfaService = {
|
mfaService = {
|
||||||
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
||||||
@@ -236,6 +240,9 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
const result = await authService.issueTokensForSsoUser(activeUser.id);
|
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(result.accessToken).toBe('signed.jwt.token');
|
||||||
expect(prisma.refreshToken.create).toHaveBeenCalled();
|
expect(prisma.refreshToken.create).toHaveBeenCalled();
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
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 () => {
|
it('throws when the user is deactivated', async () => {
|
||||||
usersService.findById.mockResolvedValue({
|
usersService.findById.mockResolvedValue({
|
||||||
...activeUser,
|
...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({
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||||
id: 'rt-1',
|
id: 'rt-1',
|
||||||
userId: activeUser.id,
|
userId: activeUser.id,
|
||||||
revokedAt: new Date(),
|
revokedAt: new Date(),
|
||||||
expiresAt: new Date(Date.now() + 1000),
|
expiresAt: new Date(Date.now() + 1000),
|
||||||
});
|
});
|
||||||
|
prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 });
|
||||||
|
|
||||||
await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf(
|
await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf(
|
||||||
UnauthorizedException,
|
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 () => {
|
it('throws when the refresh token is expired', async () => {
|
||||||
|
|||||||
@@ -144,12 +144,24 @@ export class AuthService {
|
|||||||
return { mfaRequired: false, accessToken, refreshToken };
|
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);
|
const user = await this.usersService.findById(userId);
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
return {
|
||||||
|
mfaRequired: true,
|
||||||
|
mfaToken: this.mfaService.signChallengeToken(user.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
const accessToken = this.signAccessToken({
|
const accessToken = this.signAccessToken({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -161,7 +173,7 @@ export class AuthService {
|
|||||||
action: 'LOGIN',
|
action: 'LOGIN',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
return { accessToken, refreshToken };
|
return { mfaRequired: false, accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
||||||
@@ -207,7 +219,25 @@ export class AuthService {
|
|||||||
const stored = await this.prisma.refreshToken.findUnique({
|
const stored = await this.prisma.refreshToken.findUnique({
|
||||||
where: { tokenHash },
|
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');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
}
|
}
|
||||||
const user = await this.usersService.findById(stored.userId);
|
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 { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
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 { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
|
||||||
import { PermissionKey } from '../permissions/permission-catalog';
|
import { PermissionKey } from '../permissions/permission-catalog';
|
||||||
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||||
@@ -10,14 +11,15 @@ interface RequestWithUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fail-open by decorator absence: a route with no @RequirePermission is
|
* Fails closed by decorator absence: a non-public route reaches this guard
|
||||||
* allowed through untouched (any authenticated user can reach it), only
|
* only after JwtAuthGuard confirms a valid, active session, and from here it
|
||||||
* routes that carry the decorator are enforced against the caller's live
|
* is denied unless it carries either @RequirePermission (checked against the
|
||||||
* permission set. There is no default-deny here, so
|
* caller's live permission set) or the explicit @AnyAuthenticatedUser opt-out
|
||||||
* test/authorization-coverage.e2e-spec.ts — which fails the build if any
|
* for routes that are meant to be reachable by any logged-in user regardless
|
||||||
* non-public route lacks a catalog-valid @RequirePermission — is the sole
|
* of role. A route with neither is a bug - forgetting to decorate a new
|
||||||
* safety net for "someone added a route and forgot the decorator", not a
|
* route now fails shut at runtime instead of silently granting access to
|
||||||
* defense-in-depth nicety.
|
* every authenticated user and relying solely on
|
||||||
|
* test/authorization-coverage.e2e-spec.ts to catch it before deploy.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PermissionsGuard implements CanActivate {
|
export class PermissionsGuard implements CanActivate {
|
||||||
@@ -30,14 +32,20 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
const requiredPermissions = this.reflector.getAllAndOverride<
|
const requiredPermissions = this.reflector.getAllAndOverride<
|
||||||
PermissionKey[]
|
PermissionKey[]
|
||||||
>(REQUIRE_PERMISSION_KEY, [context.getHandler(), context.getClass()]);
|
>(REQUIRE_PERMISSION_KEY, [context.getHandler(), context.getClass()]);
|
||||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
|
|
||||||
|
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return this.permissionResolver.hasAny(user.id, requiredPermissions);
|
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'],
|
['create', 'Anlegen'],
|
||||||
['update', 'Bearbeiten'],
|
['update', 'Bearbeiten'],
|
||||||
['deactivate', 'Deaktivieren'],
|
['deactivate', 'Deaktivieren'],
|
||||||
|
['assign-role', 'Rolle zuweisen'],
|
||||||
],
|
],
|
||||||
2300,
|
2300,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Throttle } from '@nestjs/throttler';
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { AuthService } from '../auth.service';
|
import { AuthService } from '../auth.service';
|
||||||
|
import { MfaRequiredResponse } from '../auth.controller';
|
||||||
import { AccessTokenDto } from '../dto/access-token.dto';
|
import { AccessTokenDto } from '../dto/access-token.dto';
|
||||||
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
||||||
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
||||||
@@ -121,7 +122,7 @@ export class SsoController {
|
|||||||
async complete(
|
async complete(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<AccessTokenDto> {
|
): Promise<AccessTokenDto | MfaRequiredResponse> {
|
||||||
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
|
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
|
||||||
SSO_CODE_COOKIE
|
SSO_CODE_COOKIE
|
||||||
];
|
];
|
||||||
@@ -132,10 +133,12 @@ export class SsoController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { userId } = this.ssoService.verifySsoCode(ssoCode);
|
const { userId } = this.ssoService.verifySsoCode(ssoCode);
|
||||||
const { accessToken, refreshToken } =
|
const result = await this.authService.issueTokensForSsoUser(userId);
|
||||||
await this.authService.issueTokensForSsoUser(userId);
|
if (result.mfaRequired) {
|
||||||
setRefreshTokenCookie(res, refreshToken);
|
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||||
return { accessToken };
|
}
|
||||||
|
setRefreshTokenCookie(res, result.refreshToken);
|
||||||
|
return { accessToken: result.accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
|
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', () => {
|
describe('create', () => {
|
||||||
const file = {
|
const file = {
|
||||||
buffer: Buffer.from('binary'),
|
buffer: Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01, 0x02]),
|
||||||
originalname: 'bild.jpg',
|
originalname: 'bild.jpg',
|
||||||
mimetype: 'image/jpeg',
|
mimetype: 'image/jpeg',
|
||||||
size: 1234,
|
size: 1234,
|
||||||
@@ -95,6 +95,14 @@ describe('DocumentsService', () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).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 () => {
|
it('throws NotFoundException when the referenced entity does not exist', async () => {
|
||||||
prisma.colony.findUnique.mockResolvedValue(null);
|
prisma.colony.findUnique.mockResolvedValue(null);
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as fs from 'node:fs/promises';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { matchesFileSignature } from '../common/file-signature';
|
||||||
import { Document, DocumentEntityType } from '../../generated/prisma';
|
import { Document, DocumentEntityType } from '../../generated/prisma';
|
||||||
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
|
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
|
||||||
|
|
||||||
@@ -86,6 +87,11 @@ export class DocumentsService {
|
|||||||
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
|
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
|
||||||
throw new BadRequestException('Nicht unterstützter Dateityp');
|
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);
|
await this.assertEntityExists(entityType, entityId);
|
||||||
|
|
||||||
const extension = ALLOWED_DOCUMENT_MIME_TYPES[file.mimetype];
|
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 { ExtractionsService } from './extractions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
@@ -7,6 +7,7 @@ describe('ExtractionsService', () => {
|
|||||||
let service: ExtractionsService;
|
let service: ExtractionsService;
|
||||||
let prisma: {
|
let prisma: {
|
||||||
extraction: { findUnique: jest.Mock; update: jest.Mock };
|
extraction: { findUnique: jest.Mock; update: jest.Mock };
|
||||||
|
harvest: { findMany: jest.Mock };
|
||||||
$transaction: jest.Mock;
|
$transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
@@ -24,6 +25,11 @@ describe('ExtractionsService', () => {
|
|||||||
};
|
};
|
||||||
prisma = {
|
prisma = {
|
||||||
extraction: { findUnique: jest.fn(), update: jest.fn() },
|
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) =>
|
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||||
callback(tx),
|
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 () => {
|
it('update writes an audit log entry with before and after data', async () => {
|
||||||
const updated = { ...extraction, notes: 'geändert' };
|
const updated = { ...extraction, notes: 'geändert' };
|
||||||
prisma.extraction.findUnique
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { CreateExtractionDto } from './dto/create-extraction.dto';
|
import { CreateExtractionDto } from './dto/create-extraction.dto';
|
||||||
@@ -37,7 +41,22 @@ export class ExtractionsService {
|
|||||||
return extraction;
|
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) {
|
async create(dto: CreateExtractionDto, actingUserId: string) {
|
||||||
|
await this.assertHarvestsExist(dto.harvestIds);
|
||||||
const extraction = await this.prisma.$transaction(async (tx) => {
|
const extraction = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.extraction.create({
|
const created = await tx.extraction.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { HarvestsService } from './harvests.service';
|
import { HarvestsService } from './harvests.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
@@ -8,6 +8,7 @@ describe('HarvestsService', () => {
|
|||||||
let service: HarvestsService;
|
let service: HarvestsService;
|
||||||
let prisma: {
|
let prisma: {
|
||||||
harvest: { findUnique: jest.Mock; update: jest.Mock };
|
harvest: { findUnique: jest.Mock; update: jest.Mock };
|
||||||
|
colony: { findMany: jest.Mock };
|
||||||
$transaction: jest.Mock;
|
$transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
@@ -25,6 +26,11 @@ describe('HarvestsService', () => {
|
|||||||
};
|
};
|
||||||
prisma = {
|
prisma = {
|
||||||
harvest: { findUnique: jest.fn(), update: jest.fn() },
|
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) =>
|
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||||
callback(tx),
|
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 () => {
|
it('update writes an audit log entry with before and after data', async () => {
|
||||||
const updated = { ...harvest, notes: 'geändert' };
|
const updated = { ...harvest, notes: 'geändert' };
|
||||||
prisma.harvest.findUnique
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
@@ -35,7 +39,22 @@ export class HarvestsService {
|
|||||||
return harvest;
|
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) {
|
async create(dto: CreateHarvestDto, actingUserId: string) {
|
||||||
|
await this.assertColoniesExist(dto.colonyIds);
|
||||||
const harvest = await this.prisma.$transaction(async (tx) => {
|
const harvest = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.harvest.create({
|
const created = await tx.harvest.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -220,6 +220,75 @@ describe('LabelTemplatesService', () => {
|
|||||||
expect(pdf.toString('latin1')).toContain('/Image');
|
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 {
|
function countPages(pdf: Buffer): number {
|
||||||
const matches = pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g);
|
const matches = pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g);
|
||||||
return matches ? matches.length : 0;
|
return matches ? matches.length : 0;
|
||||||
@@ -247,6 +316,33 @@ describe('LabelTemplatesService', () => {
|
|||||||
expect(countPages(pdf)).toBe(3);
|
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 () => {
|
it('skips already-used slots on the first sheet via startPosition, but not later sheets', async () => {
|
||||||
prisma.labelTemplate.findUnique.mockResolvedValue({
|
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||||
...template,
|
...template,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import PDFDocument from 'pdfkit';
|
|||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { matchesFileSignature } from '../common/file-signature';
|
||||||
import { LabelTemplate } from '../../generated/prisma';
|
import { LabelTemplate } from '../../generated/prisma';
|
||||||
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||||
@@ -62,7 +63,13 @@ function dataUrlToBuffer(dataUrl: string): Buffer | null {
|
|||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
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()
|
@Injectable()
|
||||||
@@ -237,15 +244,21 @@ export class LabelTemplatesService {
|
|||||||
layout;
|
layout;
|
||||||
const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm;
|
const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm;
|
||||||
const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm;
|
const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm;
|
||||||
const columns =
|
const maxColumns = Math.max(
|
||||||
layout.columns ??
|
1,
|
||||||
Math.max(1, Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm)));
|
Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm)),
|
||||||
const rows =
|
);
|
||||||
layout.rows ??
|
const maxRows = Math.max(
|
||||||
Math.max(
|
|
||||||
1,
|
1,
|
||||||
Math.floor((usableHeightMm + gapYMm) / (heightMm + gapYMm)),
|
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;
|
const perPage = columns * rows;
|
||||||
// startPosition only skips ahead on the first sheet (partially-used
|
// startPosition only skips ahead on the first sheet (partially-used
|
||||||
// physical sheet, e.g. a few labels already peeled off) - every sheet
|
// 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';
|
import { configureApp } from './app.config';
|
||||||
|
|
||||||
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
|
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 {
|
function assertRequiredEnvVars(): void {
|
||||||
const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]);
|
const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]);
|
||||||
@@ -13,6 +22,12 @@ function assertRequiredEnvVars(): void {
|
|||||||
`Missing required environment variable(s): ${missing.join(', ')}`,
|
`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() {
|
async function bootstrap() {
|
||||||
@@ -23,6 +38,12 @@ async function bootstrap() {
|
|||||||
|
|
||||||
configureApp(app);
|
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()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('ApiCore API')
|
.setTitle('ApiCore API')
|
||||||
.setDescription('Digitale Imkereiverwaltung – API-Dokumentation')
|
.setDescription('Digitale Imkereiverwaltung – API-Dokumentation')
|
||||||
@@ -31,6 +52,7 @@ async function bootstrap() {
|
|||||||
.build();
|
.build();
|
||||||
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
SwaggerModule.setup('api/v1/docs', app, swaggerDocument);
|
SwaggerModule.setup('api/v1/docs', app, swaggerDocument);
|
||||||
|
}
|
||||||
|
|
||||||
await app.listen(process.env.PORT ?? 3001);
|
await app.listen(process.env.PORT ?? 3001);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
|
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { AnyAuthenticatedUser } from '../auth/decorators/any-authenticated-user.decorator';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
@ApiTags('notifications')
|
@ApiTags('notifications')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@AnyAuthenticatedUser()
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
export class NotificationsController {
|
export class NotificationsController {
|
||||||
constructor(private readonly notificationsService: NotificationsService) {}
|
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> = {
|
export const ALLOWED_AVATAR_MIME_TYPES: Record<string, string> = {
|
||||||
'image/jpeg': '.jpg',
|
'image/jpeg': '.jpg',
|
||||||
'image/png': '.png',
|
'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 {
|
import {
|
||||||
BadRequestException,
|
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -8,26 +7,22 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Res,
|
Res,
|
||||||
UploadedFile,
|
|
||||||
UseInterceptors,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import { AnyAuthenticatedUser } from '../auth/decorators/any-authenticated-user.decorator';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle';
|
import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle';
|
||||||
import { isAllowedAvatarMimeType } from './avatar-mime-types';
|
|
||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
import { DeactivateAccountDto } from './dto/deactivate-account.dto';
|
import { DeactivateAccountDto } from './dto/deactivate-account.dto';
|
||||||
import { DisableMfaDto } from './dto/disable-mfa.dto';
|
import { DisableMfaDto } from './dto/disable-mfa.dto';
|
||||||
import { MfaConfirmDto } from './dto/mfa-confirm.dto';
|
import { MfaConfirmDto } from './dto/mfa-confirm.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
|
import { UploadAvatarDto } from './dto/upload-avatar.dto';
|
||||||
import { ProfileService } from './profile.service';
|
import { ProfileService } from './profile.service';
|
||||||
|
|
||||||
const MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deliberately its own top-level path, not nested under /users/:id -
|
* Deliberately its own top-level path, not nested under /users/:id -
|
||||||
* "/users/me" would collide with UsersController's PATCH/GET ":id" route
|
* "/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')
|
@ApiTags('profile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@AnyAuthenticatedUser()
|
||||||
@Controller('profile')
|
@Controller('profile')
|
||||||
export class ProfileController {
|
export class ProfileController {
|
||||||
constructor(private readonly profileService: ProfileService) {}
|
constructor(private readonly profileService: ProfileService) {}
|
||||||
@@ -76,31 +72,12 @@ export class ProfileController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('avatar')
|
@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)
|
@HttpCode(204)
|
||||||
uploadAvatar(
|
uploadAvatar(
|
||||||
@UploadedFile() file: Express.Multer.File | undefined,
|
@Body() dto: UploadAvatarDto,
|
||||||
@CurrentUser() actor: AuthenticatedUser,
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
) {
|
) {
|
||||||
if (!file) {
|
return this.profileService.uploadAvatar(actor.id, dto.data);
|
||||||
throw new BadRequestException('No file uploaded');
|
|
||||||
}
|
|
||||||
return this.profileService.uploadAvatar(actor.id, file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('avatar')
|
@Delete('avatar')
|
||||||
@@ -125,6 +102,7 @@ export class ProfileController {
|
|||||||
return this.profileService.enrollMfa(actor.id, actor.email);
|
return this.profileService.enrollMfa(actor.id, actor.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||||
@Post('mfa/enroll/confirm')
|
@Post('mfa/enroll/confirm')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
confirmMfaEnrollment(
|
confirmMfaEnrollment(
|
||||||
|
|||||||
@@ -178,22 +178,37 @@ describe('ProfileService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('uploadAvatar', () => {
|
describe('uploadAvatar', () => {
|
||||||
const file = {
|
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01, 0x02]);
|
||||||
buffer: Buffer.from('binary'),
|
const dataUrl = `data:image/jpeg;base64,${jpegBytes.toString('base64')}`;
|
||||||
originalname: 'me.jpg',
|
|
||||||
mimetype: 'image/jpeg',
|
it('rejects a value that is not a data: URL', async () => {
|
||||||
size: 1234,
|
await expect(
|
||||||
} as Express.Multer.File;
|
service.uploadAvatar('user-1', 'not-a-data-url'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects an unsupported mime type', async () => {
|
it('rejects an unsupported mime type', async () => {
|
||||||
|
const zipDataUrl = dataUrl.replace('image/jpeg', 'application/zip');
|
||||||
await expect(
|
await expect(
|
||||||
service.uploadAvatar('user-1', {
|
service.uploadAvatar('user-1', zipDataUrl),
|
||||||
...file,
|
|
||||||
mimetype: 'application/zip',
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).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 () => {
|
it('writes the file, updates avatarPath, and deletes the old file if present', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue({
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
...baseUser,
|
...baseUser,
|
||||||
@@ -201,7 +216,7 @@ describe('ProfileService', () => {
|
|||||||
});
|
});
|
||||||
prisma.user.update.mockResolvedValue({});
|
prisma.user.update.mockResolvedValue({});
|
||||||
|
|
||||||
await service.uploadAvatar('user-1', file);
|
await service.uploadAvatar('user-1', dataUrl);
|
||||||
|
|
||||||
expect(fs.writeFile).toHaveBeenCalled();
|
expect(fs.writeFile).toHaveBeenCalled();
|
||||||
expect(fs.unlink).toHaveBeenCalledWith('/uploads/avatars/old.jpg');
|
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 * as path from 'node:path';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { matchesFileSignature } from '../common/file-signature';
|
||||||
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import {
|
import {
|
||||||
ALLOWED_AVATAR_MIME_TYPES,
|
ALLOWED_AVATAR_MIME_TYPES,
|
||||||
|
MAX_AVATAR_SIZE_BYTES,
|
||||||
mimeTypeForExtension,
|
mimeTypeForExtension,
|
||||||
} from './avatar-mime-types';
|
} from './avatar-mime-types';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
@@ -141,19 +143,33 @@ export class ProfileService {
|
|||||||
await this.usersService.deactivate(userId, userId);
|
await this.usersService.deactivate(userId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadAvatar(userId: string, file: Express.Multer.File): Promise<void> {
|
async uploadAvatar(userId: string, dataUrl: string): Promise<void> {
|
||||||
if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) {
|
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');
|
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 user = await this.requireUser(userId);
|
||||||
const extension = ALLOWED_AVATAR_MIME_TYPES[file.mimetype];
|
const extension = ALLOWED_AVATAR_MIME_TYPES[mimeType];
|
||||||
const directory = avatarStorageRoot();
|
const directory = avatarStorageRoot();
|
||||||
await fs.mkdir(directory, { recursive: true });
|
await fs.mkdir(directory, { recursive: true });
|
||||||
const avatarPath = path.join(
|
const avatarPath = path.join(
|
||||||
directory,
|
directory,
|
||||||
`${crypto.randomUUID()}${extension}`,
|
`${crypto.randomUUID()}${extension}`,
|
||||||
);
|
);
|
||||||
await fs.writeFile(avatarPath, file.buffer);
|
await fs.writeFile(avatarPath, buffer);
|
||||||
if (user.avatarPath) {
|
if (user.avatarPath) {
|
||||||
await fs.unlink(user.avatarPath).catch(() => undefined);
|
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));
|
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(
|
async create(
|
||||||
dto: CreateRoleDto,
|
dto: CreateRoleDto,
|
||||||
actingUserId?: string,
|
actingUserId?: string,
|
||||||
@@ -91,6 +116,7 @@ export class RolesService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException('Role key already in use');
|
throw new ConflictException('Role key already in use');
|
||||||
}
|
}
|
||||||
|
await this.assertCanGrantPermissions(dto.permissionKeys, actingUserId);
|
||||||
const role = await this.prisma.$transaction(async (tx) => {
|
const role = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.role.create({
|
const created = await tx.role.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -126,6 +152,9 @@ export class RolesService {
|
|||||||
if (before.isSystem) {
|
if (before.isSystem) {
|
||||||
throw new ForbiddenException('System roles cannot be edited');
|
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) => {
|
const role = await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.role.update({
|
await tx.role.update({
|
||||||
where: { id },
|
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 { UsersService } from './users.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
@@ -22,6 +26,7 @@ describe('UsersService', () => {
|
|||||||
let permissionResolver: {
|
let permissionResolver: {
|
||||||
invalidateUser: jest.Mock;
|
invalidateUser: jest.Mock;
|
||||||
invalidateAll: jest.Mock;
|
invalidateAll: jest.Mock;
|
||||||
|
hasAny: jest.Mock;
|
||||||
};
|
};
|
||||||
|
|
||||||
const staffRole = {
|
const staffRole = {
|
||||||
@@ -77,6 +82,7 @@ describe('UsersService', () => {
|
|||||||
permissionResolver = {
|
permissionResolver = {
|
||||||
invalidateUser: jest.fn(),
|
invalidateUser: jest.fn(),
|
||||||
invalidateAll: jest.fn(),
|
invalidateAll: jest.fn(),
|
||||||
|
hasAny: jest.fn().mockResolvedValue(true),
|
||||||
};
|
};
|
||||||
usersService = new UsersService(
|
usersService = new UsersService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
@@ -324,4 +330,53 @@ describe('UsersService', () => {
|
|||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
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 {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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> {
|
private async assertActiveRole(roleId: string): Promise<void> {
|
||||||
const role = await this.prisma.role.findUnique({ where: { id: roleId } });
|
const role = await this.prisma.role.findUnique({ where: { id: roleId } });
|
||||||
if (!role) {
|
if (!role) {
|
||||||
@@ -127,6 +149,7 @@ export class UsersService {
|
|||||||
throw new ConflictException('Email already in use');
|
throw new ConflictException('Email already in use');
|
||||||
}
|
}
|
||||||
await this.assertActiveRole(dto.roleId);
|
await this.assertActiveRole(dto.roleId);
|
||||||
|
await this.assertCanAssignRole(actingUserId);
|
||||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
|
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -158,6 +181,13 @@ export class UsersService {
|
|||||||
await this.assertActiveRole(dto.roleId);
|
await this.assertActiveRole(dto.roleId);
|
||||||
}
|
}
|
||||||
if (roleChanged) {
|
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);
|
await this.assertNotLastSystemRoleUser(id);
|
||||||
}
|
}
|
||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
@@ -202,8 +232,20 @@ export class UsersService {
|
|||||||
return this.toSummary(user);
|
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> {
|
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);
|
await this.findById(id);
|
||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class WebhooksService {
|
|||||||
entityId: subscription.id,
|
entityId: subscription.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: { ...subscription, secret: undefined },
|
afterData: this.omitSecret(subscription),
|
||||||
});
|
});
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
@@ -70,10 +70,22 @@ export class WebhooksService {
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'DELETE',
|
action: 'DELETE',
|
||||||
userId: actingUserId,
|
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> {
|
async dispatch(event: WebhookEvent, payload: unknown): Promise<void> {
|
||||||
const subscriptions = await this.prisma.webhookSubscription.findMany({
|
const subscriptions = await this.prisma.webhookSubscription.findMany({
|
||||||
where: { active: true, events: { has: event } },
|
where: { active: true, events: { has: event } },
|
||||||
|
|||||||
@@ -9,28 +9,20 @@ import { PATH_METADATA } from '@nestjs/common/constants';
|
|||||||
import { AppModule } from '../src/app.module';
|
import { AppModule } from '../src/app.module';
|
||||||
import { IS_PUBLIC_KEY } from '../src/auth/decorators/public.decorator';
|
import { IS_PUBLIC_KEY } from '../src/auth/decorators/public.decorator';
|
||||||
import { REQUIRE_PERMISSION_KEY } from '../src/auth/decorators/require-permission.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';
|
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
|
* Mirrors PermissionsGuard's own runtime contract exactly (see
|
||||||
// every authenticated user must be able to reach regardless of permissions.
|
* src/auth/guards/permissions.guard.ts): every non-public route must carry
|
||||||
const ALLOWLIST = new Set([
|
* either @RequirePermission or @AnyAuthenticatedUser, or the guard denies it
|
||||||
'NotificationsController.findAll',
|
* at runtime regardless of what this test says. This test exists to catch
|
||||||
'NotificationsController.markRead',
|
* the mistake at build time - with a clear message pointing at the missing
|
||||||
'AuthController.me',
|
* decorator - rather than as a live production 403 discovered by a user, but
|
||||||
'ProfileController.updateProfile',
|
* a route that fails it would also fail closed live, not open.
|
||||||
'ProfileController.changePassword',
|
*/
|
||||||
'ProfileController.deactivateAccount',
|
|
||||||
'ProfileController.uploadAvatar',
|
|
||||||
'ProfileController.deleteAvatar',
|
|
||||||
'ProfileController.getAvatar',
|
|
||||||
'ProfileController.enrollMfa',
|
|
||||||
'ProfileController.confirmMfaEnrollment',
|
|
||||||
'ProfileController.disableMfa',
|
|
||||||
]);
|
|
||||||
|
|
||||||
describe('Authorization coverage (e2e)', () => {
|
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({
|
const moduleRef = await Test.createTestingModule({
|
||||||
imports: [AppModule, DiscoveryModule],
|
imports: [AppModule, DiscoveryModule],
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -68,7 +60,7 @@ describe('Authorization coverage (e2e)', () => {
|
|||||||
const routeKey = `${controllerName}.${methodName}`;
|
const routeKey = `${controllerName}.${methodName}`;
|
||||||
const isPublicMethod =
|
const isPublicMethod =
|
||||||
reflector.get<boolean>(IS_PUBLIC_KEY, handler) === true;
|
reflector.get<boolean>(IS_PUBLIC_KEY, handler) === true;
|
||||||
if (isPublicClass || isPublicMethod || ALLOWLIST.has(routeKey)) {
|
if (isPublicClass || isPublicMethod) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +68,15 @@ describe('Authorization coverage (e2e)', () => {
|
|||||||
REQUIRE_PERMISSION_KEY,
|
REQUIRE_PERMISSION_KEY,
|
||||||
[handler, constructor],
|
[handler, constructor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasPermissions =
|
const hasPermissions =
|
||||||
Array.isArray(permissions) && permissions.length > 0;
|
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);
|
uncoveredRoutes.push(routeKey);
|
||||||
}
|
}
|
||||||
if (hasPermissions) {
|
if (hasPermissions) {
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
import type { NextConfig } from "next";
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
async headers() {
|
async headers() {
|
||||||
@@ -13,6 +52,16 @@ const nextConfig: NextConfig = {
|
|||||||
source: "/sw.js",
|
source: "/sw.js",
|
||||||
headers: [{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }],
|
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";
|
import { ElementPropertiesPanel } from "./element-properties-panel";
|
||||||
|
|
||||||
const ELEMENT_TYPES = ["text", "qrCode", "image", "line"] as const;
|
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_STEPS: number[] = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||||
const ZOOM_MIN = ZOOM_STEPS[0];
|
const ZOOM_MIN = ZOOM_STEPS[0];
|
||||||
const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1];
|
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) -
|
// Loose runtime check for imported JSON (own export, or hand/AI-authored) -
|
||||||
// only verifies the shape is plausible enough to load into the canvas;
|
// 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.
|
// the backend's own DTO validation is still the real boundary check on save.
|
||||||
function isLabelElementArray(value: unknown): value is LabelElement[] {
|
// Returns a specific reason per bad element instead of a flat true/false, so
|
||||||
return (
|
// an AI-authored file that guessed the wrong schema tells the user exactly
|
||||||
Array.isArray(value) &&
|
// what to fix rather than a generic "invalid file".
|
||||||
value.every(
|
function validateImportedElements(value: unknown): { elements: LabelElement[] } | { errors: string[] } {
|
||||||
(item) =>
|
if (!Array.isArray(value)) {
|
||||||
typeof item === "object" &&
|
return { errors: ['Die Datei enthält kein "elements"-Array.'] };
|
||||||
item !== null &&
|
}
|
||||||
"id" in item &&
|
const errors: string[] = [];
|
||||||
"type" in item &&
|
value.forEach((item, index) => {
|
||||||
"x" in item &&
|
const label = typeof item === "object" && item && "id" in item ? String((item as { id: unknown }).id) : `#${index + 1}`;
|
||||||
"y" in item &&
|
if (typeof item !== "object" || item === null) {
|
||||||
ELEMENT_TYPES.includes((item as { type: unknown }).type as (typeof ELEMENT_TYPES)[number]),
|
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 {
|
function slugify(name: string): string {
|
||||||
return (
|
return (
|
||||||
@@ -137,6 +154,21 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
|||||||
|
|
||||||
const selectedElement = elements.find((element) => element.id === selectedId) ?? null;
|
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) {
|
function addElement(element: LabelElement) {
|
||||||
setElements((current) => [...current, element]);
|
setElements((current) => [...current, element]);
|
||||||
setSelectedId(element.id);
|
setSelectedId(element.id);
|
||||||
@@ -235,11 +267,15 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
|||||||
const importedElements = Array.isArray(parsed)
|
const importedElements = Array.isArray(parsed)
|
||||||
? parsed
|
? parsed
|
||||||
: (parsed as { elements?: unknown })?.elements;
|
: (parsed as { elements?: unknown })?.elements;
|
||||||
if (!isLabelElementArray(importedElements)) {
|
const result = validateImportedElements(importedElements);
|
||||||
toast.error("Datei enthält keine gültige Etikettenvorlage.");
|
if ("errors" in result) {
|
||||||
|
toast.error("Vorlage konnte nicht importiert werden", {
|
||||||
|
description: result.errors.slice(0, 5).join(" · "),
|
||||||
|
duration: 15000,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setElements(importedElements.map((element) => ({ ...element, id: newId() })));
|
setElements(result.elements.map((element) => ({ ...element, id: newId() })));
|
||||||
if (!Array.isArray(parsed)) {
|
if (!Array.isArray(parsed)) {
|
||||||
const value = parsed as Partial<LabelDesignerValue>;
|
const value = parsed as Partial<LabelDesignerValue>;
|
||||||
if (typeof value.name === "string" && value.name && !name) setName(value.name);
|
if (typeof value.name === "string" && value.name && !name) setName(value.name);
|
||||||
@@ -346,9 +382,11 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Spalten/Zeilen leer lassen, um automatisch so viele einzuplanen wie passen. Feste
|
Spalten/Zeilen leer lassen, um automatisch so viele einzuplanen wie passen (aktuell{" "}
|
||||||
Werte eintragen, um exakt zum vorgedruckten Raster eines Etikettenbogens (z.B.
|
{maxColumns} × {maxRows}). Feste Werte eintragen, um exakt zum vorgedruckten Raster
|
||||||
Avery-Vorlage) zu passen.
|
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>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<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))
|
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>
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
<Label htmlFor="designer-rows">Zeilen</Label>
|
<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))
|
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>
|
</div>
|
||||||
<div className="flex gap-3">
|
<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 () => {
|
it("completes SSO login and redirects to the dashboard when returning with ssoPending", async () => {
|
||||||
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
|
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
|
||||||
completeSsoMock.mockResolvedValueOnce(undefined);
|
completeSsoMock.mockResolvedValueOnce({ mfaRequired: false });
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
expect(await screen.findByText(/microsoft-anmeldung wird abgeschlossen/i)).toBeInTheDocument();
|
await vi.waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/dashboard"));
|
||||||
expect(completeSsoMock).toHaveBeenCalled();
|
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 [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [ssoEnabled, setSsoEnabled] = useState(false);
|
const [ssoEnabled, setSsoEnabled] = useState(false);
|
||||||
const [ssoLoginHref, setSsoLoginHref] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
ssoStatusRequest().then(setSsoEnabled);
|
ssoStatusRequest().then(setSsoEnabled);
|
||||||
@@ -66,8 +70,17 @@ function LoginPageContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ssoPending) {
|
if (ssoPending) {
|
||||||
completeSso()
|
completeSso()
|
||||||
.then(() => router.replace("/dashboard"))
|
.then((outcome) => {
|
||||||
|
setIsCompletingSso(false);
|
||||||
|
if (outcome.mfaRequired) {
|
||||||
|
setMfaToken(outcome.mfaToken);
|
||||||
|
router.replace("/login");
|
||||||
|
} else {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
setIsCompletingSso(false);
|
||||||
setError("SSO-Anmeldung fehlgeschlagen, bitte erneut versuchen.");
|
setError("SSO-Anmeldung fehlgeschlagen, bitte erneut versuchen.");
|
||||||
router.replace("/login");
|
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
|
// 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.
|
// 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 apiUrl = await getApiUrl();
|
||||||
const response = await fetch(`${apiUrl}/auth/sso/complete`, {
|
const response = await fetch(`${apiUrl}/auth/sso/complete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -102,7 +105,7 @@ export async function ssoCompleteRequest(): Promise<AccessTokenResponse> {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.');
|
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> {
|
export async function logoutRequest(): Promise<void> {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ interface AuthContextValue {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (email: string, password: string) => Promise<LoginOutcome>;
|
login: (email: string, password: string) => Promise<LoginOutcome>;
|
||||||
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
||||||
completeSso: () => Promise<void>;
|
completeSso: () => Promise<LoginOutcome>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
hasPermission: (permission: string) => boolean;
|
hasPermission: (permission: string) => boolean;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
@@ -78,9 +78,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(await applyAccessToken(result.accessToken));
|
setUser(await applyAccessToken(result.accessToken));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const completeSso = useCallback(async () => {
|
const completeSso = useCallback(async (): Promise<LoginOutcome> => {
|
||||||
const result = await ssoCompleteRequest();
|
const result = await ssoCompleteRequest();
|
||||||
|
if (isMfaRequired(result)) {
|
||||||
|
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||||
|
}
|
||||||
setUser(await applyAccessToken(result.accessToken));
|
setUser(await applyAccessToken(result.accessToken));
|
||||||
|
return { mfaRequired: false };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
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";
|
import { MfaEnrollment, ProfileSummary, UpdateProfileInput } from "./types";
|
||||||
|
|
||||||
export function updateProfile(input: UpdateProfileInput): Promise<ProfileSummary> {
|
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> {
|
// Sent as a base64 data: URL in a plain JSON body rather than
|
||||||
const formData = new FormData();
|
// multipart/form-data: this deployment's Cloudflare proxy has a known
|
||||||
formData.append("file", file);
|
// history of mishandling multipart uploads regardless of size (see the
|
||||||
return apiFetchFormData<void>("/profile/avatar", formData);
|
// 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> {
|
export function deleteAvatar(): Promise<void> {
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ stringData:
|
|||||||
# Anderer Namespace als das Backend? Dann FQDN nutzen:
|
# Anderer Namespace als das Backend? Dann FQDN nutzen:
|
||||||
# apicore-postgres.<namespace>.svc.cluster.local
|
# apicore-postgres.<namespace>.svc.cluster.local
|
||||||
DATABASE_URL: "postgresql://apicore:change-me-postgres-password@apicore-postgres:5432/apicore?schema=public"
|
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"
|
JWT_REFRESH_SECRET: "change-me-refresh-secret"
|
||||||
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
|
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
|
||||||
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI.
|
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI.
|
||||||
|
|||||||
Reference in new issue
Block a user