Files
KC-APP-Server/src/auth/team-auth.service.ts
T
linusandClaude Sonnet 5 2f76790135 feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes
- New VerantwortlicheInvite model: LT-issued invites so a person can
  register as Gemeinde Verantwortliche(r) for a specific Gemeinde,
  skipping the self-registration approval step.
- Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl,
  beschreibung), mirroring the WP plugin's multi-phase elections.
  Teilnehmer unique constraint now scoped per phase.
- Auth: team login + guest auth adjustments, spec coverage.
- sync.service.ts: register VerantwortlicheInvite as a synced model.
- wahl.service.ts: submitTeilnehmer updated for the new phase-scoped
  unique key.
- client: login/home screen rework, new theme.dart, FCM web tweaks.
- .gitignore: ignore .DS_Store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 18:00:18 +02:00

180 lines
6.2 KiB
TypeScript

import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Role, SyncOperation } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import * as jwt from 'jsonwebtoken';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
import { toAuthenticatedUser } from './provision-user';
export interface TeamJwtPayload {
sub: string;
typ: 'team';
}
const TOKEN_TTL = '12h';
const BCRYPT_ROUNDS = 10;
/// Local (non-Authentik) auth for Gemeinde Teamer: password login plus
/// redemption of a TeamerInvite issued by a Gemeinde Verantwortliche/r. Team
/// tokens are signed with TEAM_JWT_SECRET and carry `typ: 'team'` so they are
/// never mistaken for a guest token.
@Injectable()
export class TeamAuthService {
private readonly secret: string;
constructor(
private readonly prisma: PrismaClient,
private readonly config: ConfigService,
private readonly sync: SyncService,
) {
this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET');
}
/// Logs a Teamer in by email (legacy) OR by Gemeinde name — the normal
/// path, since a Teamer thinks of their login as "meine Gemeinde" rather
/// than an email address. A Gemeinde can have several Teamer accounts, so
/// a name lookup tries the password against every active GEMEINDE_TEAMER
/// membership for that Gemeinde (case-insensitive, trimmed name) until one
/// matches, rather than assuming a 1:1 Gemeinde-to-account mapping.
async login(
credentials: { email?: string; gemeindeName?: string },
password: string,
): Promise<{ accessToken: string }> {
if (credentials.email) {
const user = await this.prisma.user.findUnique({
where: { email: credentials.email.toLowerCase() },
});
if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Invalid credentials');
}
return { accessToken: this.sign(user.id) };
}
const gemeindeName = credentials.gemeindeName?.trim();
if (!gemeindeName) {
throw new UnauthorizedException('Invalid credentials');
}
const memberships = await this.prisma.membership.findMany({
where: {
role: Role.GEMEINDE_TEAMER,
status: 'ACTIVE',
gemeinde: { name: { equals: gemeindeName, mode: 'insensitive' } },
},
include: { user: true },
});
for (const m of memberships) {
if (m.user.passwordHash && (await bcrypt.compare(password, m.user.passwordHash))) {
return { accessToken: this.sign(m.user.id) };
}
}
throw new UnauthorizedException('Invalid credentials');
}
/// Redeems an invite token and creates the local Teamer account + its
/// GEMEINDE_TEAMER membership for the invite's Gemeinde.
async registerFromInvite(input: {
token: string;
firstName: string;
lastName: string;
password: string;
email?: string;
}): Promise<{ accessToken: string }> {
const invite = await this.prisma.teamerInvite.findUnique({
where: { token: input.token },
});
if (!invite || invite.revokedAt) {
throw new NotFoundException('Unknown or revoked invite');
}
if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) {
throw new ForbiddenException('Invite has expired');
}
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
throw new ForbiddenException('Invite has already been used up');
}
if (
invite.email &&
input.email &&
input.email.toLowerCase() !== invite.email.toLowerCase()
) {
throw new ForbiddenException('Email does not match this invite');
}
const email = (invite.email ?? input.email ?? '').toLowerCase();
if (!email) {
throw new ConflictException('This invite requires an email address');
}
if (await this.prisma.user.findUnique({ where: { email } })) {
throw new ConflictException('An account with this email already exists');
}
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS);
const user = await this.prisma.user.create({
data: {
email,
firstName: input.firstName,
lastName: input.lastName,
passwordHash,
kcId: invite.kcId,
},
});
const membership = await this.prisma.membership.create({
data: {
userId: user.id,
kcId: invite.kcId,
gemeindeId: invite.gemeindeId,
role: Role.GEMEINDE_TEAMER,
},
});
const updatedInvite = await this.prisma.teamerInvite.update({
where: { id: invite.id },
data: { usedCount: { increment: 1 } },
});
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updatedInvite.id, updatedInvite);
return { accessToken: this.sign(user.id) };
}
private sign(userId: string): string {
const payload: TeamJwtPayload = { sub: userId, typ: 'team' };
return jwt.sign(payload, this.secret, { expiresIn: TOKEN_TTL });
}
/// Verifies a raw team token (used by the WS handshake path, outside passport).
async verify(token: string): Promise<AuthenticatedUser> {
let payload: TeamJwtPayload;
try {
payload = jwt.verify(token, this.secret) as TeamJwtPayload;
} catch {
throw new UnauthorizedException('Invalid team token');
}
if (payload.typ !== 'team' || !payload.sub) {
throw new UnauthorizedException('Not a team token');
}
return this.resolve(payload.sub);
}
async resolve(userId: string): Promise<AuthenticatedUser> {
const user = await this.prisma.user.findFirst({
where: { id: userId, passwordHash: { not: null } },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
if (!user) {
throw new UnauthorizedException('Team account no longer exists');
}
// Same shape as the Authentik path, incl. the synthetic global
// LEITUNGSTEAM membership when `isLeitungsteam` is set on the row.
return toAuthenticatedUser(user);
}
}