This commit is contained in:
Bastian Wagner
2026-07-15 14:09:28 +02:00
commit 3e5348b7ec
104 changed files with 30367 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
export type AccountDeleteRequestStatus = 'pending' | 'resolved';
@Entity({ name: 'account_delete_requests' })
export class AccountDeleteRequest {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
username!: string;
@Column({ default: 'pending' })
status!: AccountDeleteRequestStatus;
@Column({ nullable: true })
reason?: string;
@Column({ nullable: true })
resolvedAt?: Date;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -0,0 +1,125 @@
import { Body, Controller, Get, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequestUser } from '../common/request-user';
import { hashToken, randomToken } from '../common/token.util';
import { LldapService } from '../lldap/lldap.service';
import { PortalMailService } from '../mail/portal-mail.service';
import { AccountDeleteRequest } from './account-delete-request.entity';
import { ConfirmEmailChangeDto } from './dto/confirm-email-change.dto';
import { DeleteRequestDto } from './dto/delete-request.dto';
import { RequestEmailChangeDto } from './dto/request-email-change.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { EmailChangeRequest } from './email-change-request.entity';
@Controller('account')
@UseGuards(JwtAuthGuard)
export class AccountController {
constructor(
private readonly lldap: LldapService,
private readonly mail: PortalMailService,
private readonly config: ConfigService,
private readonly audit: AuditService,
@InjectRepository(EmailChangeRequest)
private readonly emailChanges: Repository<EmailChangeRequest>,
@InjectRepository(AccountDeleteRequest)
private readonly deleteRequests: Repository<AccountDeleteRequest>,
) {}
@Get('me')
me(@Req() request: Request & { user: RequestUser }) {
return this.lldap.getAccount(request.user.username);
}
@Patch('profile')
async updateProfile(
@Body() dto: UpdateProfileDto,
@Req() request: Request & { user: RequestUser },
) {
await this.lldap.updateUser(request.user.username, dto);
await this.audit.record({
type: 'account.profile_updated',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
});
return this.lldap.getAccount(request.user.username);
}
@Post('email-change/request')
async requestEmailChange(
@Body() dto: RequestEmailChangeDto,
@Req() request: Request & { user: RequestUser },
) {
const token = randomToken();
await this.emailChanges.save(
this.emailChanges.create({
username: request.user.username,
newEmail: dto.newEmail.toLowerCase(),
tokenHash: hashToken(token, this.tokenSecret),
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
}),
);
await this.mail.sendEmailChangeMail(dto.newEmail, token);
await this.audit.record({
type: 'account.email_change_requested',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { newEmail: dto.newEmail.toLowerCase() },
});
return { message: 'Bitte bestaetige die neue E-Mail-Adresse.' };
}
@Post('email-change/confirm')
async confirmEmailChange(
@Body() dto: ConfirmEmailChangeDto,
@Req() request: Request & { user: RequestUser },
) {
const tokenHash = hashToken(dto.token, this.tokenSecret);
const record = await this.emailChanges.findOne({ where: { tokenHash, consumedAt: IsNull() } });
if (!record || record.expiresAt.getTime() < Date.now() || record.username !== request.user.username) {
return { message: 'Der Bestaetigungslink ist ungueltig oder abgelaufen.' };
}
await this.lldap.updateUser(request.user.username, { email: record.newEmail });
record.consumedAt = new Date();
await this.emailChanges.save(record);
await this.audit.record({
type: 'account.email_changed',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { newEmail: record.newEmail },
});
return { message: 'Die E-Mail-Adresse wurde aktualisiert.' };
}
@Post('delete-request')
async requestDeletion(
@Body() dto: DeleteRequestDto,
@Req() request: Request & { user: RequestUser },
) {
await this.deleteRequests.save(
this.deleteRequests.create({
username: request.user.username,
reason: dto.reason,
}),
);
await this.audit.record({
type: 'account.delete_requested',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
});
return { message: 'Die Anfrage wurde gespeichert.' };
}
private get tokenSecret(): string {
return this.config.getOrThrow<string>('TOKEN_SECRET');
}
}

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { LldapModule } from '../lldap/lldap.module';
import { MailModule } from '../mail/mail.module';
import { AccountDeleteRequest } from './account-delete-request.entity';
import { AccountController } from './account.controller';
import { EmailChangeRequest } from './email-change-request.entity';
@Module({
imports: [
TypeOrmModule.forFeature([EmailChangeRequest, AccountDeleteRequest]),
AuthModule,
LldapModule,
MailModule,
AuditModule,
],
controllers: [AccountController],
exports: [TypeOrmModule],
})
export class AccountModule {}

View File

@@ -0,0 +1,7 @@
import { IsString, Length } from 'class-validator';
export class ConfirmEmailChangeDto {
@IsString()
@Length(20, 256)
token!: string;
}

View File

@@ -0,0 +1,8 @@
import { IsOptional, IsString, Length } from 'class-validator';
export class DeleteRequestDto {
@IsString()
@Length(0, 1000)
@IsOptional()
reason?: string;
}

View File

@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class RequestEmailChangeDto {
@IsEmail()
newEmail!: string;
}

View File

@@ -0,0 +1,22 @@
import { IsOptional, IsString, Length } from 'class-validator';
export class UpdateProfileDto {
@IsString()
@Length(1, 128)
@IsOptional()
displayName?: string;
@IsString()
@Length(0, 128)
@IsOptional()
firstName?: string;
@IsString()
@Length(0, 128)
@IsOptional()
lastName?: string;
@IsString()
@IsOptional()
avatar?: string;
}

View File

@@ -0,0 +1,25 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'email_change_requests' })
export class EmailChangeRequest {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
username!: string;
@Column()
newEmail!: string;
@Column({ unique: true })
tokenHash!: string;
@Column()
expiresAt!: Date;
@Column({ nullable: true })
consumedAt?: Date;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -0,0 +1,20 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditEvent } from '../audit/audit-event.entity';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminRoleGuard } from './admin-role.guard';
@Controller('admin/audit')
@UseGuards(JwtAuthGuard, AdminRoleGuard('audit_viewer'))
export class AdminAuditController {
constructor(
@InjectRepository(AuditEvent)
private readonly events: Repository<AuditEvent>,
) {}
@Get()
list() {
return this.events.find({ order: { createdAt: 'DESC' }, take: 250 });
}
}

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { AuditService } from '../audit/audit.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequestUser } from '../common/request-user';
import { LldapService } from '../lldap/lldap.service';
import { AdminRoleGuard } from './admin-role.guard';
import { CreateGroupDto } from './dto/create-group.dto';
import { UpdateGroupDto } from './dto/update-group.dto';
@Controller('admin/groups')
@UseGuards(JwtAuthGuard, AdminRoleGuard('group_manager'))
export class AdminGroupsController {
constructor(
private readonly lldap: LldapService,
private readonly audit: AuditService,
) {}
@Get()
list() {
return this.lldap.listGroups();
}
@Post()
async create(@Body() dto: CreateGroupDto, @Req() request: Request & { user: RequestUser }) {
const group = await this.lldap.createGroup(dto.displayName);
await this.audit.record({ type: 'admin.group_created', metadata: { admin: request.user.username, groupId: group.id } });
return group;
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateGroupDto, @Req() request: Request & { user: RequestUser }) {
await this.lldap.updateGroup(Number(id), dto.displayName);
await this.audit.record({ type: 'admin.group_updated', metadata: { admin: request.user.username, groupId: id } });
return this.lldap.getGroup(Number(id));
}
@Delete(':id')
async delete(@Param('id') id: string, @Req() request: Request & { user: RequestUser }) {
await this.lldap.deleteGroup(Number(id));
await this.audit.record({ type: 'admin.group_deleted', metadata: { admin: request.user.username, groupId: id } });
return { message: 'Gruppe wurde geloescht.' };
}
}

View File

@@ -0,0 +1,32 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequestUser } from '../common/request-user';
import { RejectRegistrationDto } from '../registration/dto/reject-registration.dto';
import { RegistrationService } from '../registration/registration.service';
import { AdminRoleGuard } from './admin-role.guard';
@Controller('admin/registrations')
@UseGuards(JwtAuthGuard, AdminRoleGuard('registration_manager'))
export class AdminRegistrationsController {
constructor(private readonly registrations: RegistrationService) {}
@Get()
list() {
return this.registrations.list();
}
@Post(':id/approve')
approve(@Param('id') id: string, @Req() request: Request & { user: RequestUser }) {
return this.registrations.approve(id, request.user.username, request.ip, request.headers['user-agent']);
}
@Post(':id/reject')
reject(
@Param('id') id: string,
@Body() dto: RejectRegistrationDto,
@Req() request: Request & { user: RequestUser },
) {
return this.registrations.reject(id, request.user.username, dto.reason, request.ip, request.headers['user-agent']);
}
}

