From 0ba8996e3a1c824ef489c7e14516d2dfab54e300 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 02:47:09 +0200 Subject: [PATCH] feat(auth): 12-character minimum, 72-byte maximum and breach check for new passwords - New passwords (create user, admin reset, own change) need at least 12 characters (ASVS 2.1.1) and may not exceed 72 bytes, which bcrypt would silently cut off. - They are checked against Have I Been Pwned with k-anonymity (only the first 5 SHA-1 characters leave the server, padded responses); an unreachable service is logged and does not block. PASSWORD_BREACH_CHECK =false disables it for offline deployments. - Login accepts existing passwords as before, capped at 256 characters to bound anonymous bcrypt work. - Password fields in the UI say the rule up front. Co-Authored-By: Claude Opus 5.5 (1M context) --- ApiCore-Backend/.env.example | 4 + ApiCore-Backend/src/auth/dto/login.dto.ts | 5 +- .../request-logger.middleware.spec.ts | 7 +- .../common/password/password-policy.spec.ts | 76 +++++++++++++++ .../src/common/password/password-policy.ts | 95 +++++++++++++++++++ .../src/profile/dto/change-password.dto.ts | 7 +- .../src/profile/profile.service.ts | 2 + .../src/users/dto/create-user.dto.ts | 5 +- .../src/users/dto/reset-user-password.dto.ts | 7 +- ApiCore-Backend/src/users/users.service.ts | 3 + .../src/app/(app)/profile/page.tsx | 6 +- .../users-roles/create-user-dialog.tsx | 6 +- .../settings/users-roles/edit-user-dialog.tsx | 6 +- 13 files changed, 212 insertions(+), 17 deletions(-) create mode 100644 ApiCore-Backend/src/common/password/password-policy.spec.ts create mode 100644 ApiCore-Backend/src/common/password/password-policy.ts diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index 12cebe9..74c309b 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -11,6 +11,10 @@ JWT_ACCESS_EXPIRES_IN="15m" JWT_REFRESH_EXPIRES_IN="7d" # Hard limit from login, however often the session is refreshed. SESSION_MAX_AGE="7d" +# New passwords are checked against known breaches (Have I Been Pwned, +# k-anonymity: only 5 hash characters leave the server). "false" turns the +# check off for deployments without internet access. +# PASSWORD_BREACH_CHECK=true PORT=3001 FRONTEND_ORIGIN="http://localhost:3000" diff --git a/ApiCore-Backend/src/auth/dto/login.dto.ts b/ApiCore-Backend/src/auth/dto/login.dto.ts index 90af27a..0122930 100644 --- a/ApiCore-Backend/src/auth/dto/login.dto.ts +++ b/ApiCore-Backend/src/auth/dto/login.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsString, MinLength } from 'class-validator'; +import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator'; import { NormalizeEmail } from '../../common/validation/normalize-email'; export class LoginDto { @@ -11,5 +11,8 @@ export class LoginDto { @ApiProperty() @IsString() @MinLength(8) + // Long enough for any real password; bounds the bcrypt work an + // anonymous request can cause. + @MaxLength(256) password!: string; } diff --git a/ApiCore-Backend/src/common/middleware/request-logger.middleware.spec.ts b/ApiCore-Backend/src/common/middleware/request-logger.middleware.spec.ts index 10ea7ab..3b59766 100644 --- a/ApiCore-Backend/src/common/middleware/request-logger.middleware.spec.ts +++ b/ApiCore-Backend/src/common/middleware/request-logger.middleware.spec.ts @@ -8,7 +8,12 @@ describe('requestLoggerMiddleware', () => { }); function fakeRequest(): Request { - return { method: 'GET', originalUrl: '/api/v1/dashboard' } as Request; + return { + method: 'GET', + baseUrl: '', + path: '/api/v1/dashboard', + originalUrl: '/api/v1/dashboard?q=secret', + } as Request; } function fakeResponse(statusCode: number): { diff --git a/ApiCore-Backend/src/common/password/password-policy.spec.ts b/ApiCore-Backend/src/common/password/password-policy.spec.ts new file mode 100644 index 0000000..992f5be --- /dev/null +++ b/ApiCore-Backend/src/common/password/password-policy.spec.ts @@ -0,0 +1,76 @@ +import { BadRequestException } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import * as crypto from 'node:crypto'; +import { IsNewPassword, assertPasswordNotBreached } from './password-policy'; + +class Dto { + @IsNewPassword() + password!: string; +} + +function errorsFor(password: string): number { + return validateSync(plainToInstance(Dto, { password })).length; +} + +describe('password policy', () => { + it('requires 12 characters and at most 72 bytes', () => { + expect(errorsFor('kurz123!')).toBe(1); + expect(errorsFor('lang-genug-12')).toBe(0); + expect(errorsFor('a'.repeat(72))).toBe(0); + expect(errorsFor('a'.repeat(73))).toBe(1); + expect(errorsFor('ä'.repeat(37))).toBe(1); // 74 bytes + }); + + describe('assertPasswordNotBreached', () => { + const originalEnv = process.env.NODE_ENV; + let fetchMock: jest.SpiedFunction; + + beforeEach(() => { + process.env.NODE_ENV = 'production'; + fetchMock = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + process.env.NODE_ENV = originalEnv; + fetchMock.mockRestore(); + }); + + function rangeFor(password: string, count: number): string { + const sha1 = crypto + .createHash('sha1') + .update(password) + .digest('hex') + .toUpperCase(); + return `0000000000000000000000000000000000A:0\r\n${sha1.slice(5)}:${count}\r\n`; + } + + it('sends only the first five hash characters', async () => { + fetchMock.mockResolvedValue(new Response(rangeFor('other', 3))); + await assertPasswordNotBreached('Pferdebatterie-Heftklammer'); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toMatch(/\/range\/[0-9A-F]{5}$/); + }); + + it('refuses a breached password', async () => { + fetchMock.mockResolvedValue(new Response(rangeFor('Passwort1234', 4242))); + await expect( + assertPasswordNotBreached('Passwort1234'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('ignores padding entries with a count of 0', async () => { + fetchMock.mockResolvedValue(new Response(rangeFor('Padding-Only-1', 0))); + await expect( + assertPasswordNotBreached('Padding-Only-1'), + ).resolves.toBeUndefined(); + }); + + it('accepts the password when the service is unreachable', async () => { + fetchMock.mockRejectedValue(new Error('offline')); + await expect( + assertPasswordNotBreached('Irgendwas-123'), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/ApiCore-Backend/src/common/password/password-policy.ts b/ApiCore-Backend/src/common/password/password-policy.ts new file mode 100644 index 0000000..bc335cd --- /dev/null +++ b/ApiCore-Backend/src/common/password/password-policy.ts @@ -0,0 +1,95 @@ +import { BadRequestException, Logger } from '@nestjs/common'; +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength, ValidateBy, buildMessage } from 'class-validator'; +import * as crypto from 'node:crypto'; + +/** OWASP ASVS 2.1.1: at least 12 characters for a new password. */ +export const MIN_PASSWORD_LENGTH = 12; +/** bcrypt ignores everything after 72 bytes - refuse instead of truncating. */ +export const MAX_PASSWORD_BYTES = 72; + +const PWNED_RANGE_URL = 'https://api.pwnedpasswords.com/range/'; +const BREACH_CHECK_TIMEOUT_MS = 3_000; +const logger = new Logger('PasswordPolicy'); + +/** Validation for every NEW password (not for login, where old ones still count). */ +export function IsNewPassword(): PropertyDecorator { + return applyDecorators( + ApiProperty({ + minLength: MIN_PASSWORD_LENGTH, + description: `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen, höchstens ${MAX_PASSWORD_BYTES} Byte`, + }), + IsString(), + MinLength(MIN_PASSWORD_LENGTH, { + message: `Passwort muss mindestens ${MIN_PASSWORD_LENGTH} Zeichen lang sein`, + }), + ValidateBy({ + name: 'maxPasswordBytes', + validator: { + validate: (value: unknown) => + typeof value === 'string' && + Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES, + defaultMessage: buildMessage( + () => + `Passwort darf höchstens ${MAX_PASSWORD_BYTES} Byte lang sein (bei Umlauten weniger Zeichen)`, + ), + }, + }), + ); +} + +function breachCheckEnabled(): boolean { + return ( + process.env.PASSWORD_BREACH_CHECK !== 'false' && + process.env.NODE_ENV !== 'test' + ); +} + +/** + * Refuses a password that appears in known data breaches (Have I Been + * Pwned, k-anonymity: only the first 5 hex characters of its SHA-1 leave + * the server, padded responses). If the service cannot be reached the + * password is accepted and a warning logged - a third-party outage must not + * lock everyone out of changing passwords. PASSWORD_BREACH_CHECK=false turns + * it off for offline deployments. + */ +export async function assertPasswordNotBreached( + password: string, +): Promise { + if (!breachCheckEnabled()) { + return; + } + const sha1 = crypto + .createHash('sha1') + .update(password) + .digest('hex') + .toUpperCase(); + const prefix = sha1.slice(0, 5); + const suffix = sha1.slice(5); + let body: string; + try { + const response = await fetch(`${PWNED_RANGE_URL}${prefix}`, { + headers: { 'Add-Padding': 'true', 'User-Agent': 'ApiCore' }, + signal: AbortSignal.timeout(BREACH_CHECK_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + body = await response.text(); + } catch (error: unknown) { + logger.warn( + `Breach check unavailable, password accepted: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + const breached = body.split('\n').some((line) => { + const [hashSuffix, count] = line.trim().split(':'); + return hashSuffix === suffix && Number(count) > 0; + }); + if (breached) { + throw new BadRequestException( + 'Dieses Passwort ist aus Datenlecks bekannt und daher unsicher. Bitte ein anderes wählen.', + ); + } +} diff --git a/ApiCore-Backend/src/profile/dto/change-password.dto.ts b/ApiCore-Backend/src/profile/dto/change-password.dto.ts index a2921d1..473bf67 100644 --- a/ApiCore-Backend/src/profile/dto/change-password.dto.ts +++ b/ApiCore-Backend/src/profile/dto/change-password.dto.ts @@ -1,13 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, MinLength } from 'class-validator'; +import { IsNewPassword } from '../../common/password/password-policy'; +import { IsString } from 'class-validator'; export class ChangePasswordDto { @ApiProperty() @IsString() currentPassword!: string; - @ApiProperty() - @IsString() - @MinLength(8) + @IsNewPassword() newPassword!: string; } diff --git a/ApiCore-Backend/src/profile/profile.service.ts b/ApiCore-Backend/src/profile/profile.service.ts index b7837e1..03ab70e 100644 --- a/ApiCore-Backend/src/profile/profile.service.ts +++ b/ApiCore-Backend/src/profile/profile.service.ts @@ -14,6 +14,7 @@ import { AuditLogService } from '../audit/audit-log.service'; import { matchesFileSignature } from '../common/file-signature'; import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service'; import { ReauthService } from '../auth/reauth/reauth.service'; +import { assertPasswordNotBreached } from '../common/password/password-policy'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { UsersService } from '../users/users.service'; import { @@ -114,6 +115,7 @@ export class ProfileService { if (!currentMatches) { throw new UnauthorizedException('Aktuelles Passwort ist falsch'); } + await assertPasswordNotBreached(newPassword); const passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS); await this.prisma.user.update({ where: { id: userId }, diff --git a/ApiCore-Backend/src/users/dto/create-user.dto.ts b/ApiCore-Backend/src/users/dto/create-user.dto.ts index 5cf3259..4d1a2a6 100644 --- a/ApiCore-Backend/src/users/dto/create-user.dto.ts +++ b/ApiCore-Backend/src/users/dto/create-user.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsNewPassword } from '../../common/password/password-policy'; import { IsEmail, IsString, MinLength } from 'class-validator'; import { NormalizeEmail } from '../../common/validation/normalize-email'; @@ -8,9 +9,7 @@ export class CreateUserDto { @IsEmail() email!: string; - @ApiProperty() - @IsString() - @MinLength(8) + @IsNewPassword() password!: string; @ApiProperty() diff --git a/ApiCore-Backend/src/users/dto/reset-user-password.dto.ts b/ApiCore-Backend/src/users/dto/reset-user-password.dto.ts index aac8fab..2bdbb65 100644 --- a/ApiCore-Backend/src/users/dto/reset-user-password.dto.ts +++ b/ApiCore-Backend/src/users/dto/reset-user-password.dto.ts @@ -1,5 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, MinLength } from 'class-validator'; +import { IsNewPassword } from '../../common/password/password-policy'; /** * An administrator setting a new password for someone else. There is no @@ -8,8 +7,6 @@ import { IsString, MinLength } from 'class-validator'; * generic user update cannot make, since the password is never readable. */ export class ResetUserPasswordDto { - @ApiProperty({ minLength: 8 }) - @IsString() - @MinLength(8) + @IsNewPassword() password!: string; } diff --git a/ApiCore-Backend/src/users/users.service.ts b/ApiCore-Backend/src/users/users.service.ts index 636b493..1aa115b 100644 --- a/ApiCore-Backend/src/users/users.service.ts +++ b/ApiCore-Backend/src/users/users.service.ts @@ -7,6 +7,7 @@ import { import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { assertPasswordNotBreached } from '../common/password/password-policy'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { Prisma } from '../../generated/prisma'; import { CreateUserDto } from './dto/create-user.dto'; @@ -209,6 +210,7 @@ export class UsersService { await this.assertActiveRole(dto.roleId); await this.assertCanAssignRole(actingUserId); await this.assertWithinCallerPermissions(dto.roleId, actingUserId); + await assertPasswordNotBreached(dto.password); const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS); const user = await this.prisma.user.create({ data: { @@ -337,6 +339,7 @@ export class UsersService { } const target = await this.findById(id); await this.assertWithinCallerPermissions(target.roleId, actingUserId); + await assertPasswordNotBreached(dto.password); const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS); const user = await this.prisma.user.update({ where: { id }, diff --git a/ApiCore-Frontend/src/app/(app)/profile/page.tsx b/ApiCore-Frontend/src/app/(app)/profile/page.tsx index d33835d..2857496 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/page.tsx @@ -258,11 +258,15 @@ function PasswordSection() { id="new-password" type="password" autoComplete="new-password" - minLength={8} + minLength={12} + aria-describedby="new-password-rule" required value={newPassword} onChange={(event) => setNewPassword(event.target.value)} /> +

+ Mindestens 12 Zeichen. Passwörter aus bekannten Datenlecks werden abgelehnt. +

{error ? (

diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/create-user-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/create-user-dialog.tsx index a07ef0f..1d7ceff 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/create-user-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/create-user-dialog.tsx @@ -94,11 +94,15 @@ export function CreateUserDialog() { setPassword(event.target.value)} /> +

+ Mindestens 12 Zeichen. Passwörter aus bekannten Datenlecks werden abgelehnt. +

diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/edit-user-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/edit-user-dialog.tsx index cb4a81e..5ca52b9 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/edit-user-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/edit-user-dialog.tsx @@ -122,11 +122,15 @@ function EditUserForm({ user, onSaved }: { user: User; onSaved: () => void }) { id="edit-user-password" type="password" autoComplete="new-password" - minLength={8} + minLength={12} + aria-describedby="edit-user-password-rule" placeholder="Leer lassen, um es nicht zu ändern" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} /> +

+ Mindestens 12 Zeichen. Passwörter aus bekannten Datenlecks werden abgelehnt. +

Setzt das Passwort neu und meldet den Benutzer auf allen Geräten ab. Das eigene Passwort wird stattdessen im eigenen Profil geändert.