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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:47:09 +02:00
1 parent 61e32474e4
commit 0ba8996e3a
13 files changed
+212 -17

No files matched your search

+4
View File
@@ -11,6 +11,10 @@ JWT_ACCESS_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d" JWT_REFRESH_EXPIRES_IN="7d"
# Hard limit from login, however often the session is refreshed. # Hard limit from login, however often the session is refreshed.
SESSION_MAX_AGE="7d" 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 PORT=3001
FRONTEND_ORIGIN="http://localhost:3000" FRONTEND_ORIGIN="http://localhost:3000"
+4 -1
View File
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; 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'; import { NormalizeEmail } from '../../common/validation/normalize-email';
export class LoginDto { export class LoginDto {
@@ -11,5 +11,8 @@ export class LoginDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@MinLength(8) @MinLength(8)
// Long enough for any real password; bounds the bcrypt work an
// anonymous request can cause.
@MaxLength(256)
password!: string; password!: string;
} }
@@ -8,7 +8,12 @@ describe('requestLoggerMiddleware', () => {
}); });
function fakeRequest(): Request { 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): { function fakeResponse(statusCode: number): {
@@ -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<typeof fetch>;
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();
});
});
});
@@ -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<void> {
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.',
);
}
}
@@ -1,13 +1,12 @@
import { ApiProperty } from '@nestjs/swagger'; 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 { export class ChangePasswordDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
currentPassword!: string; currentPassword!: string;
@ApiProperty() @IsNewPassword()
@IsString()
@MinLength(8)
newPassword!: string; newPassword!: string;
} }
@@ -14,6 +14,7 @@ import { AuditLogService } from '../audit/audit-log.service';
import { matchesFileSignature } from '../common/file-signature'; import { matchesFileSignature } from '../common/file-signature';
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service'; import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
import { ReauthService } from '../auth/reauth/reauth.service'; import { ReauthService } from '../auth/reauth/reauth.service';
import { assertPasswordNotBreached } from '../common/password/password-policy';
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 {
@@ -114,6 +115,7 @@ export class ProfileService {
if (!currentMatches) { if (!currentMatches) {
throw new UnauthorizedException('Aktuelles Passwort ist falsch'); throw new UnauthorizedException('Aktuelles Passwort ist falsch');
} }
await assertPasswordNotBreached(newPassword);
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS); const passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: userId }, where: { id: userId },
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNewPassword } from '../../common/password/password-policy';
import { IsEmail, IsString, MinLength } from 'class-validator'; import { IsEmail, IsString, MinLength } from 'class-validator';
import { NormalizeEmail } from '../../common/validation/normalize-email'; import { NormalizeEmail } from '../../common/validation/normalize-email';
@@ -8,9 +9,7 @@ export class CreateUserDto {
@IsEmail() @IsEmail()
email!: string; email!: string;
@ApiProperty() @IsNewPassword()
@IsString()
@MinLength(8)
password!: string; password!: string;
@ApiProperty() @ApiProperty()
@@ -1,5 +1,4 @@
import { ApiProperty } from '@nestjs/swagger'; import { IsNewPassword } from '../../common/password/password-policy';
import { IsString, MinLength } from 'class-validator';
/** /**
* An administrator setting a new password for someone else. There is no * 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. * generic user update cannot make, since the password is never readable.
*/ */
export class ResetUserPasswordDto { export class ResetUserPasswordDto {
@ApiProperty({ minLength: 8 }) @IsNewPassword()
@IsString()
@MinLength(8)
password!: string; password!: string;
} }
@@ -7,6 +7,7 @@ import {
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
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 { assertPasswordNotBreached } from '../common/password/password-policy';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { Prisma } from '../../generated/prisma'; import { Prisma } from '../../generated/prisma';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
@@ -209,6 +210,7 @@ export class UsersService {
await this.assertActiveRole(dto.roleId); await this.assertActiveRole(dto.roleId);
await this.assertCanAssignRole(actingUserId); await this.assertCanAssignRole(actingUserId);
await this.assertWithinCallerPermissions(dto.roleId, actingUserId); await this.assertWithinCallerPermissions(dto.roleId, actingUserId);
await assertPasswordNotBreached(dto.password);
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: {
@@ -337,6 +339,7 @@ export class UsersService {
} }
const target = await this.findById(id); const target = await this.findById(id);
await this.assertWithinCallerPermissions(target.roleId, actingUserId); await this.assertWithinCallerPermissions(target.roleId, actingUserId);
await assertPasswordNotBreached(dto.password);
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.update({ const user = await this.prisma.user.update({
where: { id }, where: { id },
@@ -258,11 +258,15 @@ function PasswordSection() {
id="new-password" id="new-password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
minLength={8} minLength={12}
aria-describedby="new-password-rule"
required required
value={newPassword} value={newPassword}
onChange={(event) => setNewPassword(event.target.value)} onChange={(event) => setNewPassword(event.target.value)}
/> />
<p id="new-password-rule" className="text-xs text-muted-foreground">
Mindestens 12 Zeichen. Passwörter aus bekannten Datenlecks werden abgelehnt.
</p>
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-destructive"> <p role="alert" className="text-sm text-destructive">
@@ -94,11 +94,15 @@ export function CreateUserDialog() {
<Input <Input
id="new-user-password" id="new-user-password"
type="password" type="password"
minLength={8} minLength={12}
aria-describedby="new-user-password-rule"
required required
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
/> />
<p id="new-user-password-rule" className="text-xs text-muted-foreground">
Mindestens 12 Zeichen. Passwörter aus bekannten Datenlecks werden abgelehnt.
</p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="new-user-role">Rolle</Label> <Label htmlFor="new-user-role">Rolle</Label>
@@ -122,11 +122,15 @@ function EditUserForm({ user, onSaved }: { user: User; onSaved: () => void }) {
id="edit-user-password" id="edit-user-password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
minLength={8} minLength={12}
aria-describedby="edit-user-password-rule"
placeholder="Leer lassen, um es nicht zu ändern" placeholder="Leer lassen, um es nicht zu ändern"
value={newPassword} value={newPassword}
onChange={(event) => setNewPassword(event.target.value)} onChange={(event) => setNewPassword(event.target.value)}
/> />
<p id="edit-user-password-rule" className="text-xs text-muted-foreground">
Mindestens 12 Zeichen. Passwörter aus bekannten Datenlecks werden abgelehnt.
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Setzt das Passwort neu und meldet den Benutzer auf allen Geräten ab. Setzt das Passwort neu und meldet den Benutzer auf allen Geräten ab.
Das eigene Passwort wird stattdessen im eigenen Profil geändert. Das eigene Passwort wird stattdessen im eigenen Profil geändert.