View File

@@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, mixin, Type } from '@nestjs/common';
import { Request } from 'express';
import { RequestUser } from '../common/request-user';
import { LldapService } from '../lldap/lldap.service';
export function AdminRoleGuard(requiredGroup: string): Type<CanActivate> {
@Injectable()
class RoleGuard implements CanActivate {
constructor(private readonly lldap: LldapService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user?: RequestUser }>();
const username = request.user?.username;
if (!username) {
throw new ForbiddenException('Nicht angemeldet.');
}
const account = await this.lldap.getAccount(username);
const allowed = account.groups.some((group) => group.displayName === requiredGroup);
if (!allowed) {
throw new ForbiddenException(`Benötigt Gruppe ${requiredGroup}.`);
}
return true;
}
}
return mixin(RoleGuard);
}

View File

@@ -0,0 +1,59 @@
import { Body, Controller, Delete, Get, Param, Patch, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { AuditService } from '../audit/audit.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequestUser } from '../common/request-user';
import { LldapService } from '../lldap/lldap.service';
import { AdminRoleGuard } from './admin-role.guard';
import { UpdateAdminUserDto } from './dto/update-admin-user.dto';
@Controller('admin/users')
@UseGuards(JwtAuthGuard, AdminRoleGuard('user_manager'))
export class AdminUsersController {
constructor(
private readonly lldap: LldapService,
private readonly audit: AuditService,
) {}
@Get()
list() {
return this.lldap.listUsers();
}
@Get(':id')
detail(@Param('id') id: string) {
return this.lldap.getAccount(id);
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateAdminUserDto,
@Req() request: Request & { user: RequestUser },
) {
await this.lldap.updateUser(id, dto);
await this.audit.record({ type: 'admin.user_updated', username: id, metadata: { admin: request.user.username } });
return this.lldap.getAccount(id);
}
@Delete(':id')
async delete(@Param('id') id: string, @Req() request: Request & { user: RequestUser }) {
await this.lldap.deleteUser(id);
await this.audit.record({ type: 'admin.user_deleted', username: id, metadata: { admin: request.user.username } });
return { message: 'User wurde geloescht.' };
}
@Patch(':id/groups/:groupId')
async addGroup(@Param('id') id: string, @Param('groupId') groupId: string, @Req() request: Request & { user: RequestUser }) {
await this.lldap.addUserToGroup(id, groupId);
await this.audit.record({ type: 'admin.user_group_added', username: id, metadata: { admin: request.user.username, groupId } });
return this.lldap.getAccount(id);
}
@Delete(':id/groups/:groupId')
async removeGroup(@Param('id') id: string, @Param('groupId') groupId: string, @Req() request: Request & { user: RequestUser }) {
await this.lldap.removeUserFromGroup(id, groupId);
await this.audit.record({ type: 'admin.user_group_removed', username: id, metadata: { admin: request.user.username, groupId } });
return this.lldap.getAccount(id);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditEvent } from '../audit/audit-event.entity';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { LldapModule } from '../lldap/lldap.module';
import { RegistrationModule } from '../registration/registration.module';
import { AdminAuditController } from './admin-audit.controller';
import { AdminGroupsController } from './admin-groups.controller';
import { AdminRegistrationsController } from './admin-registrations.controller';
import { AdminUsersController } from './admin-users.controller';
@Module({
imports: [AuthModule, LldapModule, RegistrationModule, AuditModule, TypeOrmModule.forFeature([AuditEvent])],
controllers: [AdminRegistrationsController, AdminUsersController, AdminGroupsController, AdminAuditController],
})
export class AdminModule {}

View File

@@ -0,0 +1,7 @@
import { IsString, Length } from 'class-validator';
export class CreateGroupDto {
@IsString()
@Length(1, 128)
displayName!: string;
}

View File

@@ -0,0 +1,22 @@
import { IsEmail, IsOptional, IsString, Length } from 'class-validator';
export class UpdateAdminUserDto {
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@Length(1, 128)
@IsOptional()
displayName?: string;
@IsString()
@Length(0, 128)
@IsOptional()
firstName?: string;
@IsString()
@Length(0, 128)
@IsOptional()
lastName?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, Length } from 'class-validator';
export class UpdateGroupDto {
@IsString()
@Length(1, 128)
displayName!: string;
}

View File

@@ -0,0 +1,65 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from './audit/audit.module';
import { AdminModule } from './admin/admin.module';
import { AuthModule } from './auth/auth.module';
import { MailModule } from './mail/mail.module';
import { OidcModule } from './oidc/oidc.module';
import { PasswordModule } from './password/password.module';
import { RegistrationModule } from './registration/registration.module';
import { AccountModule } from './account/account.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env', '../../.env'],
}),
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 20,
},
]),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const databaseUrl = config.get<string>('DATABASE_URL');
const base = {
type: 'mysql' as const,
autoLoadEntities: true,
synchronize: config.get('NODE_ENV') !== 'production',
ssl: config.get('DATABASE_SSL') === 'true' ? { rejectUnauthorized: false } : false,
charset: 'utf8mb4_unicode_ci',
};
if (databaseUrl?.includes('://')) {
return {
...base,
url: databaseUrl,
};
}
return {
...base,
host: config.get<string>('DB_HOST') ?? databaseUrl ?? 'localhost',
port: Number(config.get<string>('DB_PORT', '3306')),
username: config.get<string>('DB_USERNAME', 'root'),
password: config.get<string>('DB_PASSWORD', ''),
database: config.get<string>('DB_DATABASE', 'ldap_portal'),
};
},
}),
AuditModule,
AdminModule,
MailModule,
AuthModule,
AccountModule,
OidcModule,
RegistrationModule,
PasswordModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,25 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'audit_events' })
export class AuditEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
type!: string;
@Column({ nullable: true })
username?: string;
@Column({ nullable: true })
ipAddress?: string;
@Column({ nullable: true })
userAgent?: string;
@Column({ type: 'simple-json' })
metadata!: Record<string, unknown>;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditEvent } from './audit-event.entity';
import { AuditService } from './audit.service';
@Module({
imports: [TypeOrmModule.forFeature([AuditEvent])],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditEvent } from './audit-event.entity';
export interface AuditInput {
type: string;
username?: string;
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
}
@Injectable()
export class AuditService {
constructor(
@InjectRepository(AuditEvent)
private readonly events: Repository<AuditEvent>,
) {}
async record(input: AuditInput): Promise<void> {
await this.events.save(
this.events.create({
...input,
metadata: input.metadata ?? {},
}),
);
}
}

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { RequestUser } from '../common/request-user';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('login')
login(@Body() dto: LoginDto, @Req() request: Request) {
return this.auth.login(
dto.username,
dto.password,
request.ip,
request.headers['user-agent'],
);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@Req() request: Request & { user: RequestUser }) {
return { user: request.user };
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { AuditModule } from '../audit/audit.module';
import { LldapModule } from '../lldap/lldap.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@Module({
imports: [
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '8h' },
}),
}),
LldapModule,
AuditModule,
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
exports: [AuthService, JwtAuthGuard, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,27 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuditService } from '../audit/audit.service';
import { LdapAuthService } from '../lldap/ldap-auth.service';
@Injectable()
export class AuthService {
constructor(
private readonly ldapAuth: LdapAuthService,
private readonly jwt: JwtService,
private readonly audit: AuditService,
) {}
async login(username: string, password: string, ipAddress?: string, userAgent?: string) {
const valid = await this.ldapAuth.verifyPassword(username, password);
if (!valid) {
await this.audit.record({ type: 'auth.login_failed', username, ipAddress, userAgent });
throw new UnauthorizedException('Ungueltige Zugangsdaten.');
}
await this.audit.record({ type: 'auth.login_success', username, ipAddress, userAgent });
return {
accessToken: await this.jwt.signAsync({ sub: username, username }),
user: { username },
};
}
}

View File

@@ -0,0 +1,11 @@
import { IsString, Length } from 'class-validator';
export class LoginDto {
@IsString()
@Length(1, 128)
username!: string;
@IsString()
@Length(1, 256)
password!: string;
}

View File

