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('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 { 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 { 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); } }