feat: initialize backend with NestJS, PostgreSQL, and Prisma
- Add package.json for backend dependencies and scripts. - Create Prisma schema for multi-tenant event management. - Implement main application module and configure global settings. - Develop authentication module with JWT and Authentik integration. - Create DTOs for guest account creation and KC management. - Implement role-based access control with custom guards and decorators. - Add services and controllers for managing KCs and guest accounts. - Set up global validation and CORS in the main application entry point. - Establish Prisma module for database access throughout the application. - Document project plan and architecture for multi-tenant platform.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { KcModule } from './kc/kc.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
KcModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { GuestAuthService } from './guest-auth.service';
|
||||
import { CreateGuestDto } from './dto/create-guest.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly guestAuth: GuestAuthService) {}
|
||||
|
||||
/// Redeems a KC invite code and registers a new temporary guest/Konfi account.
|
||||
@Post('guest')
|
||||
createGuest(@Body() dto: CreateGuestDto) {
|
||||
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { GuestAuthService } from './guest-auth.service';
|
||||
import { AuthentikStrategy } from './authentik.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('GUEST_JWT_SECRET'),
|
||||
signOptions: { expiresIn: '12h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [GuestAuthService, AuthentikStrategy],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Request } from 'express';
|
||||
import { Role } from '../common/role.enum';
|
||||
|
||||
export interface AuthenticatedMembership {
|
||||
kcId: string;
|
||||
gemeindeId: string | null;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
/// Shape attached to req.user by JwtStrategy after validating an access token.
|
||||
export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
authentikSub: string;
|
||||
email: string;
|
||||
memberships: AuthenticatedMembership[];
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import * as jwksRsa from 'jwks-rsa';
|
||||
import { Request } from 'express';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { AuthenticatedUser } from './authenticated-request';
|
||||
|
||||
interface AuthentikJwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
}
|
||||
|
||||
/// Validates access tokens issued by Authentik (resource-server pattern):
|
||||
/// signature is checked against Authentik's JWKS, then the local Membership
|
||||
/// table decides what the user may do. Authentik itself is only the identity
|
||||
/// source, never asked for authorization here.
|
||||
@Injectable()
|
||||
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaClient,
|
||||
) {
|
||||
const issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
||||
super({
|
||||
jwtFromRequest: (req: Request) =>
|
||||
req.headers.authorization?.startsWith('Bearer ')
|
||||
? req.headers.authorization.slice('Bearer '.length)
|
||||
: null,
|
||||
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
||||
jwksUri: `${issuerUrl}/jwks/`,
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
}),
|
||||
issuer: issuerUrl,
|
||||
algorithms: ['RS256'],
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { authentikSub: payload.sub },
|
||||
include: { memberships: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not provisioned locally yet');
|
||||
}
|
||||
return {
|
||||
userId: user.id,
|
||||
authentikSub: user.authentikSub,
|
||||
email: user.email,
|
||||
memberships: user.memberships.map((m) => ({
|
||||
kcId: m.kcId,
|
||||
gemeindeId: m.gemeindeId,
|
||||
role: m.role,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateGuestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
inviteCode!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName!: string;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
|
||||
export interface GuestJwtPayload {
|
||||
guestId: string;
|
||||
kcId: string;
|
||||
gemeindeId: string | null;
|
||||
}
|
||||
|
||||
/// Guest/Konfi accounts are local to this server (never Authentik-backed),
|
||||
/// created via a KC invite code, and scoped to that single KC.
|
||||
@Injectable()
|
||||
export class GuestAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async createGuest(
|
||||
inviteCode: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
): Promise<{ accessToken: string }> {
|
||||
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
|
||||
if (!kc || !kc.isActive) {
|
||||
throw new NotFoundException('Unknown or inactive KC invite code');
|
||||
}
|
||||
|
||||
const guest = await this.prisma.guestAccount.create({
|
||||
data: { kcId: kc.id, firstName, lastName },
|
||||
});
|
||||
|
||||
const payload: GuestJwtPayload = {
|
||||
guestId: guest.id,
|
||||
kcId: kc.id,
|
||||
gemeindeId: guest.gemeindeId,
|
||||
};
|
||||
return { accessToken: await this.jwt.signAsync(payload) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/// Re-exported from the Prisma client so guards and strategies share one
|
||||
/// enum type with the database schema. Guests are not part of this enum
|
||||
/// since they authenticate separately and never hold elevated rights.
|
||||
export { Role } from '@prisma/client';
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from './role.enum';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
/// Marks a route as requiring at least one of the given roles (scope-checked by RolesGuard).
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from './role.enum';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
|
||||
/// Checks the caller holds one of the required roles, scoped to the KC in the
|
||||
/// request (route param `kcId`, falling back to body.kcId). LEITUNGSTEAM
|
||||
/// memberships are global and satisfy any KC scope.
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new ForbiddenException('Not authenticated');
|
||||
}
|
||||
|
||||
const kcId = request.params?.kcId ?? request.body?.kcId;
|
||||
const hasRole = user.memberships.some((membership) => {
|
||||
if (!requiredRoles.includes(membership.role)) {
|
||||
return false;
|
||||
}
|
||||
if (membership.role === Role.LEITUNGSTEAM) {
|
||||
return true;
|
||||
}
|
||||
return kcId ? membership.kcId === kcId : true;
|
||||
});
|
||||
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('Insufficient role for this KC');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateKcDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { KcService } from './kc.service';
|
||||
import { CreateKcDto } from './dto/create-kc.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
|
||||
@Controller('kc')
|
||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||
export class KcController {
|
||||
constructor(private readonly kc: KcService) {}
|
||||
|
||||
/// Only the Leitungsteam may create new KC events.
|
||||
@Post()
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
create(@Body() dto: CreateKcDto) {
|
||||
return this.kc.createKc(dto.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
list() {
|
||||
return this.kc.listKcs();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KcService } from './kc.service';
|
||||
import { KcController } from './kc.controller';
|
||||
|
||||
@Module({
|
||||
providers: [KcService],
|
||||
controllers: [KcController],
|
||||
})
|
||||
export class KcModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
|
||||
@Injectable()
|
||||
export class KcService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
createKc(name: string) {
|
||||
return this.prisma.kc.create({
|
||||
data: { name, inviteCode: randomBytes(6).toString('hex') },
|
||||
});
|
||||
}
|
||||
|
||||
listKcs() {
|
||||
return this.prisma.kc.findMany();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.enableCors();
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/// Shared Prisma connection; injected wherever DB access is needed.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: PrismaClient,
|
||||
useFactory: () => new PrismaClient(),
|
||||
},
|
||||
],
|
||||
exports: [PrismaClient],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
||||
export { PrismaClient };
|
||||
Reference in New Issue
Block a user