@@ -0,0 +1,34 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { RequestUser } from '../common/request-user';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user?: RequestUser }>();
const token = this.extractBearerToken(request);
if (!token) {
throw new UnauthorizedException('Nicht angemeldet.');
}
try {
request.user = await this.jwt.verifyAsync<RequestUser>(token);
return true;
} catch {
throw new UnauthorizedException('Session ist ungueltig oder abgelaufen.');
}
}
private extractBearerToken(request: Request): string | undefined {
const header = request.headers.authorization;
if (!header) {
return undefined;
}
const [type, token] = header.split(' ');
return type?.toLowerCase() === 'bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,13 @@
import { BadRequestException } from '@nestjs/common';
export function assertPasswordPolicy(password: string): void {
if (password.length < 12) {
throw new BadRequestException('Das Passwort muss mindestens 12 Zeichen lang sein.');
}
if (!/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/\d/.test(password)) {
throw new BadRequestException(
'Das Passwort muss Grossbuchstaben, Kleinbuchstaben und Ziffern enthalten.',
);
}
}

View File

@@ -0,0 +1,4 @@
export interface RequestUser {
sub: string;
username: string;
}

View File

@@ -0,0 +1,33 @@
import { createCipheriv, createDecipheriv, createHmac, createHash, randomBytes } from 'node:crypto';
export function randomToken(): string {
return randomBytes(32).toString('base64url');
}
export function hashToken(token: string, secret: string): string {
return createHmac('sha256', secret).update(token).digest('hex');
}
export function encryptSecret(value: string, secret: string): string {
const key = createHash('sha256').update(secret).digest();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv, tag, ciphertext].map((part) => part.toString('base64url')).join('.');
}
export function decryptSecret(value: string, secret: string): string {
const [ivRaw, tagRaw, ciphertextRaw] = value.split('.');
if (!ivRaw || !tagRaw || !ciphertextRaw) {
throw new Error('Invalid encrypted payload');
}
const key = createHash('sha256').update(secret).digest();
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivRaw, 'base64url'));
decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
return Buffer.concat([
decipher.update(Buffer.from(ciphertextRaw, 'base64url')),
decipher.final(),
]).toString('utf8');
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Client } from 'ldapts';
@Injectable()
export class LdapAuthService {
constructor(private readonly config: ConfigService) {}
async verifyPassword(username: string, password: string): Promise<boolean> {
const client = new Client({ url: this.config.getOrThrow<string>('LLDAP_LDAP_URL') });
try {
await client.bind(this.userDn(username), password);
return true;
} catch {
return false;
} finally {
await client.unbind().catch(() => undefined);
}
}
private userDn(username: string): string {
const baseDn = this.config.getOrThrow<string>('LLDAP_BASE_DN');
return `uid=${this.escapeDn(username)},ou=people,${baseDn}`;
}
private escapeDn(value: string): string {
return value.replace(/[\\,+"<>;=]/g, (char) => `\\${char}`);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { LdapAuthService } from './ldap-auth.service';
import { LldapService } from './lldap.service';
@Module({
providers: [LdapAuthService, LldapService],
exports: [LdapAuthService, LldapService],
})
export class LldapModule {}

View File

@@ -0,0 +1,441 @@
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
interface LldapUserInput {
username: string;
email: string;
displayName: string;
password: string;
}
interface LldapUser {
id: string;
email?: string;
displayName?: string;
}
export interface LldapGroup {
id: number;
displayName: string;
creationDate: string;
uuid: string;
attributes: LldapAttributeValue[];
users?: LldapUser[];
}
export interface LldapUserUpdateInput {
email?: string;
displayName?: string;
firstName?: string;
lastName?: string;
avatar?: string | null;
}
export interface LldapAttributeSchema {
name: string;
attributeType: string;
isList: boolean;
isVisible: boolean;
isEditable: boolean;
isHardcoded: boolean;
isReadonly: boolean;
}
export interface LldapAttributeValue {
name: string;
value: string[];
schema: LldapAttributeSchema;
}
export interface LldapAccountGroup {
id: number;
displayName: string;
creationDate: string;
uuid: string;
attributes: LldapAttributeValue[];
}
export interface LldapAccountUser {
id: string;
email: string;
displayName: string;
firstName: string;
lastName: string;
avatar?: string | null;
creationDate: string;
uuid: string;
attributes: LldapAttributeValue[];
groups: LldapAccountGroup[];
}
@Injectable()
export class LldapService {
private cachedHeaders?: { expiresAt: number; headers: Record<string, string> };
constructor(private readonly config: ConfigService) {}
async createUser(input: LldapUserInput): Promise<void> {
await this.graphql(
`mutation CreateUser($user: CreateUserInput!) {
createUser(user: $user) { id }
}`,
{
user: {
id: input.username,
email: input.email,
displayName: input.displayName,
password: input.password,
},
},
);
const defaultGroup = this.config.get<string>('LLDAP_DEFAULT_GROUP');
if (defaultGroup) {
await this.addUserToGroup(input.username, defaultGroup);
}
}
async setPassword(username: string, password: string): Promise<void> {
await this.graphql(
`mutation SetPassword($userId: String!, $password: String!) {
setPassword(userId: $userId, password: $password)
}`,
{ userId: username, password },
);
}
async updateUser(username: string, input: LldapUserUpdateInput): Promise<void> {
await this.graphql(
`mutation UpdateUser($user: UpdateUserInput!) {
updateUser(user: $user) { ok }
}`,
{
user: {
id: username,
...input,
avatar: input.avatar === null ? '' : input.avatar,
},
},
);
}
async deleteUser(username: string): Promise<void> {
await this.graphql(
`mutation DeleteUser($userId: String!) {
deleteUser(userId: $userId) { ok }
}`,
{ userId: username },
);
}
async findUserByUsername(username: string): Promise<LldapUser | null> {
const response = await this.graphql<{ user: LldapUser | null }>(
`query User($id: String!) {
user(userId: $id) { id email displayName }
}`,
{ id: username },
);
return response.user ?? null;
}
async findUserByEmail(email: string): Promise<LldapUser | null> {
const response = await this.graphql<{ users: LldapUser[] }>(
`query Users($filters: RequestFilter) {
users(filters: $filters) { id email displayName }
}`,
{ filters: { eq: { field: 'email', value: email } } },
);
return response.users?.[0] ?? null;
}
async getAccount(username: string): Promise<LldapAccountUser> {
const response = await this.graphql<{ user: LldapAccountUser }>(
`query Account($id: String!) {
user(userId: $id) {
id
email
displayName
firstName
lastName
avatar
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
groups {
id
displayName
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}
}`,
{ id: username },
);
return response.user;
}
async listUsers(): Promise<LldapAccountUser[]> {
const response = await this.graphql<{ users: LldapAccountUser[] }>(
`query Users {
users {
id
email
displayName
firstName
lastName
avatar
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
groups {
id
displayName
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}
}`,
{},
);
return response.users;
}
async listGroups(): Promise<LldapGroup[]> {
const response = await this.graphql<{ groups: LldapGroup[] }>(
`query Groups {
groups {
id
displayName
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
users { id email displayName }
}
}`,
{},
);
return response.groups;
}
async getGroup(groupId: number): Promise<LldapGroup> {
const response = await this.graphql<{ group: LldapGroup }>(
`query Group($groupId: Int!) {
group(groupId: $groupId) {
id
displayName
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
users { id email displayName }
}
}`,
{ groupId },
);
return response.group;
}
async createGroup(displayName: string): Promise<LldapGroup> {
const response = await this.graphql<{ createGroupWithDetails: LldapGroup }>(
`mutation CreateGroup($request: CreateGroupInput!) {
createGroupWithDetails(request: $request) {
id
displayName
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}`,
{ request: { displayName, attributes: [] } },
);
return response.createGroupWithDetails;
}
async updateGroup(groupId: number, displayName: string): Promise<void> {
await this.graphql(
`mutation UpdateGroup($group: UpdateGroupInput!) {
updateGroup(group: $group) { ok }
}`,
{ group: { id: groupId, displayName } },
);
}
async deleteGroup(groupId: number): Promise<void> {
await this.graphql(
`mutation DeleteGroup($groupId: Int!) {
deleteGroup(groupId: $groupId) { ok }
}`,
{ groupId },
);
}
async addUserToGroup(username: string, groupId: string | number): Promise<void> {
await this.graphql(
`mutation AddUserToGroup($userId: String!, $groupId: Int!) {
addUserToGroup(userId: $userId, groupId: $groupId) { ok }
}`,
{ userId: username, groupId: Number(groupId) },
);
}
async removeUserFromGroup(username: string, groupId: string | number): Promise<void> {
await this.graphql(
`mutation RemoveUserFromGroup($userId: String!, $groupId: Int!) {
removeUserFromGroup(userId: $userId, groupId: $groupId) { ok }
}`,
{ userId: username, groupId: Number(groupId) },
);
}
private async graphql<T = unknown>(query: string, variables: Record<string, unknown>): Promise<T> {
const endpoint = `${this.config.getOrThrow<string>('LLDAP_URL').replace(/\/$/, '')}/api/graphql`;
const headers = await this.adminHeaders();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...headers,
},
body: JSON.stringify({ query, variables }),
});
const payload = (await response.json().catch(() => ({}))) as {
data?: T;
errors?: Array<{ message?: string }>;
};
if (!response.ok || payload.errors?.length) {
const message = payload.errors?.map((error) => error.message).join('; ') || response.statusText;
if (/not found/i.test(message)) {
throw new NotFoundException('LLDAP user not found');
}
throw new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`);
}
if (!payload.data) {
throw new InternalServerErrorException('LLDAP GraphQL response did not contain data');
}
return payload.data;
}
private async adminHeaders(): Promise<Record<string, string>> {
const staticToken = this.config.get<string>('LLDAP_GRAPHQL_TOKEN');
if (staticToken) {
return { authorization: `Bearer ${staticToken}` };
}
if (this.cachedHeaders && this.cachedHeaders.expiresAt > Date.now()) {
return this.cachedHeaders.headers;
}
const baseUrl = this.config.getOrThrow<string>('LLDAP_URL').replace(/\/$/, '');
const response = await fetch(`${baseUrl}/auth/simple/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: this.config.getOrThrow<string>('LLDAP_ADMIN_USERNAME'),
password: this.config.getOrThrow<string>('LLDAP_ADMIN_PASSWORD'),
}),
});
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
const cookie = response.headers.get('set-cookie');
const token = typeof body.token === 'string' ? body.token : typeof body.jwt === 'string' ? body.jwt : undefined;
if (!response.ok || (!cookie && !token)) {
throw new InternalServerErrorException('LLDAP admin login failed');
}
const headers: Record<string, string> = token
? { authorization: `Bearer ${token}` }
: { cookie: cookie ?? '' };
this.cachedHeaders = { headers, expiresAt: Date.now() + 5 * 60_000 };
return headers;
}
}

