feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
# Postgres connection used by Prisma
|
# Postgres connection used by Prisma
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
|
||||||
|
|
||||||
# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/
|
# Authentik OIDC issuer (trailing slash optional — both forms are accepted).
|
||||||
AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app"
|
AUTHENTIK_ISSUER_URL="https://sso.konfi-castle.com/application/o/konfi-castle-app"
|
||||||
|
|
||||||
# Name of the Authentik group whose members are Leitungsteam. Mirrored to
|
# Name of the Authentik group whose members are Leitungsteam. Mirrored to
|
||||||
# User.isLeitungsteam on every login (the access token must carry a `groups`
|
# User.isLeitungsteam on every login (the access token must carry a `groups`
|
||||||
|
|||||||
+13
-3
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
@@ -14,13 +15,22 @@ import { FilesModule } from './files/files.module';
|
|||||||
import { ChatModule } from './chat/chat.module';
|
import { ChatModule } from './chat/chat.module';
|
||||||
import { SyncModule } from './sync/sync.module';
|
import { SyncModule } from './sync/sync.module';
|
||||||
|
|
||||||
|
// Prefer the Flutter web build (single entry point at :3000, incl. the OIDC
|
||||||
|
// redirect path /v1/auth/callback via SPA fallback). Falls back to the plain
|
||||||
|
// interim client if the Flutter build hasn't been produced yet.
|
||||||
|
const flutterWeb = join(__dirname, '..', '..', 'client', 'app', 'build', 'web');
|
||||||
|
const interimWeb = join(__dirname, '..', '..', 'client', 'web');
|
||||||
|
const webRoot =
|
||||||
|
process.env.WEB_CLIENT_DIR ?? (existsSync(flutterWeb) ? flutterWeb : interimWeb);
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
// Serves the plain static web client from ../client/web; the REST API
|
// Static web client; the REST API lives under /api (see main.ts) so it
|
||||||
// lives under /api (see main.ts) so it never collides with these routes.
|
// never collides. Unmatched non-file paths fall back to index.html so the
|
||||||
|
// client-side router owns routes like /v1/auth/callback.
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: join(__dirname, '..', '..', 'client', 'web'),
|
rootPath: webRoot,
|
||||||
exclude: ['/api*'],
|
exclude: ['/api*'],
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
|||||||
@@ -32,18 +32,20 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
|||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly sync: SyncService,
|
private readonly sync: SyncService,
|
||||||
) {
|
) {
|
||||||
const issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
// Authentik's discovery `issuer` carries a trailing slash and so does the
|
||||||
|
// `iss` claim in its tokens; accept both spellings and never emit `//`.
|
||||||
|
const base = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL').replace(/\/+$/, '');
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: (req: Request) =>
|
jwtFromRequest: (req: Request) =>
|
||||||
req.headers.authorization?.startsWith('Bearer ')
|
req.headers.authorization?.startsWith('Bearer ')
|
||||||
? req.headers.authorization.slice('Bearer '.length)
|
? req.headers.authorization.slice('Bearer '.length)
|
||||||
: null,
|
: null,
|
||||||
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
||||||
jwksUri: `${issuerUrl}/jwks/`,
|
jwksUri: `${base}/jwks/`,
|
||||||
cache: true,
|
cache: true,
|
||||||
rateLimit: true,
|
rateLimit: true,
|
||||||
}),
|
}),
|
||||||
issuer: issuerUrl,
|
issuer: [base, `${base}/`],
|
||||||
algorithms: ['RS256'],
|
algorithms: ['RS256'],
|
||||||
});
|
});
|
||||||
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
|
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import * as jwt from 'jsonwebtoken';
|
|||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
import { AuthenticatedUser } from './authenticated-request';
|
import { AuthenticatedUser } from './authenticated-request';
|
||||||
|
import { toAuthenticatedUser } from './provision-user';
|
||||||
|
|
||||||
export interface TeamJwtPayload {
|
export interface TeamJwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -146,15 +147,8 @@ export class TeamAuthService {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Team account no longer exists');
|
throw new UnauthorizedException('Team account no longer exists');
|
||||||
}
|
}
|
||||||
return {
|
// Same shape as the Authentik path, incl. the synthetic global
|
||||||
userId: user.id,
|
// LEITUNGSTEAM membership when `isLeitungsteam` is set on the row.
|
||||||
authentikSub: user.authentikSub,
|
return toAuthenticatedUser(user);
|
||||||
email: user.email,
|
|
||||||
memberships: user.memberships.map((m) => ({
|
|
||||||
kcId: m.kcId,
|
|
||||||
gemeindeId: m.gemeindeId,
|
|
||||||
role: m.role,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export class TokenVerificationService {
|
|||||||
private readonly teamAuth: TeamAuthService,
|
private readonly teamAuth: TeamAuthService,
|
||||||
private readonly sync: SyncService,
|
private readonly sync: SyncService,
|
||||||
) {
|
) {
|
||||||
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
// See AuthentikStrategy: normalise the trailing slash, accept both forms.
|
||||||
|
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL').replace(/\/+$/, '');
|
||||||
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
||||||
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
|
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
|
||||||
}
|
}
|
||||||
@@ -48,7 +49,7 @@ export class TokenVerificationService {
|
|||||||
}
|
}
|
||||||
const key = await this.jwks.getSigningKey(kid);
|
const key = await this.jwks.getSigningKey(kid);
|
||||||
const payload = jwt.verify(token, key.getPublicKey(), {
|
const payload = jwt.verify(token, key.getPublicKey(), {
|
||||||
issuer: this.issuerUrl,
|
issuer: [this.issuerUrl, `${this.issuerUrl}/`],
|
||||||
algorithms: ['RS256'],
|
algorithms: ['RS256'],
|
||||||
}) as jwt.JwtPayload & {
|
}) as jwt.JwtPayload & {
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Role } from '../common/role.enum';
|
|||||||
/// global across all KCs (see RolesGuard); Gemeinde Verantwortliche/Teamer
|
/// global across all KCs (see RolesGuard); Gemeinde Verantwortliche/Teamer
|
||||||
/// learn their own Gemeinde from their Membership, not from this endpoint.
|
/// learn their own Gemeinde from their Membership, not from this endpoint.
|
||||||
@Controller('gemeinde')
|
@Controller('gemeinde')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
export class GemeindeController {
|
export class GemeindeController {
|
||||||
constructor(private readonly gemeinde: GemeindeService) {}
|
constructor(private readonly gemeinde: GemeindeService) {}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { RolesGuard } from '../common/roles.guard';
|
|||||||
import { Role } from '../common/role.enum';
|
import { Role } from '../common/role.enum';
|
||||||
|
|
||||||
@Controller('kc')
|
@Controller('kc')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
export class KcController {
|
export class KcController {
|
||||||
constructor(private readonly kc: KcService) {}
|
constructor(private readonly kc: KcService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -45,21 +45,21 @@ export class OnboardingController {
|
|||||||
|
|
||||||
/// Leitungsteam: review and act on pending self-registrations.
|
/// Leitungsteam: review and act on pending self-registrations.
|
||||||
@Get('requests')
|
@Get('requests')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
listRequests(@Query('kcId') kcId: string) {
|
listRequests(@Query('kcId') kcId: string) {
|
||||||
return this.onboarding.listRequests(kcId);
|
return this.onboarding.listRequests(kcId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('requests/:membershipId/approve')
|
@Post('requests/:membershipId/approve')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
approve(@Param('membershipId') membershipId: string) {
|
approve(@Param('membershipId') membershipId: string) {
|
||||||
return this.onboarding.approve(membershipId);
|
return this.onboarding.approve(membershipId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('requests/:membershipId/reject')
|
@Post('requests/:membershipId/reject')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
reject(@Param('membershipId') membershipId: string) {
|
reject(@Param('membershipId') membershipId: string) {
|
||||||
return this.onboarding.reject(membershipId);
|
return this.onboarding.reject(membershipId);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class SyncController {
|
|||||||
|
|
||||||
/// Manual on-demand push+pull against the configured peer (Leitungsteam-only).
|
/// Manual on-demand push+pull against the configured peer (Leitungsteam-only).
|
||||||
@Post('trigger')
|
@Post('trigger')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
async trigger() {
|
async trigger() {
|
||||||
const peerUrl = this.config.getOrThrow<string>('SYNC_PEER_URL');
|
const peerUrl = this.config.getOrThrow<string>('SYNC_PEER_URL');
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ import { Role } from '../common/role.enum';
|
|||||||
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
|
||||||
/// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and
|
/// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and
|
||||||
/// Gemeinde Verantwortliche (both Authentik-backed); TeamerService then
|
/// Gemeinde Verantwortliche (Authentik tokens, or a local `isLeitungsteam`
|
||||||
/// checks the caller is actually responsible for `:gemeindeId`.
|
/// account via a team token); TeamerService then checks the caller is
|
||||||
|
/// actually responsible for `:gemeindeId`.
|
||||||
@Controller('gemeinde/:gemeindeId')
|
@Controller('gemeinde/:gemeindeId')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER)
|
@Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER)
|
||||||
export class TeamerController {
|
export class TeamerController {
|
||||||
constructor(private readonly teamer: TeamerService) {}
|
constructor(private readonly teamer: TeamerService) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user