Per the updated plan, Gemeinde Teamer are no longer Authentik-backed; they
are local accounts a Gemeinde Verantwortliche/r provisions per KC.
Schema:
- User.authentikSub now nullable; add passwordHash + kcId (cascade from Kc)
so one User model covers Authentik members and local Teamer.
- new TeamerInvite model: shareable group link (email null, maxUses null)
or personal invite (email pinned, single use), with expiry + revoke.
- sync log now also replicates User / Membership / TeamerInvite.
Auth:
- TeamAuthService: bcrypt password login (POST /auth/team-login) and invite
redemption (POST /auth/teamer/register) issuing a JWT signed with
TEAM_JWT_SECRET, payload typ:"team".
- TeamJwtStrategy (AuthGuard('team')) resolves it to the same
AuthenticatedUser shape as AuthentikStrategy.
- TokenVerificationService.verifyEither() also accepts team tokens (WS).
- files + chat read endpoints accept 'team' tokens; Teamer see non-Konfi
files and can use chat / start DMs.
Teamer admin (teamer/ module, under /gemeinde/:gemeindeId):
- POST/GET teamer, DELETE teamer/:userId
- POST/GET teamer-invites, DELETE teamer-invites/:inviteId
- LT may manage any Gemeinde; a Verantwortliche/r only their own
(checked in TeamerService, since RolesGuard only scopes by kcId).
Tests: TeamAuthService + TeamerService specs added (Prisma/Sync mocked),
npm test green at 32. Docs (plan + backend README) updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
161 lines
5.1 KiB
TypeScript
161 lines
5.1 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';
|
|
|
|
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');
|
|
}
|
|
|
|
async login(email: string, password: string): Promise<{ accessToken: string }> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { email: email.toLowerCase() },
|
|
include: { memberships: true },
|
|
});
|
|
if (!user || !user.passwordHash) {
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
const ok = await bcrypt.compare(password, user.passwordHash);
|
|
if (!ok) {
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
return { accessToken: this.sign(user.id) };
|
|
}
|
|
|
|
/// 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: true },
|
|
});
|
|
if (!user) {
|
|
throw new UnauthorizedException('Team account no longer exists');
|
|
}
|
|
return {
|
|
userId: user.id,
|
|
authentikSub: user.authentikSub,
|
|
email: user.email,
|
|
memberships: user.memberships.map((m) => ({
|
|
kcId: m.kcId,
|
|
gemeindeId: m.gemeindeId,
|
|
role: m.role,
|
|
})),
|
|
};
|
|
}
|
|
}
|