View File

@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer';
import { PortalMailService } from './portal-mail.service';
@Module({
imports: [
MailerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: {
host: config.getOrThrow<string>('SMTP_HOST'),
port: Number(config.get('SMTP_PORT') ?? 587),
secure: config.get('SMTP_SECURE') === 'true',
auth:
config.get('SMTP_USER') && config.get('SMTP_PASS')
? {
user: config.get<string>('SMTP_USER'),
pass: config.get<string>('SMTP_PASS'),
}
: undefined,
},
defaults: {
from: config.get<string>('SMTP_FROM') ?? 'LDAP Portal <no-reply@example.com>',
},
}),
}),
],
providers: [PortalMailService],
exports: [PortalMailService],
})
export class MailModule {}

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer';
@Injectable()
export class PortalMailService {
constructor(
private readonly mailer: MailerService,
private readonly config: ConfigService,
) {}
async sendVerificationMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/verify-email?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
to,
subject: 'LDAP Portal: E-Mail bestaetigen',
html: `<p>Bitte bestaetige deine Registrierung:</p><p><a href="${url}">${url}</a></p>`,
text: `Bitte bestaetige deine Registrierung: ${url}`,
});
}
async sendPasswordResetMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
to,
subject: 'LDAP Portal: Passwort zuruecksetzen',
html: `<p>Du kannst dein Passwort ueber diesen Link zuruecksetzen:</p><p><a href="${url}">${url}</a></p>`,
text: `Du kannst dein Passwort ueber diesen Link zuruecksetzen: ${url}`,
});
}
async sendEmailChangeMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/account/email?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
to,
subject: 'LDAP Portal: neue E-Mail bestaetigen',
html: `<p>Bitte bestaetige deine neue E-Mail-Adresse:</p><p><a href="${url}">${url}</a></p>`,
text: `Bitte bestaetige deine neue E-Mail-Adresse: ${url}`,
});
}
private get publicWebUrl(): string {
return this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
}
}

44
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,44 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import express, { NextFunction, Request, Response } from 'express';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false });
app.enableCors({
origin: process.env.PUBLIC_WEB_URL ?? 'http://localhost:4200',
credentials: true,
});
const jsonParser = express.json();
const formParser = express.urlencoded({ extended: false });
app.use((request: Request, response: Response, next: NextFunction) => {
if (request.path.startsWith('/oidc') || request.path.startsWith('/.well-known')) {
next();
return;
}
jsonParser(request, response, (jsonError) => {
if (jsonError) {
next(jsonError);
return;
}
formParser(request, response, next);
});
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const port = Number(process.env.API_PORT ?? 3000);
await app.listen(port);
}
void bootstrap();

View File

@@ -0,0 +1,32 @@
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
export class CreateOidcClientDto {
@IsString()
@Length(3, 120)
clientName!: string;
@IsArray()
@IsString({ each: true })
redirectUris!: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
postLogoutRedirectUris?: string[];
@IsString()
@IsOptional()
scope?: string;
@IsBoolean()
@IsOptional()
publicClient?: boolean;
@IsBoolean()
@IsOptional()
firstParty?: boolean;
@IsBoolean()
@IsOptional()
includeGroups?: boolean;
}

View File

@@ -0,0 +1,34 @@
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
export class UpdateOidcClientDto {
@IsString()
@Length(3, 120)
@IsOptional()
clientName?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
redirectUris?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
postLogoutRedirectUris?: string[];
@IsString()
@IsOptional()
scope?: string;
@IsBoolean()
@IsOptional()
firstParty?: boolean;
@IsBoolean()
@IsOptional()
includeGroups?: boolean;
@IsBoolean()
@IsOptional()
enabled?: boolean;
}

View File

@@ -0,0 +1,50 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Unique, UpdateDateColumn } from 'typeorm';
@Entity({ name: 'oidc_clients' })
@Unique(['clientId'])
export class OidcClientEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
clientId!: string;
@Column()
clientName!: string;
@Column({ nullable: true })
encryptedClientSecret?: string;
@Column({ default: 'client_secret_basic' })
tokenEndpointAuthMethod!: string;
@Column({ type: 'simple-json' })
redirectUris!: string[];
@Column({ type: 'simple-json' })
postLogoutRedirectUris!: string[];
@Column({ type: 'simple-json' })
grantTypes!: string[];
@Column({ type: 'simple-json' })
responseTypes!: string[];
@Column({ default: 'openid profile email groups' })
scope!: string;
@Column({ default: false })
firstParty!: boolean;
@Column({ default: true })
enabled!: boolean;
@Column({ default: true })
includeGroups!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}

View File

@@ -0,0 +1,31 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity({ name: 'oidc_provider_storage' })
@Index(['model', 'uid'])
@Index(['model', 'userCode'])
@Index(['grantId'])
export class OidcProviderStorageEntity {
@PrimaryColumn()
key!: string;
@Column()
model!: string;
@Column()
id!: string;
@Column({ type: 'simple-json' })
payload!: Record<string, unknown>;
@Column({ nullable: true })
uid?: string;
@Column({ nullable: true })
userCode?: string;
@Column({ nullable: true })
grantId?: string;
@Column({ nullable: true })
expiresAt?: Date;
}

View File

@@ -0,0 +1,19 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'oidc_signing_keys' })
export class OidcSigningKeyEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
kid!: string;
@Column({ default: true })
active!: boolean;
@Column({ type: 'simple-json' })
jwk!: Record<string, unknown>;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -0,0 +1,10 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'oidc_subjects' })
export class OidcSubjectEntity {
@PrimaryColumn()
subject!: string;
@Column()
username!: string;
}

View File

@@ -0,0 +1,32 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateOidcClientDto } from './dto/create-oidc-client.dto';
import { UpdateOidcClientDto } from './dto/update-oidc-client.dto';
import { OidcAdminGuard } from './oidc-admin.guard';
import { OidcClientService } from './oidc-client.service';
@Controller('admin/oidc/clients')
@UseGuards(JwtAuthGuard, OidcAdminGuard)
export class OidcAdminClientsController {
constructor(private readonly clients: OidcClientService) {}
@Get()
list() {
return this.clients.list();
}
@Post()
create(@Body() dto: CreateOidcClientDto) {
return this.clients.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateOidcClientDto) {
return this.clients.update(id, dto);
}
@Delete(':id')
delete(@Param('id') id: string) {
return this.clients.delete(id);
}
}

View File

