dorfteich/apps/api/src/auth/auth.service.ts
Claude Fable 5 c8aac13dfb
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m14s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m45s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s
Add Site-Admin system panel with persistent audit trail (#86)
New /admin/system panel (operations.md §Maintenance jobs): the maintenance
job list shows every registered job with truthful last-run data (new
Job.lastDurationMs recorded by the scheduler) and a manual trigger that
respects the run-mutex and is itself audit-logged; a backup card mirrors
the sidecar's status.json including the freshness verdict; an audit-log
viewer filters by actor, action, and time range with pagination; and a
storage overview lists the largest ponds. Auth events and admin actions
(grants, members, user/quota admin, plugins, settings, setup) now land in
a new audit_log table through a central AuditService — which keeps
emitting the established stdout log line — while content activity stays
log-only by design. All endpoints are Site-Admin-only; covered by API DB
tests and a Playwright pack in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 20:03:05 +02:00

159 lines
6.1 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { SignupInput } from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { MailService } from '../mail/mail.service';
import { PondsService } from '../ponds/ponds.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { RateLimitService } from '../rate-limit/rate-limit.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { UsersService } from '../users/users.service';
import { AuthTokensService } from './auth-tokens.service';
import { SessionsService } from './sessions.service';
const VERIFY_TTL_SECONDS = 24 * 60 * 60;
const RESET_TTL_SECONDS = 60 * 60;
// Account-scoped login backoff: 5 failures per 15 minutes, reset on success.
const LOGIN_BACKOFF = { limit: 5, windowSeconds: 15 * 60 };
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly users: UsersService,
private readonly tokens: AuthTokensService,
private readonly sessions: SessionsService,
private readonly mail: MailService,
private readonly ponds: PondsService,
private readonly rateLimits: RateLimitService,
private readonly audit: AuditService,
private readonly config: AppConfig,
private readonly settings: InstanceSettingsService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(AuthService.name);
}
async signup(input: SignupInput): Promise<void> {
if ((await this.settings.get('auth.registrationMode')) === 'closed') {
throw new ForbiddenException({ code: 'registration_closed' });
}
const user = await this.users.createUser(input);
await this.sendVerificationMail(user);
await this.audit.record({ action: 'auth.signup', actorId: user.id });
}
async verifyEmail(token: string): Promise<void> {
const userId = await this.tokens.consume(token, 'EMAIL_VERIFICATION');
if (!userId) throw new BadRequestException({ code: 'token_invalid' });
const user = await this.users.findById(userId);
if (!user) throw new BadRequestException({ code: 'token_invalid' });
if (user.status === 'PENDING_VERIFICATION') {
await this.users.markEmailVerified(userId);
await this.audit.record({ action: 'auth.email_verified', actorId: userId });
}
// Every verified account owns a personal pond (issue #21). Idempotent,
// so re-verification attempts and races cannot create duplicates.
await this.ponds.ensurePersonalPond(user);
}
/** Always succeeds outwardly — never reveals whether the address exists. */
async resendVerification(email: string): Promise<void> {
const user = await this.users.findByEmail(email);
if (user?.status === 'PENDING_VERIFICATION') {
await this.sendVerificationMail(user);
}
}
async login(
usernameOrEmail: string,
password: string,
userAgent: string | undefined,
): Promise<{ sessionToken: string; user: User }> {
const user = await this.users.findByUsernameOrEmail(usernameOrEmail);
// Backoff before the (expensive) hash check; keyed by account so a
// distributed guesser cannot sidestep it by rotating IPs.
if (user) {
const backoff = await this.rateLimits.hit(
'login-account',
user.id,
LOGIN_BACKOFF.limit,
LOGIN_BACKOFF.windowSeconds,
);
if (!backoff.allowed) {
throw new UnauthorizedException({ code: 'login_backoff' });
}
}
const passwordOk = user ? await this.users.checkPassword(user.id, password) : false;
if (!user || !passwordOk) {
// Same generic error for unknown user and wrong password.
await this.audit.record({ action: 'auth.login_failed', actorId: user?.id ?? null });
throw new UnauthorizedException({ code: 'login_failed' });
}
if (user.status === 'DISABLED') {
throw new ForbiddenException({ code: 'account_disabled' });
}
if (user.status === 'PENDING_VERIFICATION') {
throw new ForbiddenException({ code: 'email_unverified' });
}
await this.rateLimits.reset('login-account', user.id);
const sessionToken = await this.sessions.create(user.id, userAgent);
await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
await this.audit.record({ action: 'auth.login_succeeded', actorId: user.id });
return { sessionToken, user };
}
/** Always succeeds outwardly — never reveals whether the address exists. */
async forgotPassword(email: string): Promise<void> {
const user = await this.users.findByEmail(email);
if (!user || user.status === 'DISABLED') return;
const token = await this.tokens.issue(user.id, 'PASSWORD_RESET', RESET_TTL_SECONDS);
await this.mail.enqueue(
user.email,
'resetPassword',
{
displayName: user.displayName,
link: `${this.config.env.APP_BASE_URL}/reset-password?token=${token}`,
},
asLocale(user.locale),
);
}
async resetPassword(token: string, password: string): Promise<void> {
const userId = await this.tokens.consume(token, 'PASSWORD_RESET');
if (!userId) throw new BadRequestException({ code: 'token_invalid' });
await this.users.setPassword(userId, password);
// Whoever held old sessions (possibly an attacker) is logged out.
await this.sessions.destroyAllForUser(userId);
await this.audit.record({ action: 'auth.password_reset', actorId: userId });
}
private async sendVerificationMail(user: User): Promise<void> {
const token = await this.tokens.issue(user.id, 'EMAIL_VERIFICATION', VERIFY_TTL_SECONDS);
await this.mail.enqueue(
user.email,
'verifyEmail',
{
displayName: user.displayName,
link: `${this.config.env.APP_BASE_URL}/verify-email?token=${token}`,
},
asLocale(user.locale),
);
}
}
function asLocale(locale: string): 'de' | 'en' {
return locale === 'de' ? 'de' : 'en';
}