feat(backend): local accounts + invites for Gemeinde Teamer
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>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user