Files
ApiCore/ApiCore-Backend/src/auth/auth.service.ts
T
Nick SzaunigandClaude Opus 5.5 45efb08369 fix(auth): one-time SSO and MFA tokens, permission changes effective on every replica
- Every JWT carries a jti. The SSO completion token and the MFA challenge
  (once a code was accepted) are recorded as used in ConsumedToken; a
  replayed request or copied cookie cannot open a second session. Used
  ids are cleaned up after they expire.
- The token-validity check (active user, active role, password change)
  reads fresh on every request and refreshes the permission cache, so a
  deactivation or role change made on another replica applies to the very
  next request instead of up to 30 s later.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:24:28 +02:00

339 lines
12 KiB
TypeScript

import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from './permissions/permission-resolver.service';
import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
import { OneTimeTokenService } from './one-time-token.service';
import { parseDurationToMs } from './util/duration';
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export type LoginResult =
| ({ mfaRequired: false } & AuthTokens)
| { mfaRequired: true; mfaToken: string };
export interface CurrentUserProfile {
id: string;
email: string;
name: string;
roleKey: string;
roleName: string;
permissions: string[];
mfaEnabled: boolean;
hasAvatar: boolean;
}
const REFRESH_TOKEN_BYTES = 40;
// Hard cap from the moment of login, however often the session is refreshed.
const DEFAULT_SESSION_MAX_AGE = '7d';
// Two tabs refreshing with the same token at the same moment is normal;
// only a reuse after this window is treated as theft.
const ROTATION_GRACE_MS = 10_000;
// Same answer for unknown email, wrong password, deactivated or locked
// account - nothing in the response tells which one it was.
const INVALID_CREDENTIALS =
'E-Mail oder Passwort falsch. Nach mehreren Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.';
// Hash of a random value nobody knows, at the same cost as real password
// hashes; compared against when there is no real hash, so every login
// costs the same time.
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(
crypto.randomBytes(32).toString('hex'),
12,
);
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly usersService: UsersService,
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
private readonly mfaService: MfaService,
private readonly accountGuard: AccountGuardService,
private readonly oneTimeTokens: OneTimeTokenService,
) {}
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
include: { role: { select: { key: true, name: true } } },
});
const permissions = await this.permissionResolver.getPermissions(userId);
return {
id: user.id,
email: user.email,
name: user.name,
roleKey: user.role.key,
roleName: user.role.name,
permissions: Array.from(permissions),
mfaEnabled: user.mfaEnabled,
hasAvatar: user.avatarPath !== null,
};
}
private hashRefreshToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
private signAccessToken(user: { id: string; email: string }): string {
const expiresInSeconds = Math.floor(
parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000,
);
return this.jwtService.sign(
{ sub: user.id, email: user.email },
jwtSignOptions(TOKEN_AUDIENCE.access, expiresInSeconds),
);
}
private async issueRefreshToken(
userId: string,
sessionStartedAt: Date = new Date(),
): Promise<string> {
const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex');
const slidingExpiry =
Date.now() +
parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d');
const absoluteExpiry =
sessionStartedAt.getTime() +
parseDurationToMs(process.env.SESSION_MAX_AGE ?? DEFAULT_SESSION_MAX_AGE);
await this.prisma.refreshToken.create({
data: {
userId,
tokenHash: this.hashRefreshToken(token),
expiresAt: new Date(Math.min(slidingExpiry, absoluteExpiry)),
sessionStartedAt,
},
});
return token;
}
async login(email: string, password: string): Promise<LoginResult> {
const user = await this.usersService.findByEmail(email);
// bcrypt runs on every path - unknown, deactivated, locked or real - so
// the response time does not reveal which accounts exist.
const passwordMatches = await bcrypt.compare(
password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
);
if (!user) {
this.logger.warn('Login failed: unknown email');
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (!user.isActive || this.accountGuard.isLocked(user)) {
this.logger.warn(`Login refused: user ${user.id} inactive or locked`);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (!passwordMatches) {
this.logger.warn(`Login failed: wrong password for user ${user.id}`);
await this.accountGuard.recordFailure(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (user.mfaEnabled) {
return {
mfaRequired: true,
mfaToken: this.mfaService.signChallengeToken(user.id),
};
}
const authenticatedUser = {
id: user.id,
email: user.email,
};
const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id);
await this.accountGuard.recordSuccess(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN',
userId: user.id,
});
return { mfaRequired: false, accessToken, refreshToken };
}
/**
* Issues our own access/refresh token pair for a user already
* authenticated via SSO. Local MFA is still enforced here, same as
* password login: SSO establishes identity, not the second factor, so a
* user who has explicitly enabled TOTP must complete it here too rather
* than getting it silently skipped by signing in via Microsoft instead.
*/
async issueTokensForSsoUser(userId: string): Promise<LoginResult> {
const user = await this.usersService.findById(userId);
if (!user.isActive) {
throw new UnauthorizedException('Invalid credentials');
}
if (user.mfaEnabled) {
return {
mfaRequired: true,
mfaToken: this.mfaService.signChallengeToken(user.id),
};
}
const accessToken = this.signAccessToken({
id: user.id,
email: user.email,
});
const refreshToken = await this.issueRefreshToken(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN',
userId: user.id,
});
return { mfaRequired: false, accessToken, refreshToken };
}
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
const { userId, claims } = this.mfaService.verifyChallengeToken(mfaToken);
const user = await this.usersService.findById(userId);
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
throw new UnauthorizedException('MFA nicht verfügbar');
}
if (this.accountGuard.isLocked(user)) {
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
const isValid = await this.accountGuard.consumeTotp(user, code);
if (!isValid) {
this.logger.warn(
`MFA verification failed: wrong or reused code for user ${user.id}`,
);
await this.accountGuard.recordFailure(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException('Code ungültig');
}
// A challenge that already opened a session cannot open another one.
await this.oneTimeTokens.consume(claims);
await this.accountGuard.recordSuccess(user.id);
const authenticatedUser = {
id: user.id,
email: user.email,
};
const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN',
userId: user.id,
});
return { accessToken, refreshToken };
}
async refresh(refreshToken: string): Promise<AuthTokens> {
const tokenHash = this.hashRefreshToken(refreshToken);
const stored = await this.prisma.refreshToken.findUnique({
where: { tokenHash },
});
if (!stored) {
throw new UnauthorizedException('Invalid refresh token');
}
if (
stored.revokedAt &&
Date.now() - stored.revokedAt.getTime() < ROTATION_GRACE_MS
) {
// Lost a race with a parallel refresh (second tab): no new session,
// but no alarm either - that tab simply uses the other's cookie.
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);
if (!user.isActive) {
throw new UnauthorizedException('Invalid refresh token');
}
// Atomic: of two requests presenting the same token, exactly one wins
// and gets a successor - one stolen token can never fork into two live
// sessions.
const claimed = await this.prisma.refreshToken.updateMany({
where: { id: stored.id, revokedAt: null },
data: { revokedAt: new Date() },
});
if (claimed.count !== 1) {
throw new UnauthorizedException('Invalid refresh token');
}
const authenticatedUser = {
id: user.id,
email: user.email,
};
const accessToken = this.signAccessToken(authenticatedUser);
const newRefreshToken = await this.issueRefreshToken(
user.id,
stored.sessionStartedAt,
);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'TOKEN_REFRESHED',
userId: user.id,
});
return { accessToken, refreshToken: newRefreshToken };
}
async logout(refreshToken: string): Promise<void> {
const tokenHash = this.hashRefreshToken(refreshToken);
const stored = await this.prisma.refreshToken.findUnique({
where: { tokenHash },
});
if (!stored || stored.revokedAt) {
return;
}
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
await this.auditLog.record({
entityType: 'AuthSession',
entityId: stored.userId,
action: 'LOGOUT',
userId: stored.userId,
});
}
}