@@ -0,0 +1,39 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { RequestUser } from '../common/request-user';
import { LldapService } from '../lldap/lldap.service';
@Injectable()
export class OidcAdminGuard implements CanActivate {
constructor(
private readonly config: ConfigService,
private readonly lldap: LldapService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user?: RequestUser }>();
const username = request.user?.username;
if (!username) {
throw new ForbiddenException('Nicht angemeldet.');
}
const adminGroup = this.config.get<string>('OIDC_ADMIN_GROUP') ?? 'client_manager';
const adminGroupUuid =
this.config.get<string>('OIDC_ADMIN_GROUP_UUID') ?? '89aa3d8d-fcbd-3ec9-b99d-901a0cfc405e';
const account = await this.lldap.getAccount(username);
const allowed = account.groups.some(
(group) =>
group.displayName === adminGroup ||
String(group.id) === adminGroup ||
group.uuid === adminGroup ||
group.uuid === adminGroupUuid,
);
if (!allowed) {
throw new ForbiddenException('Keine OIDC-Admin-Berechtigung.');
}
return true;
}
}

View File

@@ -0,0 +1,139 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'node:crypto';
import { Repository } from 'typeorm';
import { decryptSecret, encryptSecret, randomToken } from '../common/token.util';
import { CreateOidcClientDto } from './dto/create-oidc-client.dto';
import { UpdateOidcClientDto } from './dto/update-oidc-client.dto';
import { OidcClientEntity } from './entities/oidc-client.entity';
export interface OidcClientSummary {
id: string;
clientId: string;
clientName: string;
tokenEndpointAuthMethod: string;
redirectUris: string[];
postLogoutRedirectUris: string[];
grantTypes: string[];
responseTypes: string[];
scope: string;
firstParty: boolean;
enabled: boolean;
includeGroups: boolean;
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class OidcClientService {
constructor(
@InjectRepository(OidcClientEntity)
private readonly clients: Repository<OidcClientEntity>,
private readonly config: ConfigService,
) {}
async list(): Promise<OidcClientSummary[]> {
const clients = await this.clients.find({ order: { createdAt: 'DESC' } });
return clients.map((client) => this.toSummary(client));
}
async create(dto: CreateOidcClientDto) {
const publicClient = dto.publicClient ?? false;
const clientSecret = publicClient ? undefined : randomToken();
const client = await this.clients.save(
this.clients.create({
clientId: `client_${randomUUID().replaceAll('-', '')}`,
clientName: dto.clientName,
encryptedClientSecret: clientSecret ? encryptSecret(clientSecret, this.tokenSecret) : undefined,
tokenEndpointAuthMethod: publicClient ? 'none' : 'client_secret_basic',
redirectUris: dto.redirectUris,
postLogoutRedirectUris: dto.postLogoutRedirectUris ?? [],
grantTypes: publicClient ? ['authorization_code'] : ['authorization_code', 'refresh_token'],
responseTypes: ['code'],
scope: dto.scope ?? 'openid profile email groups',
firstParty: dto.firstParty ?? false,
includeGroups: dto.includeGroups ?? true,
enabled: true,
}),
);
return {
...this.toSummary(client),
clientSecret,
};
}
async update(id: string, dto: UpdateOidcClientDto): Promise<OidcClientSummary> {
const client = await this.clients.findOneBy({ id });
if (!client) {
throw new NotFoundException('OIDC client not found');
}
Object.assign(client, {
clientName: dto.clientName ?? client.clientName,
redirectUris: dto.redirectUris ?? client.redirectUris,
postLogoutRedirectUris: dto.postLogoutRedirectUris ?? client.postLogoutRedirectUris,
scope: dto.scope ?? client.scope,
firstParty: dto.firstParty ?? client.firstParty,
includeGroups: dto.includeGroups ?? client.includeGroups,
enabled: dto.enabled ?? client.enabled,
});
return this.toSummary(await this.clients.save(client));
}
async delete(id: string): Promise<void> {
const result = await this.clients.delete(id);
if (!result.affected) {
throw new NotFoundException('OIDC client not found');
}
}
async findByClientId(clientId: string): Promise<OidcClientEntity | null> {
return this.clients.findOneBy({ clientId, enabled: true });
}
async toProviderMetadata(client: OidcClientEntity): Promise<Record<string, unknown>> {
const metadata: Record<string, unknown> = {
client_id: client.clientId,
client_name: client.clientName,
redirect_uris: client.redirectUris,
post_logout_redirect_uris: client.postLogoutRedirectUris,
grant_types: client.grantTypes,
response_types: client.responseTypes,
scope: client.scope,
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
};
if (client.encryptedClientSecret) {
metadata.client_secret = decryptSecret(client.encryptedClientSecret, this.tokenSecret);
metadata.client_secret_expires_at = 0;
}
return metadata;
}
private toSummary(client: OidcClientEntity): OidcClientSummary {
return {
id: client.id,
clientId: client.clientId,
clientName: client.clientName,
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
redirectUris: client.redirectUris,
postLogoutRedirectUris: client.postLogoutRedirectUris,
grantTypes: client.grantTypes,
responseTypes: client.responseTypes,
scope: client.scope,
firstParty: client.firstParty,
enabled: client.enabled,
includeGroups: client.includeGroups,
createdAt: client.createdAt,
updatedAt: client.updatedAt,
};
}
private get tokenSecret(): string {
return this.config.getOrThrow<string>('TOKEN_SECRET');
}
}

View File

@@ -0,0 +1,109 @@
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { OidcProviderService } from './oidc-provider.service';
@Controller('interaction')
export class OidcInteractionController {
constructor(private readonly oidc: OidcProviderService) {}
@Get(':uid')
async view(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) {
const details = await this.oidc.interactionDetails(request, response);
if (details.uid !== uid) {
response.status(400).send(this.page('Ungueltige Anfrage', '<p>Die OIDC-Interaktion ist ungueltig.</p>'));
return;
}
if (details.prompt.name === 'login') {
response.send(
this.page(
'Anmelden',
`
<form method="post" action="/interaction/${encodeURIComponent(uid)}/login">
<label>Benutzername <input name="username" autocomplete="username" required></label>
<label>Passwort <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Anmelden</button>
</form>
<form method="post" action="/interaction/${encodeURIComponent(uid)}/abort">
<button class="secondary" type="submit">Abbrechen</button>
</form>
`,
),
);
return;
}
if (details.prompt.name === 'consent') {
response.send(
this.page(
'Zugriff erlauben',
`
<p>Client <strong>${this.escape(String(details.params.client_id ?? ''))}</strong> moechte Zugriff auf folgende Scopes:</p>
<p class="scopes">${this.escape(String(details.params.scope ?? 'openid'))}</p>
<form method="post" action="/interaction/${encodeURIComponent(uid)}/confirm">
<button type="submit">Erlauben</button>
</form>
<form method="post" action="/interaction/${encodeURIComponent(uid)}/abort">
<button class="secondary" type="submit">Ablehnen</button>
</form>
`,
),
);
return;
}
response.status(400).send(this.page('OIDC', '<p>Diese Interaktion wird noch nicht unterstuetzt.</p>'));
}
@Post(':uid/login')
async login(
@Param('uid') uid: string,
@Body() body: { username?: string; password?: string },
@Req() request: Request,
@Res() response: Response,
) {
await this.oidc.finishLogin(request, response, uid, body.username ?? '', body.password ?? '');
}
@Post(':uid/confirm')
async confirm(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) {
await this.oidc.finishConsent(request, response, uid);
}
@Post(':uid/abort')
async abort(@Req() request: Request, @Res() response: Response) {
await this.oidc.abortInteraction(request, response);
}
private page(title: string, body: string): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${this.escape(title)} - LDAP Portal</title>
<style>
body { background: #f5f7f9; color: #18202a; font-family: Inter, system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 20px; }
main { background: white; border: 1px solid #d8e0e7; border-radius: 8px; box-shadow: 0 16px 40px rgb(24 32 42 / 8%); max-width: 420px; padding: 28px; width: 100%; }
h1 { font-size: 1.45rem; margin: 0 0 22px; }
form { display: grid; gap: 16px; margin-top: 16px; }
label { display: grid; gap: 7px; font-weight: 700; }
input { border: 1px solid #bcc8d3; border-radius: 6px; font: inherit; min-height: 44px; padding: 10px 12px; }
button { background: #0f6b6e; border: 1px solid #0f6b6e; border-radius: 6px; color: white; cursor: pointer; font: inherit; font-weight: 700; min-height: 44px; padding: 10px 14px; }
button.secondary { background: white; color: #0f6b6e; }
.scopes { background: #edf2f5; border-radius: 6px; padding: 10px; word-break: break-word; }
</style>
</head>
<body><main><h1>${this.escape(title)}</h1>${body}</main></body>
</html>`;
}
private escape(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
}

View File

@@ -0,0 +1,280 @@
import { Injectable, InternalServerErrorException, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpAdapterHost } from '@nestjs/core';
import { InjectRepository } from '@nestjs/typeorm';
import { Request, Response } from 'express';
import type Provider from 'oidc-provider';
import type { AccountClaims, Adapter, Configuration, Interaction } from 'oidc-provider';
import { Repository } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { LdapAuthService } from '../lldap/ldap-auth.service';
import { LldapService } from '../lldap/lldap.service';
import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity';
import { OidcSigningKeyEntity } from './entities/oidc-signing-key.entity';
import { OidcSubjectEntity } from './entities/oidc-subject.entity';
import { OidcClientService } from './oidc-client.service';
import { TypeormOidcAdapter } from './typeorm-oidc.adapter';
type OidcModuleImport = typeof import('oidc-provider');
type JoseImport = typeof import('jose');
@Injectable()
export class OidcProviderService implements OnModuleInit {
private provider?: Provider;
constructor(
@InjectRepository(OidcProviderStorageEntity)
private readonly storage: Repository<OidcProviderStorageEntity>,
@InjectRepository(OidcSigningKeyEntity)
private readonly signingKeys: Repository<OidcSigningKeyEntity>,
@InjectRepository(OidcSubjectEntity)
private readonly subjects: Repository<OidcSubjectEntity>,
private readonly clients: OidcClientService,
private readonly config: ConfigService,
private readonly httpAdapterHost: HttpAdapterHost,
private readonly ldapAuth: LdapAuthService,
private readonly lldap: LldapService,
private readonly audit: AuditService,
) {}
async onModuleInit(): Promise<void> {
const oidc = await this.importOidcProvider();
const jwks = await this.loadOrCreateJwks();
const issuer = this.config.get<string>('OIDC_ISSUER') ?? `http://localhost:${this.config.get('API_PORT') ?? 3000}`;
this.provider = new oidc.default(issuer, this.buildConfiguration(jwks));
this.provider.proxy = this.config.get('OIDC_TRUST_PROXY') === 'true';
this.registerAuditEvents(this.provider);
const expressApp = this.httpAdapterHost.httpAdapter.getInstance();
expressApp.use(this.provider.callback());
}
async interactionDetails(request: Request, response: Response): Promise<Interaction> {
return this.getProvider().interactionDetails(request, response);
}
async finishLogin(
request: Request,
response: Response,
uid: string,
username: string,
password: string,
): Promise<void> {
const details = await this.interactionDetails(request, response);
if (details.uid !== uid || details.prompt.name !== 'login') {
throw new UnauthorizedException('Ungueltige OIDC-Interaktion.');
}
const valid = await this.ldapAuth.verifyPassword(username, password);
if (!valid) {
await this.audit.record({ type: 'oidc.login_failed', username, ipAddress: request.ip, userAgent: request.headers['user-agent'] });
throw new UnauthorizedException('Ungueltige Zugangsdaten.');
}
const account = await this.lldap.getAccount(username);
const subject = account.uuid || account.id;
await this.subjects.save(this.subjects.create({ subject, username: account.id }));
await this.audit.record({ type: 'oidc.login_success', username: account.id, ipAddress: request.ip, userAgent: request.headers['user-agent'] });
await this.getProvider().interactionFinished(
request,
response,
{
login: {
accountId: subject,
acr: 'urn:ldap-portal:password',
amr: ['pwd'],
remember: true,
ts: Math.floor(Date.now() / 1000),
},
},
{ mergeWithLastSubmission: false },
);
}
async finishConsent(request: Request, response: Response, uid: string): Promise<void> {
const details = await this.interactionDetails(request, response);
if (details.uid !== uid || details.prompt.name !== 'consent') {
throw new UnauthorizedException('Ungueltige OIDC-Interaktion.');
}
const clientId = String(details.params.client_id ?? '');
const accountId = details.session?.accountId;
if (!clientId || !accountId) {
throw new InternalServerErrorException('OIDC consent context is incomplete');
}
const Grant = (this.getProvider() as unknown as { Grant: any }).Grant;
const grant = details.grantId
? await Grant.find(details.grantId)
: new Grant({ accountId, clientId });
grant.addOIDCScope(String(details.params.scope ?? 'openid'));
if (details.prompt.details?.missingOIDCClaims) {
grant.addOIDCClaims(details.prompt.details.missingOIDCClaims);
}
const grantId = await grant.save();
await this.audit.record({ type: 'oidc.consent_granted', username: accountId, metadata: { clientId } });
await this.getProvider().interactionFinished(
request,
response,
{ consent: { grantId } },
{ mergeWithLastSubmission: true },
);
}
async abortInteraction(request: Request, response: Response): Promise<void> {
await this.getProvider().interactionFinished(
request,
response,
{
error: 'access_denied',
error_description: 'End-User aborted interaction',
},
{ mergeWithLastSubmission: false },
);
}
private buildConfiguration(jwks: { keys: Record<string, unknown>[] }): Configuration {
return {
adapter: (name: string): Adapter => new TypeormOidcAdapter(name, this.storage, this.clients),
jwks,
clientDefaults: {
grant_types: ['authorization_code'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
},
claims: {
openid: ['sub'],
profile: ['name', 'preferred_username', 'given_name', 'family_name', 'updated_at'],
email: ['email', 'email_verified'],
groups: ['groups'],
},
scopes: ['openid', 'profile', 'email', 'groups', 'offline_access'],
routes: {
authorization: '/oidc/auth',
token: '/oidc/token',
userinfo: '/oidc/me',
jwks: '/oidc/jwks',
end_session: '/oidc/session/end',
revocation: '/oidc/token/revocation',
introspection: '/oidc/token/introspection',
},
interactions: {
url: (_ctx, interaction) => `/interaction/${interaction.uid}`,
},
features: {
devInteractions: { enabled: false },
revocation: { enabled: true },
introspection: { enabled: true },
rpInitiatedLogout: { enabled: true },
},
cookies: {
keys: [this.config.get<string>('OIDC_COOKIE_SECRET') ?? this.config.getOrThrow<string>('TOKEN_SECRET')],
short: {
httpOnly: true,
sameSite: 'lax',
secure: this.config.get('NODE_ENV') === 'production',
},
long: {
httpOnly: true,
sameSite: 'lax',
secure: this.config.get('NODE_ENV') === 'production',
},
},
pkce: {
required: () => true,
},
ttl: {
AccessToken: 10 * 60,
AuthorizationCode: 10 * 60,
IdToken: 10 * 60,
RefreshToken: 14 * 24 * 60 * 60,
Session: 8 * 60 * 60,
},
findAccount: async (_ctx, sub) => {
const subject = await this.subjects.findOneBy({ subject: sub });
if (!subject) {
return undefined;
}
return {
accountId: sub,
claims: async () => this.claimsFor(subject.username, sub),
};
},
};
}
private async claimsFor(username: string, sub: string): Promise<AccountClaims> {
const account = await this.lldap.getAccount(username);
const claims: AccountClaims = {
sub,
preferred_username: account.id,
name: account.displayName || account.id,
email: account.email,
email_verified: Boolean(account.email),
given_name: account.firstName,
family_name: account.lastName,
updated_at: Math.floor(new Date(account.creationDate).getTime() / 1000),
};
const client = await this.currentClient();
if (!client || client.includeGroups) {
claims.groups = account.groups.map((group) => group.displayName);
}
return claims;
}
private async currentClient() {
const oidcModule = await this.importOidcProvider();
const ctx = oidcModule.Provider.ctx;
const clientId = ctx?.oidc?.client?.clientId;
return clientId ? this.clients.findByClientId(clientId) : null;
}
private async loadOrCreateJwks(): Promise<{ keys: Record<string, unknown>[] }> {
const active = await this.signingKeys.find({ where: { active: true }, order: { createdAt: 'DESC' } });
if (active.length) {
return { keys: active.map((key) => key.jwk) };
}
const jose = await this.importJose();
const { privateKey } = await jose.generateKeyPair('ES256', { extractable: true });
const jwk = (await jose.exportJWK(privateKey)) as Record<string, unknown>;
jwk.kid = `sig-${Date.now()}`;
jwk.alg = 'ES256';
jwk.use = 'sig';
await this.signingKeys.save(this.signingKeys.create({ kid: String(jwk.kid), active: true, jwk }));
return { keys: [jwk] };
}
private registerAuditEvents(provider: Provider): void {
provider.on('authorization_code.saved', (code) => {
void this.audit.record({ type: 'oidc.authorization_code_saved', username: code.accountId, metadata: { clientId: code.clientId } });
});
provider.on('access_token.issued', (token) => {
void this.audit.record({ type: 'oidc.access_token_issued', username: token.accountId, metadata: { clientId: token.clientId } });
});
}
private getProvider(): Provider {
if (!this.provider) {
throw new InternalServerErrorException('OIDC provider is not initialized');
}
return this.provider;
}
private async importOidcProvider(): Promise<OidcModuleImport> {
return new Function('specifier', 'return import(specifier)')('oidc-provider') as Promise<OidcModuleImport>;
}
private async importJose(): Promise<JoseImport> {
return new Function('specifier', 'return import(specifier)')('jose') as Promise<JoseImport>;
}
}

View File

@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { LldapModule } from '../lldap/lldap.module';
import { OidcClientEntity } from './entities/oidc-client.entity';
import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity';
import { OidcSigningKeyEntity } from './entities/oidc-signing-key.entity';
import { OidcSubjectEntity } from './entities/oidc-subject.entity';
import { OidcAdminClientsController } from './oidc-admin-clients.controller';
import { OidcAdminGuard } from './oidc-admin.guard';
import { OidcClientService } from './oidc-client.service';
import { OidcInteractionController } from './oidc-interaction.controller';
import { OidcProviderService } from './oidc-provider.service';
@Module({
imports: [
TypeOrmModule.forFeature([
OidcClientEntity,
OidcProviderStorageEntity,
OidcSigningKeyEntity,
OidcSubjectEntity,
]),
AuthModule,
LldapModule,
AuditModule,
],
controllers: [OidcAdminClientsController, OidcInteractionController],
providers: [OidcAdminGuard, OidcClientService, OidcProviderService],
})
export class OidcModule {}

View File

@@ -0,0 +1,104 @@
import type { Adapter, AdapterPayload } from 'oidc-provider';
import { IsNull, MoreThan, Repository } from 'typeorm';
import { OidcClientService } from './oidc-client.service';
import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity';
export class TypeormOidcAdapter implements Adapter {
constructor(
private readonly model: string,
private readonly storage: Repository<OidcProviderStorageEntity>,
private readonly clients: OidcClientService,
) {}
async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
if (this.model === 'Client') {
return;
}
const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000) : undefined;
await this.storage.save(
this.storage.create({
key: this.key(id),
model: this.model,
id,
payload: payload as Record<string, unknown>,
uid: typeof payload.uid === 'string' ? payload.uid : undefined,
userCode: typeof payload.userCode === 'string' ? payload.userCode : undefined,
grantId: typeof payload.grantId === 'string' ? payload.grantId : undefined,
expiresAt,
}),
);
}
async find(id: string): Promise<AdapterPayload | undefined> {
if (this.model === 'Client') {
const client = await this.clients.findByClientId(id);
return client ? ((await this.clients.toProviderMetadata(client)) as AdapterPayload) : undefined;
}
const entity = await this.storage.findOneBy({ key: this.key(id) });
return this.payloadOrDestroy(entity);
}
async findByUserCode(userCode: string): Promise<AdapterPayload | undefined> {
const entity = await this.storage.findOne({
where: [
{ model: this.model, userCode, expiresAt: MoreThan(new Date()) },
{ model: this.model, userCode, expiresAt: IsNull() },
],
});
return this.payloadOrDestroy(entity);
}
async findByUid(uid: string): Promise<AdapterPayload | undefined> {
const entity = await this.storage.findOne({
where: [
{ model: this.model, uid, expiresAt: MoreThan(new Date()) },
{ model: this.model, uid, expiresAt: IsNull() },
],
});
return this.payloadOrDestroy(entity);
}
async consume(id: string): Promise<void> {
const entity = await this.storage.findOneBy({ key: this.key(id) });
if (!entity) {
return;
}
entity.payload = {
...entity.payload,
consumed: Math.floor(Date.now() / 1000),
};
await this.storage.save(entity);
}
async destroy(id: string): Promise<void> {
await this.storage.delete({ key: this.key(id) });
}
async revokeByGrantId(grantId: string): Promise<void> {
const rows = await this.storage.findBy({ grantId });
await this.storage.remove(rows);
await this.storage.delete({ key: `Grant:${grantId}` });
}
private async payloadOrDestroy(
entity?: OidcProviderStorageEntity | null,
): Promise<AdapterPayload | undefined> {
if (!entity) {
return undefined;
}
if (entity.expiresAt && entity.expiresAt.getTime() <= Date.now()) {
await this.storage.delete({ key: entity.key });
return undefined;
}
return entity.payload as AdapterPayload;
}
private key(id: string): string {
return `${this.model}:${id}`;
}
}

View File

@@ -0,0 +1,11 @@
import { IsString, Length } from 'class-validator';
export class ChangePasswordDto {
@IsString()
@Length(1, 256)
currentPassword!: string;
@IsString()
@Length(12, 256)
newPassword!: string;
}

View File

@@ -0,0 +1,11 @@
import { IsString, Length } from 'class-validator';
export class ConfirmResetDto {
@IsString()
@Length(20, 256)
token!: string;
@IsString()
@Length(12, 256)
newPassword!: string;
}

View File

@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class RequestResetDto {
@IsEmail()
email!: string;
}

View File

@@ -0,0 +1,25 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'password_reset_tokens' })
export class PasswordResetToken {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
username!: string;
@Column()
email!: string;
@Column({ unique: true })
tokenHash!: string;
@Column()
expiresAt!: Date;
@Column({ nullable: true })
consumedAt?: Date;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -0,0 +1,38 @@
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequestUser } from '../common/request-user';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ConfirmResetDto } from './dto/confirm-reset.dto';
import { RequestResetDto } from './dto/request-reset.dto';
import { PasswordService } from './password.service';
@Controller('password')
export class PasswordController {
constructor(private readonly password: PasswordService) {}
@UseGuards(JwtAuthGuard)
@Post('change')
change(
@Body() dto: ChangePasswordDto,
@Req() request: Request & { user: RequestUser },
) {
return this.password.changePassword(
request.user.username,
dto.currentPassword,
dto.newPassword,
request.ip,
request.headers['user-agent'],
);
}
@Post('reset/request')
requestReset(@Body() dto: RequestResetDto, @Req() request: Request) {
return this.password.requestReset(dto.email, request.ip, request.headers['user-agent']);
}
@Post('reset/confirm')
confirmReset(@Body() dto: ConfirmResetDto, @Req() request: Request) {
return this.password.confirmReset(dto.token, dto.newPassword, request.ip, request.headers['user-agent']);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { LldapModule } from '../lldap/lldap.module';
import { MailModule } from '../mail/mail.module';
import { PasswordResetToken } from './password-reset-token.entity';
import { PasswordController } from './password.controller';
import { PasswordService } from './password.service';
@Module({
imports: [TypeOrmModule.forFeature([PasswordResetToken]), AuthModule, LldapModule, MailModule, AuditModule],
controllers: [PasswordController],
providers: [PasswordService],
})
export class PasswordModule {}

View File

@@ -0,0 +1,108 @@
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { assertPasswordPolicy } from '../common/password-policy';
import { hashToken, randomToken } from '../common/token.util';
import { LdapAuthService } from '../lldap/ldap-auth.service';
import { LldapService } from '../lldap/lldap.service';
import { PortalMailService } from '../mail/portal-mail.service';
import { PasswordResetToken } from './password-reset-token.entity';
@Injectable()
export class PasswordService {
constructor(
@InjectRepository(PasswordResetToken)
private readonly resetTokens: Repository<PasswordResetToken>,
private readonly config: ConfigService,
private readonly ldapAuth: LdapAuthService,
private readonly lldap: LldapService,
private readonly mail: PortalMailService,
private readonly audit: AuditService,
) {}
async changePassword(
username: string,
currentPassword: string,
newPassword: string,
ipAddress?: string,
userAgent?: string,
) {
assertPasswordPolicy(newPassword);
const valid = await this.ldapAuth.verifyPassword(username, currentPassword);
if (!valid) {
await this.audit.record({ type: 'password.change_failed', username, ipAddress, userAgent });
throw new UnauthorizedException('Das aktuelle Passwort ist nicht korrekt.');
}
await this.lldap.setPassword(username, newPassword);
await this.audit.record({ type: 'password.changed', username, ipAddress, userAgent });
return { message: 'Das Passwort wurde geaendert.' };
}
async requestReset(email: string, ipAddress?: string, userAgent?: string) {
const normalizedEmail = email.toLowerCase();
const neutral = {
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link versendet.',
};
const user = await this.lldap.findUserByEmail(normalizedEmail).catch(() => null);
if (!user?.email) {
await this.audit.record({
type: 'password.reset_requested_unknown',
ipAddress,
userAgent,
metadata: { email: normalizedEmail },
});
return neutral;
}
const token = randomToken();
await this.resetTokens.save(
this.resetTokens.create({
username: user.id,
email: user.email,
tokenHash: hashToken(token, this.tokenSecret),
expiresAt: new Date(Date.now() + 60 * 60_000),
}),
);
await this.mail.sendPasswordResetMail(user.email, token);
await this.audit.record({
type: 'password.reset_requested',
username: user.id,
ipAddress,
userAgent,
});
return neutral;
}
async confirmReset(token: string, newPassword: string, ipAddress?: string, userAgent?: string) {
assertPasswordPolicy(newPassword);
const tokenHash = hashToken(token, this.tokenSecret);
const record = await this.resetTokens.findOne({ where: { tokenHash, consumedAt: IsNull() } });
if (!record || record.expiresAt.getTime() < Date.now()) {
throw new BadRequestException('Der Reset-Link ist ungueltig oder abgelaufen.');
}
await this.lldap.setPassword(record.username, newPassword);
record.consumedAt = new Date();
await this.resetTokens.save(record);
await this.audit.record({
type: 'password.reset_completed',
username: record.username,
ipAddress,
userAgent,
});
return { message: 'Das Passwort wurde zurueckgesetzt.' };
}
private get tokenSecret(): string {
return this.config.getOrThrow<string>('TOKEN_SECRET');
}
}

View File

@@ -0,0 +1,19 @@
import { IsEmail, IsString, Length, Matches } from 'class-validator';
export class RegisterDto {
@IsString()
@Length(3, 64)
@Matches(/^[a-zA-Z0-9._-]+$/)
username!: string;
@IsEmail()
email!: string;
@IsString()
@Length(1, 128)
displayName!: string;
@IsString()
@Length(12, 256)
password!: string;
}

View File

@@ -0,0 +1,8 @@
import { IsOptional, IsString, Length } from 'class-validator';
export class RejectRegistrationDto {
@IsString()
@Length(0, 1000)
@IsOptional()
reason?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, Length } from 'class-validator';
export class VerifyEmailDto {
@IsString()
@Length(20, 256)
token!: string;
}

View File

@@ -0,0 +1,22 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'email_tokens' })
export class EmailToken {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
registrationId!: string;
@Column({ unique: true })
tokenHash!: string;
@Column()
expiresAt!: Date;
@Column({ nullable: true })
consumedAt?: Date;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -0,0 +1,45 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
export type RegistrationStatus = 'pending_email' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
@Entity({ name: 'registration_requests' })
export class RegistrationRequest {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
username!: string;
@Column()
email!: string;
@Column()
displayName!: string;
@Column()
encryptedPassword!: string;
@Column({ default: 'pending_email' })
status!: RegistrationStatus;
@Column()
expiresAt!: Date;
@Column({ nullable: true })
verifiedAt?: Date;
@Column({ nullable: true })
reviewedAt?: Date;
@Column({ nullable: true })
reviewedBy?: string;
@Column({ nullable: true })
rejectionReason?: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}

View File

@@ -0,0 +1,20 @@
import { Body, Controller, Post, Req } from '@nestjs/common';
import { Request } from 'express';
import { RegisterDto } from './dto/register.dto';
import { VerifyEmailDto } from './dto/verify-email.dto';
import { RegistrationService } from './registration.service';
@Controller('registration')
export class RegistrationController {
constructor(private readonly registration: RegistrationService) {}
@Post()
register(@Body() dto: RegisterDto, @Req() request: Request) {
return this.registration.register(dto, request.ip, request.headers['user-agent']);
}
@Post('verify')
verify(@Body() dto: VerifyEmailDto, @Req() request: Request) {
return this.registration.verify(dto.token, request.ip, request.headers['user-agent']);
}
}

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { LldapModule } from '../lldap/lldap.module';
import { MailModule } from '../mail/mail.module';
import { EmailToken } from './email-token.entity';
import { RegistrationRequest } from './registration-request.entity';
import { RegistrationController } from './registration.controller';
import { RegistrationService } from './registration.service';
@Module({
imports: [
TypeOrmModule.forFeature([RegistrationRequest, EmailToken]),
LldapModule,
MailModule,
AuditModule,
],
controllers: [RegistrationController],
providers: [RegistrationService],
exports: [RegistrationService],
})
export class RegistrationModule {}

View File

@@ -0,0 +1,156 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { assertPasswordPolicy } from '../common/password-policy';
import { decryptSecret, encryptSecret, hashToken, randomToken } from '../common/token.util';
import { LldapService } from '../lldap/lldap.service';
import { PortalMailService } from '../mail/portal-mail.service';
import { RegisterDto } from './dto/register.dto';
import { EmailToken } from './email-token.entity';
import { RegistrationRequest } from './registration-request.entity';
@Injectable()
export class RegistrationService {
constructor(
@InjectRepository(RegistrationRequest)
private readonly registrations: Repository<RegistrationRequest>,
@InjectRepository(EmailToken)
private readonly emailTokens: Repository<EmailToken>,
private readonly config: ConfigService,
private readonly lldap: LldapService,
private readonly mail: PortalMailService,
private readonly audit: AuditService,
) {}
async register(dto: RegisterDto, ipAddress?: string, userAgent?: string) {
assertPasswordPolicy(dto.password);
const existingLdapUser = await this.lldap.findUserByUsername(dto.username).catch(() => null);
if (existingLdapUser) {
throw new ConflictException('Der Benutzername ist bereits vergeben.');
}
const pending = await this.registrations.findOne({
where: { username: dto.username, status: 'pending_email' },
});
if (pending) {
throw new ConflictException('Fuer diesen Benutzernamen existiert bereits eine offene Registrierung.');
}
const registration = await this.registrations.save(
this.registrations.create({
username: dto.username,
email: dto.email.toLowerCase(),
displayName: dto.displayName,
encryptedPassword: encryptSecret(dto.password, this.tokenSecret),
status: 'pending_email',
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
}),
);
const token = randomToken();
await this.emailTokens.save(
this.emailTokens.create({
registrationId: registration.id,
tokenHash: hashToken(token, this.tokenSecret),
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
}),
);
await this.mail.sendVerificationMail(registration.email, token);
await this.audit.record({
type: 'registration.started',
username: registration.username,
ipAddress,
userAgent,
});
return { message: 'Bitte pruefe dein E-Mail-Postfach, um die Registrierung abzuschliessen.' };
}
async verify(token: string, ipAddress?: string, userAgent?: string) {
const tokenHash = hashToken(token, this.tokenSecret);
const tokenRecord = await this.emailTokens.findOne({ where: { tokenHash, consumedAt: IsNull() } });
if (!tokenRecord || tokenRecord.expiresAt.getTime() < Date.now()) {
throw new BadRequestException('Der Bestaetigungslink ist ungueltig oder abgelaufen.');
}
const registration = await this.registrations.findOneByOrFail({ id: tokenRecord.registrationId });
if (registration.status !== 'pending_email' || registration.expiresAt.getTime() < Date.now()) {
throw new BadRequestException('Diese Registrierung ist nicht mehr gueltig.');
}
tokenRecord.consumedAt = new Date();
registration.status = 'pending_approval';
registration.verifiedAt = new Date();
await this.emailTokens.save(tokenRecord);
await this.registrations.save(registration);
await this.audit.record({
type: 'registration.email_verified',
username: registration.username,
ipAddress,
userAgent,
});
return { message: 'Die E-Mail wurde bestaetigt. Die Registrierung wartet jetzt auf Freigabe.' };
}
async list() {
return this.registrations.find({ order: { createdAt: 'DESC' } });
}
async approve(id: string, reviewer: string, ipAddress?: string, userAgent?: string) {
const registration = await this.registrations.findOneByOrFail({ id });
if (registration.status !== 'pending_approval') {
throw new BadRequestException('Diese Registrierung wartet nicht auf Freigabe.');
}
await this.lldap.createUser({
username: registration.username,
email: registration.email,
displayName: registration.displayName,
password: decryptSecret(registration.encryptedPassword, this.tokenSecret),
});
registration.status = 'approved';
registration.reviewedAt = new Date();
registration.reviewedBy = reviewer;
await this.registrations.save(registration);
await this.audit.record({
type: 'registration.approved',
username: registration.username,
ipAddress,
userAgent,
metadata: { reviewer },
});
return registration;
}
async reject(id: string, reviewer: string, reason?: string, ipAddress?: string, userAgent?: string) {
const registration = await this.registrations.findOneByOrFail({ id });
if (registration.status !== 'pending_approval') {
throw new BadRequestException('Diese Registrierung wartet nicht auf Freigabe.');
}
registration.status = 'rejected';
registration.reviewedAt = new Date();
registration.reviewedBy = reviewer;
registration.rejectionReason = reason;
await this.registrations.save(registration);
await this.audit.record({
type: 'registration.rejected',
username: registration.username,
ipAddress,
userAgent,
metadata: { reviewer, reason },
});
return registration;
}
private get tokenSecret(): string {
return this.config.getOrThrow<string>('TOKEN_SECRET');
}
}