initial
This commit is contained in:
24
apps/api/src/account/account-delete-request.entity.ts
Normal file
24
apps/api/src/account/account-delete-request.entity.ts
Normal 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;
|
||||
}
|
||||
125
apps/api/src/account/account.controller.ts
Normal file
125
apps/api/src/account/account.controller.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
22
apps/api/src/account/account.module.ts
Normal file
22
apps/api/src/account/account.module.ts
Normal 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 {}
|
||||
7
apps/api/src/account/dto/confirm-email-change.dto.ts
Normal file
7
apps/api/src/account/dto/confirm-email-change.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, Length } from 'class-validator';
|
||||
|
||||
export class ConfirmEmailChangeDto {
|
||||
@IsString()
|
||||
@Length(20, 256)
|
||||
token!: string;
|
||||
}
|
||||
8
apps/api/src/account/dto/delete-request.dto.ts
Normal file
8
apps/api/src/account/dto/delete-request.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class DeleteRequestDto {
|
||||
@IsString()
|
||||
@Length(0, 1000)
|
||||
@IsOptional()
|
||||
reason?: string;
|
||||
}
|
||||
6
apps/api/src/account/dto/request-email-change.dto.ts
Normal file
6
apps/api/src/account/dto/request-email-change.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class RequestEmailChangeDto {
|
||||
@IsEmail()
|
||||
newEmail!: string;
|
||||
}
|
||||
22
apps/api/src/account/dto/update-profile.dto.ts
Normal file
22
apps/api/src/account/dto/update-profile.dto.ts
Normal 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;
|
||||
}
|
||||
25
apps/api/src/account/email-change-request.entity.ts
Normal file
25
apps/api/src/account/email-change-request.entity.ts
Normal 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;
|
||||
}
|
||||
20
apps/api/src/admin/admin-audit.controller.ts
Normal file
20
apps/api/src/admin/admin-audit.controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
44
apps/api/src/admin/admin-groups.controller.ts
Normal file
44
apps/api/src/admin/admin-groups.controller.ts
Normal 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.' };
|
||||
}
|
||||
}
|
||||
32
apps/api/src/admin/admin-registrations.controller.ts
Normal file
32
apps/api/src/admin/admin-registrations.controller.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
28
apps/api/src/admin/admin-role.guard.ts
Normal file
28
apps/api/src/admin/admin-role.guard.ts
Normal 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);
|
||||
}
|
||||
59
apps/api/src/admin/admin-users.controller.ts
Normal file
59
apps/api/src/admin/admin-users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
apps/api/src/admin/admin.module.ts
Normal file
17
apps/api/src/admin/admin.module.ts
Normal 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 {}
|
||||
7
apps/api/src/admin/dto/create-group.dto.ts
Normal file
7
apps/api/src/admin/dto/create-group.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, Length } from 'class-validator';
|
||||
|
||||
export class CreateGroupDto {
|
||||
@IsString()
|
||||
@Length(1, 128)
|
||||
displayName!: string;
|
||||
}
|
||||
22
apps/api/src/admin/dto/update-admin-user.dto.ts
Normal file
22
apps/api/src/admin/dto/update-admin-user.dto.ts
Normal 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;
|
||||
}
|
||||
7
apps/api/src/admin/dto/update-group.dto.ts
Normal file
7
apps/api/src/admin/dto/update-group.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, Length } from 'class-validator';
|
||||
|
||||
export class UpdateGroupDto {
|
||||
@IsString()
|
||||
@Length(1, 128)
|
||||
displayName!: string;
|
||||
}
|
||||
65
apps/api/src/app.module.ts
Normal file
65
apps/api/src/app.module.ts
Normal 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 {}
|
||||
25
apps/api/src/audit/audit-event.entity.ts
Normal file
25
apps/api/src/audit/audit-event.entity.ts
Normal 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;
|
||||
}
|
||||
11
apps/api/src/audit/audit.module.ts
Normal file
11
apps/api/src/audit/audit.module.ts
Normal 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 {}
|
||||
29
apps/api/src/audit/audit.service.ts
Normal file
29
apps/api/src/audit/audit.service.ts
Normal 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 ?? {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
apps/api/src/auth/auth.controller.ts
Normal file
27
apps/api/src/auth/auth.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
26
apps/api/src/auth/auth.module.ts
Normal file
26
apps/api/src/auth/auth.module.ts
Normal 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 {}
|
||||
27
apps/api/src/auth/auth.service.ts
Normal file
27
apps/api/src/auth/auth.service.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
11
apps/api/src/auth/dto/login.dto.ts
Normal file
11
apps/api/src/auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
34
apps/api/src/auth/jwt-auth.guard.ts
Normal file
34
apps/api/src/auth/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
apps/api/src/common/password-policy.ts
Normal file
13
apps/api/src/common/password-policy.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
4
apps/api/src/common/request-user.ts
Normal file
4
apps/api/src/common/request-user.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface RequestUser {
|
||||
sub: string;
|
||||
username: string;
|
||||
}
|
||||
33
apps/api/src/common/token.util.ts
Normal file
33
apps/api/src/common/token.util.ts
Normal 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');
|
||||
}
|
||||
29
apps/api/src/lldap/ldap-auth.service.ts
Normal file
29
apps/api/src/lldap/ldap-auth.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/lldap/lldap.module.ts
Normal file
9
apps/api/src/lldap/lldap.module.ts
Normal 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 {}
|
||||
441
apps/api/src/lldap/lldap.service.ts
Normal file
441
apps/api/src/lldap/lldap.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
32
apps/api/src/mail/mail.module.ts
Normal file
32
apps/api/src/mail/mail.module.ts
Normal 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 {}
|
||||
45
apps/api/src/mail/portal-mail.service.ts
Normal file
45
apps/api/src/mail/portal-mail.service.ts
Normal 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
44
apps/api/src/main.ts
Normal 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();
|
||||
32
apps/api/src/oidc/dto/create-oidc-client.dto.ts
Normal file
32
apps/api/src/oidc/dto/create-oidc-client.dto.ts
Normal 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;
|
||||
}
|
||||
34
apps/api/src/oidc/dto/update-oidc-client.dto.ts
Normal file
34
apps/api/src/oidc/dto/update-oidc-client.dto.ts
Normal 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;
|
||||
}
|
||||
50
apps/api/src/oidc/entities/oidc-client.entity.ts
Normal file
50
apps/api/src/oidc/entities/oidc-client.entity.ts
Normal 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;
|
||||
}
|
||||
31
apps/api/src/oidc/entities/oidc-provider-storage.entity.ts
Normal file
31
apps/api/src/oidc/entities/oidc-provider-storage.entity.ts
Normal 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;
|
||||
}
|
||||
19
apps/api/src/oidc/entities/oidc-signing-key.entity.ts
Normal file
19
apps/api/src/oidc/entities/oidc-signing-key.entity.ts
Normal 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;
|
||||
}
|
||||
10
apps/api/src/oidc/entities/oidc-subject.entity.ts
Normal file
10
apps/api/src/oidc/entities/oidc-subject.entity.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'oidc_subjects' })
|
||||
export class OidcSubjectEntity {
|
||||
@PrimaryColumn()
|
||||
subject!: string;
|
||||
|
||||
@Column()
|
||||
username!: string;
|
||||
}
|
||||
32
apps/api/src/oidc/oidc-admin-clients.controller.ts
Normal file
32
apps/api/src/oidc/oidc-admin-clients.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
39
apps/api/src/oidc/oidc-admin.guard.ts
Normal file
39
apps/api/src/oidc/oidc-admin.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
139
apps/api/src/oidc/oidc-client.service.ts
Normal file
139
apps/api/src/oidc/oidc-client.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
109
apps/api/src/oidc/oidc-interaction.controller.ts
Normal file
109
apps/api/src/oidc/oidc-interaction.controller.ts
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
}
|
||||
280
apps/api/src/oidc/oidc-provider.service.ts
Normal file
280
apps/api/src/oidc/oidc-provider.service.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
31
apps/api/src/oidc/oidc.module.ts
Normal file
31
apps/api/src/oidc/oidc.module.ts
Normal 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 {}
|
||||
104
apps/api/src/oidc/typeorm-oidc.adapter.ts
Normal file
104
apps/api/src/oidc/typeorm-oidc.adapter.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
11
apps/api/src/password/dto/change-password.dto.ts
Normal file
11
apps/api/src/password/dto/change-password.dto.ts
Normal 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;
|
||||
}
|
||||
11
apps/api/src/password/dto/confirm-reset.dto.ts
Normal file
11
apps/api/src/password/dto/confirm-reset.dto.ts
Normal 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;
|
||||
}
|
||||
6
apps/api/src/password/dto/request-reset.dto.ts
Normal file
6
apps/api/src/password/dto/request-reset.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class RequestResetDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
25
apps/api/src/password/password-reset-token.entity.ts
Normal file
25
apps/api/src/password/password-reset-token.entity.ts
Normal 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;
|
||||
}
|
||||
38
apps/api/src/password/password.controller.ts
Normal file
38
apps/api/src/password/password.controller.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
16
apps/api/src/password/password.module.ts
Normal file
16
apps/api/src/password/password.module.ts
Normal 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 {}
|
||||
108
apps/api/src/password/password.service.ts
Normal file
108
apps/api/src/password/password.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
19
apps/api/src/registration/dto/register.dto.ts
Normal file
19
apps/api/src/registration/dto/register.dto.ts
Normal 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;
|
||||
}
|
||||
8
apps/api/src/registration/dto/reject-registration.dto.ts
Normal file
8
apps/api/src/registration/dto/reject-registration.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class RejectRegistrationDto {
|
||||
@IsString()
|
||||
@Length(0, 1000)
|
||||
@IsOptional()
|
||||
reason?: string;
|
||||
}
|
||||
7
apps/api/src/registration/dto/verify-email.dto.ts
Normal file
7
apps/api/src/registration/dto/verify-email.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, Length } from 'class-validator';
|
||||
|
||||
export class VerifyEmailDto {
|
||||
@IsString()
|
||||
@Length(20, 256)
|
||||
token!: string;
|
||||
}
|
||||
22
apps/api/src/registration/email-token.entity.ts
Normal file
22
apps/api/src/registration/email-token.entity.ts
Normal 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;
|
||||
}
|
||||
45
apps/api/src/registration/registration-request.entity.ts
Normal file
45
apps/api/src/registration/registration-request.entity.ts
Normal 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;
|
||||
}
|
||||
20
apps/api/src/registration/registration.controller.ts
Normal file
20
apps/api/src/registration/registration.controller.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
22
apps/api/src/registration/registration.module.ts
Normal file
22
apps/api/src/registration/registration.module.ts
Normal 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 {}
|
||||
156
apps/api/src/registration/registration.service.ts
Normal file
156
apps/api/src/registration/registration.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user