Any authenticated user can invite an e-mail address; the mailed single-use token lets exactly one signup through even while registration is closed. Open (pending, unexpired) invitations count against the new instance setting invitations.maxOpenPerUser (default 5, 0 disables inviting) — plus a 20/day per-user rate limit so a revoke-and-recreate loop cannot become a mail cannon. Only the SHA-256 token hash is stored (auth-tokens pattern); a failed signup (taken username) un-redeems the token so the invitee can retry. Surfaces: invitations section in the user settings (list, invite, revoke, quota line; wide table in a focusable .table-scroll region), signup page reads ?invitation=<token> (preview banner, e-mail prefill, closed-mode gate opens only for a previewed-valid token), admin general card gets the quota field (flat RHF name per #322; VS-NfD marked and hideable). Governance: audit actions invitation.created/revoked/accepted (catalogue 1.10), VS-NfD profile entry (compliant: 0) + hardening-guide row, i18n de+en including the invitation mail template. Tests: api e2e-db (mail link, closed-mode single-use signup with un-redeem on failure, quota + revoke frees slot, quota 0 = 403, auth matrix), new web e2e pack invitations.spec.ts (full UI loop through Mailpit, wired into ci.yml with its own rate-limit reset), a11y scan waits for the new section. Full api suite (107 files / 607 tests), auth/admin-settings/a11y packs green against a fresh local stack. Closes #332
188 lines
7.2 KiB
TypeScript
188 lines
7.2 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 { InvitationsService } from '../invitations/invitations.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 invitations: InvitationsService,
|
|
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> {
|
|
// An invitation token (issue #332) lets exactly one signup through a
|
|
// closed registration. Claimed atomically BEFORE the account exists;
|
|
// rolled back if the signup fails (duplicate username), so the invitee
|
|
// can retry with the same link.
|
|
const invitation = input.invitationToken
|
|
? await this.invitations.redeem(input.invitationToken)
|
|
: null;
|
|
if (input.invitationToken && !invitation) {
|
|
throw new BadRequestException({ code: 'token_invalid' });
|
|
}
|
|
if (!invitation && (await this.settings.get('auth.registrationMode')) === 'closed') {
|
|
throw new ForbiddenException({ code: 'registration_closed' });
|
|
}
|
|
let user: User;
|
|
try {
|
|
user = await this.users.createUser(input);
|
|
} catch (error) {
|
|
if (invitation) await this.invitations.unredeem(invitation.id);
|
|
throw error;
|
|
}
|
|
if (invitation) {
|
|
await this.invitations.markAccepted(invitation.id, user.id);
|
|
await this.audit.record({
|
|
action: 'invitation.accepted',
|
|
actorId: user.id,
|
|
targetType: 'invitation',
|
|
targetId: invitation.id,
|
|
});
|
|
}
|
|
// The invite link proves nothing about the mailbox (it can be
|
|
// forwarded), so the usual verification mail still applies.
|
|
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';
|
|
}
|