Weitere vom Sicherheitsaudit gemeldete Luecken behoben, die bisher als niedrige Prioritaet offen gelassen wurden: - SSO-Login (Entra ID) prüft jetzt lokale MFA genauso wie der Passwort-Login: ist mfaEnabled gesetzt, liefert /auth/sso/complete denselben mfaRequired-Codepfad wie /auth/login, bevor Tokens ausgestellt werden. Frontend (Login-Seite, AuthContext) zeigt in dem Fall den bestehenden Code-Eingabe-Schritt statt die Anmeldung stillschweigend abzuschliessen. - Avatar- und Dokument-Uploads pruefen jetzt die tatsaechlichen Datei-Bytes (Magic Numbers fuer PNG/JPEG/WEBP/PDF) statt nur dem client-angegebenen MIME-Typ/data:-URL-Praefix zu vertrauen. - Swagger-UI (/api/v1/docs) ist jetzt standardmaessig deaktiviert (ENABLE_SWAGGER=true erforderlich), da sie zuvor das komplette API-Schema ungeschuetzt oeffentlich preisgab. npm-audit-Warnungen (deepmerge-ts/mysql2 unter prisma) bewusst nicht gefixt: kein stabiles Prisma-Release loest sie bislang auf, nur eine Release-Candidate-Hauptversion - das Risiko eines Downgrades/RC-Upgrades der Datenbankschicht uebersteigt den Nutzen fuer eine Kette, die zur Laufzeit ohnehin nicht erreichbar ist (nur Prisma-CLI, nur MySQL-Treiber, dieses Projekt nutzt Postgres). Co-Authored-By: Claude Sonnet 5 <[email protected]>
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
import { UnauthorizedException } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import { AuthService } from './auth.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { UsersService } from '../users/users.service';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { MfaService } from './mfa/mfa.service';
|
|
|
|
describe('AuthService', () => {
|
|
let authService: AuthService;
|
|
let usersService: { findByEmail: jest.Mock; findById: jest.Mock };
|
|
let prisma: {
|
|
refreshToken: {
|
|
create: jest.Mock;
|
|
findUnique: jest.Mock;
|
|
update: jest.Mock;
|
|
updateMany: jest.Mock;
|
|
};
|
|
};
|
|
let jwtService: { sign: jest.Mock };
|
|
let auditLog: { record: jest.Mock };
|
|
let permissionResolver: {
|
|
getPermissions: jest.Mock;
|
|
invalidateUser: jest.Mock;
|
|
};
|
|
let mfaService: {
|
|
signChallengeToken: jest.Mock;
|
|
verifyChallengeToken: jest.Mock;
|
|
verifyCode: jest.Mock;
|
|
};
|
|
|
|
const activeUser = {
|
|
id: 'user-1',
|
|
email: '[email protected]',
|
|
isActive: true,
|
|
passwordHash: '',
|
|
mfaEnabled: false,
|
|
mfaSecret: null as string | null,
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
activeUser.passwordHash = await bcrypt.hash('correct-password', 4);
|
|
activeUser.mfaEnabled = false;
|
|
activeUser.mfaSecret = null;
|
|
usersService = { findByEmail: jest.fn(), findById: jest.fn() };
|
|
prisma = {
|
|
refreshToken: {
|
|
create: jest.fn(),
|
|
findUnique: jest.fn(),
|
|
update: jest.fn(),
|
|
updateMany: jest.fn(),
|
|
},
|
|
};
|
|
jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') };
|
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
|
permissionResolver = {
|
|
getPermissions: jest.fn().mockResolvedValue(new Set()),
|
|
invalidateUser: jest.fn(),
|
|
};
|
|
mfaService = {
|
|
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
|
verifyChallengeToken: jest.fn(),
|
|
verifyCode: jest.fn(),
|
|
};
|
|
|
|
authService = new AuthService(
|
|
usersService as unknown as UsersService,
|
|
prisma as unknown as PrismaService,
|
|
jwtService as never,
|
|
auditLog as unknown as AuditLogService,
|
|
permissionResolver as never,
|
|
mfaService as unknown as MfaService,
|
|
);
|
|
});
|
|
|
|
describe('login', () => {
|
|
it('throws when the user does not exist', async () => {
|
|
usersService.findByEmail.mockResolvedValue(null);
|
|
await expect(
|
|
authService.login('[email protected]', 'x'),
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
});
|
|
|
|
it('throws when the user is deactivated', async () => {
|
|
usersService.findByEmail.mockResolvedValue({
|
|
...activeUser,
|
|
isActive: false,
|
|
});
|
|
await expect(
|
|
authService.login(activeUser.email, 'correct-password'),
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
});
|
|
|
|
it('throws when the password does not match', async () => {
|
|
usersService.findByEmail.mockResolvedValue(activeUser);
|
|
await expect(
|
|
authService.login(activeUser.email, 'wrong-password'),
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
});
|
|
|
|
it('returns tokens and stores a hashed refresh token on success', async () => {
|
|
usersService.findByEmail.mockResolvedValue(activeUser);
|
|
prisma.refreshToken.create.mockResolvedValue({});
|
|
|
|
const result = await authService.login(
|
|
activeUser.email,
|
|
'correct-password',
|
|
);
|
|
|
|
if (result.mfaRequired) {
|
|
throw new Error('expected mfaRequired to be false');
|
|
}
|
|
expect(result.accessToken).toBe('signed.jwt.token');
|
|
expect(result.refreshToken).toEqual(expect.any(String));
|
|
const [[createArg]] = prisma.refreshToken.create.mock.calls as [
|
|
[{ data: { userId: string } }],
|
|
];
|
|
expect(createArg.data.userId).toBe(activeUser.id);
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }),
|
|
);
|
|
});
|
|
|
|
it('returns an MFA challenge instead of tokens when MFA is enabled', async () => {
|
|
usersService.findByEmail.mockResolvedValue({
|
|
...activeUser,
|
|
mfaEnabled: true,
|
|
mfaSecret: 'encrypted-secret',
|
|
});
|
|
|
|
const result = await authService.login(
|
|
activeUser.email,
|
|
'correct-password',
|
|
);
|
|
|
|
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('records a LOGIN_FAILED audit entry on wrong password', async () => {
|
|
usersService.findByEmail.mockResolvedValue(activeUser);
|
|
await expect(
|
|
authService.login(activeUser.email, 'wrong-password'),
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: 'LOGIN_FAILED',
|
|
userId: activeUser.id,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('records a LOGIN_FAILED audit entry keyed by email when the user is unknown', async () => {
|
|
usersService.findByEmail.mockResolvedValue(null);
|
|
await expect(
|
|
authService.login('[email protected]', 'x'),
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: 'LOGIN_FAILED',
|
|
entityId: '[email protected]',
|
|
}),
|
|
);
|
|
const [[recordArg]] = auditLog.record.mock.calls as [
|
|
[{ userId?: string }],
|
|
];
|
|
expect(recordArg).not.toHaveProperty('userId');
|
|
});
|
|
});
|
|
|
|
describe('verifyMfaAndLogin', () => {
|
|
const mfaUser = {
|
|
...activeUser,
|
|
mfaEnabled: true,
|
|
mfaSecret: 'encrypted-secret',
|
|
};
|
|
|
|
it('issues tokens and records LOGIN when the code is valid', async () => {
|
|
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
|
usersService.findById.mockResolvedValue(mfaUser);
|
|
mfaService.verifyCode.mockResolvedValue(true);
|
|
prisma.refreshToken.create.mockResolvedValue({});
|
|
|
|
const result = await authService.verifyMfaAndLogin(
|
|
'mfa-challenge-token',
|
|
'123456',
|
|
);
|
|
|
|
expect(result.accessToken).toBe('signed.jwt.token');
|
|
expect(mfaService.verifyCode).toHaveBeenCalledWith(
|
|
mfaUser.mfaSecret,
|
|
mfaUser.email,
|
|
'123456',
|
|
);
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }),
|
|
);
|
|
});
|
|
|
|
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
|
|
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
|
usersService.findById.mockResolvedValue(mfaUser);
|
|
mfaService.verifyCode.mockResolvedValue(false);
|
|
|
|
await expect(
|
|
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'),
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: 'LOGIN_FAILED',
|
|
userId: mfaUser.id,
|
|
}),
|
|
);
|
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('propagates an expired/invalid challenge token as UnauthorizedException', async () => {
|
|
mfaService.verifyChallengeToken.mockImplementation(() => {
|
|
throw new UnauthorizedException('expired');
|
|
});
|
|
|
|
await expect(
|
|
authService.verifyMfaAndLogin('bad-token', '123456'),
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
expect(usersService.findById).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('issueTokensForSsoUser', () => {
|
|
it('issues tokens and records LOGIN for an active user', async () => {
|
|
usersService.findById.mockResolvedValue(activeUser);
|
|
prisma.refreshToken.create.mockResolvedValue({});
|
|
|
|
const result = await authService.issueTokensForSsoUser(activeUser.id);
|
|
|
|
if (result.mfaRequired) {
|
|
throw new Error('expected mfaRequired to be false');
|
|
}
|
|
expect(result.accessToken).toBe('signed.jwt.token');
|
|
expect(prisma.refreshToken.create).toHaveBeenCalled();
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }),
|
|
);
|
|
});
|
|
|
|
it('returns an MFA challenge instead of tokens when the SSO user has local MFA enabled', async () => {
|
|
usersService.findById.mockResolvedValue({
|
|
...activeUser,
|
|
mfaEnabled: true,
|
|
mfaSecret: 'encrypted-secret',
|
|
});
|
|
|
|
const result = await authService.issueTokensForSsoUser(activeUser.id);
|
|
|
|
if (!result.mfaRequired) {
|
|
throw new Error('expected mfaRequired to be true');
|
|
}
|
|
expect(result.mfaToken).toBe('mfa-challenge-token');
|
|
expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id);
|
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
|
expect(auditLog.record).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: 'LOGIN' }),
|
|
);
|
|
});
|
|
|
|
it('throws when the user is deactivated', async () => {
|
|
usersService.findById.mockResolvedValue({
|
|
...activeUser,
|
|
isActive: false,
|
|
});
|
|
|
|
await expect(
|
|
authService.issueTokensForSsoUser(activeUser.id),
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('refresh', () => {
|
|
it('throws when the refresh token is unknown', async () => {
|
|
prisma.refreshToken.findUnique.mockResolvedValue(null);
|
|
await expect(authService.refresh('unknown-token')).rejects.toBeInstanceOf(
|
|
UnauthorizedException,
|
|
);
|
|
});
|
|
|
|
it('throws when the refresh token is revoked and revokes all other sessions for that user (reuse detection)', async () => {
|
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
|
id: 'rt-1',
|
|
userId: activeUser.id,
|
|
revokedAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 1000),
|
|
});
|
|
prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 });
|
|
|
|
await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf(
|
|
UnauthorizedException,
|
|
);
|
|
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { userId: activeUser.id, revokedAt: null },
|
|
}),
|
|
);
|
|
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith(
|
|
activeUser.id,
|
|
);
|
|
});
|
|
|
|
it('throws when the refresh token is expired', async () => {
|
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
|
id: 'rt-1',
|
|
userId: activeUser.id,
|
|
revokedAt: null,
|
|
expiresAt: new Date(Date.now() - 1000),
|
|
});
|
|
await expect(authService.refresh('expired-token')).rejects.toBeInstanceOf(
|
|
UnauthorizedException,
|
|
);
|
|
});
|
|
|
|
it('rotates the refresh token on success', async () => {
|
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
|
id: 'rt-1',
|
|
userId: activeUser.id,
|
|
revokedAt: null,
|
|
expiresAt: new Date(Date.now() + 1000),
|
|
});
|
|
usersService.findById.mockResolvedValue(activeUser);
|
|
prisma.refreshToken.update.mockResolvedValue({});
|
|
prisma.refreshToken.create.mockResolvedValue({});
|
|
|
|
const result = await authService.refresh('valid-token');
|
|
|
|
expect(result.accessToken).toBe('signed.jwt.token');
|
|
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { id: 'rt-1' } }),
|
|
);
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: 'TOKEN_REFRESHED',
|
|
userId: activeUser.id,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
it('revokes the matching refresh token and records a LOGOUT audit entry', async () => {
|
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
|
id: 'rt-1',
|
|
userId: activeUser.id,
|
|
revokedAt: null,
|
|
});
|
|
prisma.refreshToken.update.mockResolvedValue({});
|
|
|
|
await authService.logout('some-token');
|
|
|
|
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { id: 'rt-1' } }),
|
|
);
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: 'LOGOUT', userId: activeUser.id }),
|
|
);
|
|
});
|
|
|
|
it('does nothing when the token is unknown or already revoked', async () => {
|
|
prisma.refreshToken.findUnique.mockResolvedValue(null);
|
|
await authService.logout('unknown-token');
|
|
expect(prisma.refreshToken.update).not.toHaveBeenCalled();
|
|
expect(auditLog.record).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|