272 lines
9.4 KiB
TypeScript
272 lines
9.4 KiB
TypeScript
import { UnauthorizedException } from '@nestjs/common';
|
|
import type { AppConfig } from '../config/config.tokens';
|
|
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
|
import { PasswordHasher } from '../users/password-hasher';
|
|
import { UserRepository } from '../users/user.repository';
|
|
import type { AuthUser, UserRecord } from '../users/user.types';
|
|
import { AuthService } from './auth.service';
|
|
import { RateLimiterService } from './rate-limiter.service';
|
|
import { SessionService, type SessionData } from './session.service';
|
|
|
|
/** Erzeugt eine Test-Konfiguration mit überschreibbaren Werten. */
|
|
function createConfig(overrides: Partial<AppConfig['security']> = {}): AppConfig {
|
|
return {
|
|
nodeEnv: 'test',
|
|
port: 3000,
|
|
database: { url: 'postgresql://test' },
|
|
security: {
|
|
sessionTtlMinutes: 120,
|
|
cookieSecure: false,
|
|
behindProxy: false,
|
|
loginMaxAttempts: 3,
|
|
loginLockoutMinutes: 15,
|
|
loginRateLimitAttempts: 10,
|
|
loginRateLimitWindowMinutes: 5,
|
|
...overrides,
|
|
},
|
|
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
|
|
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' },
|
|
marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} },
|
|
};
|
|
}
|
|
|
|
/** Erzeugt einen Benutzer-Datensatz für Tests. */
|
|
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
|
|
return {
|
|
id: 'user-1',
|
|
username: 'max',
|
|
email: 'max@example.com',
|
|
passwordHash: 'not-a-real-hash',
|
|
displayName: 'Max Mustermann',
|
|
role: 'USER',
|
|
isActive: true,
|
|
failedLoginAttempts: 0,
|
|
lockedUntil: null,
|
|
lastLoginAt: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Mock des UserRepository. */
|
|
class MockUserRepository {
|
|
public findByUsernameResult: UserRecord | null = null;
|
|
public updateLoginSuccessCalls: string[] = [];
|
|
public updateLoginFailureCalls: string[] = [];
|
|
|
|
async findByUsername(): Promise<UserRecord | null> {
|
|
return this.findByUsernameResult;
|
|
}
|
|
|
|
async updateLoginSuccess(userId: string): Promise<void> {
|
|
this.updateLoginSuccessCalls.push(userId);
|
|
}
|
|
|
|
async updateLoginFailure(userId: string): Promise<void> {
|
|
this.updateLoginFailureCalls.push(userId);
|
|
}
|
|
}
|
|
|
|
/** Mock des SessionService. */
|
|
class MockSessionService {
|
|
public createResult: { token: string; data: SessionData } = {
|
|
token: 'session-token',
|
|
data: {
|
|
id: 'session-1',
|
|
userId: 'user-1',
|
|
csrfToken: 'csrf-token',
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
};
|
|
|
|
async create(): Promise<{ token: string; data: SessionData }> {
|
|
return this.createResult;
|
|
}
|
|
|
|
async delete(): Promise<void> {}
|
|
}
|
|
|
|
/** Mock des AuditService. */
|
|
class MockAuditService {
|
|
public records: Array<{ userId: string | null; username: string; action: string; details?: Record<string, unknown>; ipAddress?: string | null }> = [];
|
|
|
|
async record(entry: { userId: string | null; username: string; action: string; details?: Record<string, unknown>; ipAddress?: string | null }): Promise<void> {
|
|
this.records.push(entry);
|
|
}
|
|
}
|
|
|
|
describe('AuthService', () => {
|
|
let userRepository: MockUserRepository;
|
|
let passwordHasher: PasswordHasher;
|
|
let sessionService: MockSessionService;
|
|
let auditService: MockAuditService;
|
|
let rateLimiter: RateLimiterService;
|
|
let authService: AuthService;
|
|
let config: AppConfig;
|
|
|
|
beforeEach(() => {
|
|
userRepository = new MockUserRepository();
|
|
passwordHasher = new PasswordHasher();
|
|
sessionService = new MockSessionService();
|
|
auditService = new MockAuditService();
|
|
rateLimiter = new RateLimiterService();
|
|
config = createConfig();
|
|
authService = new AuthService(
|
|
userRepository as unknown as UserRepository,
|
|
passwordHasher,
|
|
sessionService as unknown as SessionService,
|
|
rateLimiter,
|
|
auditService as unknown as AuditService,
|
|
config,
|
|
);
|
|
});
|
|
|
|
describe('login', () => {
|
|
it('meldet einen Benutzer mit korrekten Zugangsdaten an', async () => {
|
|
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
|
userRepository.findByUsernameResult = createUserRecord({ passwordHash });
|
|
|
|
const result = await authService.login({
|
|
username: 'max',
|
|
password: 'Sicheres-Passwort-1',
|
|
ipAddress: '127.0.0.1',
|
|
});
|
|
|
|
expect(result.user.username).toBe('max');
|
|
expect(result.sessionToken).toBe('session-token');
|
|
expect(userRepository.updateLoginSuccessCalls).toEqual(['user-1']);
|
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_SUCCESS);
|
|
});
|
|
|
|
it('lehnt unbekannte Benutzer mit generischer Meldung ab', async () => {
|
|
userRepository.findByUsernameResult = null;
|
|
|
|
await expect(
|
|
authService.login({ username: 'ghost', password: 'wrong', ipAddress: '127.0.0.1' }),
|
|
).rejects.toThrow(UnauthorizedException);
|
|
|
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED);
|
|
expect(auditService.records.at(-1)?.details).toEqual({ reason: 'UNKNOWN_USER' });
|
|
});
|
|
|
|
it('lehnt falsche Passwörter ab und zählt Fehlversuche', async () => {
|
|
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
|
userRepository.findByUsernameResult = createUserRecord({ passwordHash });
|
|
|
|
await expect(
|
|
authService.login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' }),
|
|
).rejects.toThrow(UnauthorizedException);
|
|
|
|
expect(userRepository.updateLoginFailureCalls).toEqual([
|
|
'user-1',
|
|
]);
|
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED);
|
|
});
|
|
|
|
it('verhindert Loginversuche nicht durch Kontosperren', async () => {
|
|
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
|
userRepository.findByUsernameResult = createUserRecord({
|
|
passwordHash,
|
|
failedLoginAttempts: 2,
|
|
});
|
|
|
|
await expect(
|
|
authService.login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' }),
|
|
).rejects.toThrow(UnauthorizedException);
|
|
|
|
expect(userRepository.updateLoginFailureCalls).toEqual([
|
|
'user-1',
|
|
]);
|
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED);
|
|
});
|
|
|
|
it('lehnt gesperrte Benutzer ab', async () => {
|
|
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
|
userRepository.findByUsernameResult = createUserRecord({
|
|
passwordHash,
|
|
lockedUntil: new Date(Date.now() + 60_000),
|
|
});
|
|
|
|
const result = await authService.login({
|
|
username: 'max',
|
|
password: 'Sicheres-Passwort-1',
|
|
ipAddress: '127.0.0.1',
|
|
});
|
|
expect(result.user.username).toBe('max');
|
|
});
|
|
|
|
it('lehnt deaktivierte Benutzer ab', async () => {
|
|
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
|
userRepository.findByUsernameResult = createUserRecord({
|
|
passwordHash,
|
|
isActive: false,
|
|
});
|
|
|
|
await expect(
|
|
authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1' }),
|
|
).rejects.toThrow(UnauthorizedException);
|
|
|
|
expect(auditService.records.at(-1)?.details).toEqual({ reason: 'ACCOUNT_INACTIVE' });
|
|
});
|
|
|
|
it('blockiert Requests nach Überschreitung des Rate Limits', async () => {
|
|
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
|
userRepository.findByUsernameResult = createUserRecord({ passwordHash });
|
|
|
|
// Limit: 10 Versuche / 5 Minuten (Default-Konfiguration)
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
await authService
|
|
.login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' })
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
await expect(
|
|
authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1' }),
|
|
).rejects.toThrow('Zu viele Anmeldeversuche. Bitte später erneut versuchen.');
|
|
|
|
expect(auditService.records.at(-1)?.details).toEqual({ reason: 'RATE_LIMITED' });
|
|
});
|
|
|
|
it('setzt das Rate-Limit-Fenster nach erfolgreichem Login nicht zurück', async () => {
|
|
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
|
userRepository.findByUsernameResult = createUserRecord({ passwordHash });
|
|
|
|
for (let attempt = 0; attempt < 9; attempt += 1) {
|
|
await authService
|
|
.login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' })
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
const result = await authService.login({
|
|
username: 'max',
|
|
password: 'Sicheres-Passwort-1',
|
|
ipAddress: '127.0.0.1',
|
|
});
|
|
expect(result.user.username).toBe('max');
|
|
|
|
await expect(authService.login({
|
|
username: 'max',
|
|
password: 'Sicheres-Passwort-1',
|
|
ipAddress: '127.0.0.1',
|
|
})).rejects.toThrow('Zu viele Anmeldeversuche. Bitte später erneut versuchen.');
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
it('löscht die Session und schreibt ein Audit-Log', async () => {
|
|
const user: AuthUser = {
|
|
id: 'user-1',
|
|
username: 'max',
|
|
email: 'max@example.com',
|
|
displayName: 'Max Mustermann',
|
|
role: 'USER',
|
|
};
|
|
|
|
await authService.logout('session-1', user, '127.0.0.1');
|
|
|
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGOUT);
|
|
});
|
|
});
|
|
});
|