This commit is contained in:
Bastian Wagner
2026-07-15 15:53:27 +02:00
parent a79988b35c
commit d51d915c74
8 changed files with 119 additions and 36 deletions

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Header, Param, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express'; import { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequestUser } from '../common/request-user'; import { RequestUser } from '../common/request-user';
@@ -12,6 +12,9 @@ export class AdminRegistrationsController {
constructor(private readonly registrations: RegistrationService) {} constructor(private readonly registrations: RegistrationService) {}
@Get() @Get()
@Header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
@Header('Pragma', 'no-cache')
@Header('Expires', '0')
list() { list() {
return this.registrations.list(); return this.registrations.list();
} }

View File

@@ -1,5 +1,6 @@
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Ber, BerWriter, Client } from 'ldapts';
interface LldapUserInput { interface LldapUserInput {
username: string; username: string;
@@ -70,6 +71,8 @@ export interface LldapAccountUser {
@Injectable() @Injectable()
export class LldapService { export class LldapService {
private static readonly passwordModifyOid = '1.3.6.1.4.1.4203.1.11.1';
private cachedHeaders?: { expiresAt: number; headers: Record<string, string> }; private cachedHeaders?: { expiresAt: number; headers: Record<string, string> };
constructor(private readonly config: ConfigService) {} constructor(private readonly config: ConfigService) {}
@@ -84,11 +87,17 @@ export class LldapService {
id: input.username, id: input.username,
email: input.email, email: input.email,
displayName: input.displayName, displayName: input.displayName,
password: input.password,
}, },
}, },
); );
try {
await this.setPassword(input.username, input.password);
} catch (error) {
await this.deleteUser(input.username).catch(() => undefined);
throw error;
}
const defaultGroup = this.config.get<string>('LLDAP_DEFAULT_GROUP'); const defaultGroup = this.config.get<string>('LLDAP_DEFAULT_GROUP');
if (defaultGroup) { if (defaultGroup) {
await this.addUserToGroup(input.username, defaultGroup); await this.addUserToGroup(input.username, defaultGroup);
@@ -96,12 +105,18 @@ export class LldapService {
} }
async setPassword(username: string, password: string): Promise<void> { async setPassword(username: string, password: string): Promise<void> {
await this.graphql( const client = new Client({ url: this.config.getOrThrow<string>('LLDAP_LDAP_URL') });
`mutation SetPassword($userId: String!, $password: String!) { try {
setPassword(userId: $userId, password: $password) await client.bind(this.adminDn(), this.config.getOrThrow<string>('LLDAP_ADMIN_PASSWORD'));
}`, await client.exop(
{ userId: username, password }, LldapService.passwordModifyOid,
this.passwordModifyRequestValue(this.userDn(username), password),
); );
} catch (error) {
throw new InternalServerErrorException(`LLDAP password change failed: ${this.errorMessage(error)}`);
} finally {
await client.unbind().catch(() => undefined);
}
} }
async updateUser(username: string, input: LldapUserUpdateInput): Promise<void> { async updateUser(username: string, input: LldapUserUpdateInput): Promise<void> {
@@ -438,4 +453,30 @@ export class LldapService {
this.cachedHeaders = { headers, expiresAt: Date.now() + 5 * 60_000 }; this.cachedHeaders = { headers, expiresAt: Date.now() + 5 * 60_000 };
return headers; return headers;
} }
private passwordModifyRequestValue(userDn: string, newPassword: string): Buffer {
const writer = new BerWriter();
writer.startSequence(Ber.Sequence | Ber.Constructor);
writer.writeString(userDn, 0x80);
writer.writeString(newPassword, 0x82);
writer.endSequence();
return writer.buffer;
}
private adminDn(): string {
return this.userDn(this.config.getOrThrow<string>('LLDAP_ADMIN_USERNAME'));
}
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}`);
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'unknown error';
}
} }

View File

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

View File

@@ -1,7 +1,7 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm'; import { In, IsNull, Repository } from 'typeorm';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { assertPasswordPolicy } from '../common/password-policy'; import { assertPasswordPolicy } from '../common/password-policy';
import { decryptSecret, encryptSecret, hashToken, randomToken } from '../common/token.util'; import { decryptSecret, encryptSecret, hashToken, randomToken } from '../common/token.util';
@@ -26,23 +26,24 @@ export class RegistrationService {
async register(dto: RegisterDto, ipAddress?: string, userAgent?: string) { async register(dto: RegisterDto, ipAddress?: string, userAgent?: string) {
assertPasswordPolicy(dto.password); assertPasswordPolicy(dto.password);
const email = dto.email.toLowerCase();
const existingLdapUser = await this.lldap.findUserByUsername(dto.username).catch(() => null); const existingLdapUser = await this.lldap.findUserByUsername(email).catch(() => null);
if (existingLdapUser) { if (existingLdapUser) {
throw new ConflictException('Der Benutzername ist bereits vergeben.'); throw new ConflictException('Diese E-Mail-Adresse ist bereits registriert.');
} }
const pending = await this.registrations.findOne({ const pending = await this.registrations.findOne({
where: { username: dto.username, status: 'pending_email' }, where: { username: email, status: 'pending_email' },
}); });
if (pending) { if (pending) {
throw new ConflictException('Fuer diesen Benutzernamen existiert bereits eine offene Registrierung.'); throw new ConflictException('Fuer diese E-Mail-Adresse existiert bereits eine offene Registrierung.');
} }
const registration = await this.registrations.save( const registration = await this.registrations.save(
this.registrations.create({ this.registrations.create({
username: dto.username, username: email,
email: dto.email.toLowerCase(), email,
displayName: dto.displayName, displayName: dto.displayName,
encryptedPassword: encryptSecret(dto.password, this.tokenSecret), encryptedPassword: encryptSecret(dto.password, this.tokenSecret),
status: 'pending_email', status: 'pending_email',
@@ -99,7 +100,10 @@ export class RegistrationService {
} }
async list() { async list() {
return this.registrations.find({ order: { createdAt: 'DESC' } }); return this.registrations.find({
where: { status: In(['pending_email', 'pending_approval']) },
order: { createdAt: 'DESC' },
});
} }
async approve(id: string, reviewer: string, ipAddress?: string, userAgent?: string) { async approve(id: string, reviewer: string, ipAddress?: string, userAgent?: string) {

View File

@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "ng serve --host 0.0.0.0 --port 4200", "start": "ng serve --host 0.0.0.0 --port 4200 --proxy-config proxy.conf.json",
"build": "ng build", "build": "ng build",
"lint": "eslint \"src/**/*.ts\"" "lint": "eslint \"src/**/*.ts\""
}, },

42
apps/web/proxy.conf.json Normal file
View File

@@ -0,0 +1,42 @@
{
"/.well-known": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
},
"/oidc": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
},
"/interaction": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
},
"/auth": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
},
"/account": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
},
"/admin": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
},
"/password": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
},
"/registration": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
}
}

View File

@@ -47,7 +47,12 @@ export class AdminRegistrationsComponent implements OnInit {
} }
private load(): void { private load(): void {
this.http.get<any[]>(`${this.apiBaseUrl}/admin/registrations`).subscribe({ next: (items) => this.registrations.set(items), error: (e) => this.error(e) }); this.http
.get<any[]>(`${this.apiBaseUrl}/admin/registrations`, {
headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' },
params: { _: Date.now() },
})
.subscribe({ next: (items) => this.registrations.set(items), error: (e) => this.error(e) });
} }
private error(error: unknown): void { private error(error: unknown): void {

View File

@@ -12,20 +12,14 @@ import { API_BASE_URL } from '../shared/api-base-url';
<section class="panel wide"> <section class="panel wide">
<h1>Registrieren</h1> <h1>Registrieren</h1>
<form [formGroup]="form" (ngSubmit)="submit()"> <form [formGroup]="form" (ngSubmit)="submit()">
<div class="grid">
<label> <label>
Benutzername E-Mail
<input formControlName="username" autocomplete="username"> <input type="email" formControlName="email" autocomplete="email">
</label> </label>
<label> <label>
Anzeigename Anzeigename
<input formControlName="displayName" autocomplete="name"> <input formControlName="displayName" autocomplete="name">
</label> </label>
</div>
<label>
E-Mail
<input type="email" formControlName="email" autocomplete="email">
</label>
<label> <label>
Passwort Passwort
<input type="password" formControlName="password" autocomplete="new-password"> <input type="password" formControlName="password" autocomplete="new-password">
@@ -55,9 +49,8 @@ export class RegisterComponent {
@Inject(API_BASE_URL) private readonly apiBaseUrl: string, @Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) { ) {
this.form = this.fb.nonNullable.group({ this.form = this.fb.nonNullable.group({
username: ['', [Validators.required, Validators.minLength(3), Validators.pattern(/^[a-zA-Z0-9._-]+$/)]],
displayName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]], email: ['', [Validators.required, Validators.email]],
displayName: ['', Validators.required],
password: ['', [Validators.required, Validators.minLength(12)]], password: ['', [Validators.required, Validators.minLength(12)]],
}); });
} }