- 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]>
339 lines
12 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|