fix: Rechteausweitung ueber Rollenzuweisung und weitere Auth-Haertung

Ein Sicherheitsaudit deckte auf, dass jede Rolle mit users:update sich
selbst per PATCH /users/:id auf eine Admin-Rolle heraufstufen konnte,
da roleId ohne eigene Berechtigung und ohne Selbstbezug-Pruefung
akzeptiert wurde. Ausserdem lag ein fest im Code hinterlegtes
Standard-Admin-Passwort vor, das bei fehlender SEED_ADMIN_PASSWORD-Env
greift.

- Neue Berechtigung users:assign-role, getrennt von users:create/update,
  fuer jede Rollenzuweisung erforderlich
- Selbstaenderung der eigenen Rolle serverseitig blockiert (auch fuer
  Admins), ebenso Selbst-Reset der eigenen MFA ueber die Admin-Route
- Seed erzeugt bei fehlender Env ein zufaelliges statt eines fixen
  Passworts
- Wiederverwendung eines bereits rotierten Refresh-Tokens (Diebstahl-
  Indiz) widerruft nun alle Sessions des betroffenen Nutzers
- Rate-Limit auf MFA-Enrollment-Bestaetigung ergaenzt

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-02 08:57:42 +02:00
1 parent 7bd2a3dc3a
commit 1b16b49913
7 files changed
+146 -7

No files matched your search

+16 -2
View File
@@ -19,7 +19,10 @@ describe('AuthService', () => {
};
let jwtService: { sign: jest.Mock };
let auditLog: { record: jest.Mock };
let permissionResolver: { getPermissions: jest.Mock };
let permissionResolver: {
getPermissions: jest.Mock;
invalidateUser: jest.Mock;
};
let mfaService: {
signChallengeToken: jest.Mock;
verifyChallengeToken: jest.Mock;
@@ -52,6 +55,7 @@ describe('AuthService', () => {
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = {
getPermissions: jest.fn().mockResolvedValue(new Set()),
invalidateUser: jest.fn(),
};
mfaService = {
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
@@ -264,16 +268,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({
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 () => {
+19 -1
View File
@@ -207,7 +207,25 @@ export class AuthService {
const stored = await this.prisma.refreshToken.findUnique({
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');
}
const user = await this.usersService.findById(stored.userId);
@@ -255,6 +255,7 @@ export const PERMISSION_CATALOG = [
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
['assign-role', 'Rolle zuweisen'],
],
2300,
),