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

35
.env.example Normal file
View File

@@ -0,0 +1,35 @@
NODE_ENV=development
API_PORT=3000
WEB_PORT=4200
PUBLIC_WEB_URL=http://localhost:4200
DATABASE_URL=mysql://ldap_portal:change-me@mysql.example.com:3306/ldap_portal
DATABASE_SSL=false
JWT_SECRET=change-me-long-random-jwt-secret
TOKEN_SECRET=change-me-32-byte-minimum-token-secret
LLDAP_URL=https://lldap.example.com
LLDAP_LDAP_URL=ldap://lldap.example.com:3890
LLDAP_BASE_DN=dc=example,dc=com
LLDAP_ADMIN_USERNAME=admin
LLDAP_ADMIN_PASSWORD=change-me
LLDAP_DEFAULT_GROUP=
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=portal@example.com
SMTP_PASS=change-me
SMTP_FROM="LDAP Portal <portal@example.com>"
OIDC_ISSUER=http://localhost:3000
OIDC_COOKIE_SECRET=change-me-long-random-oidc-cookie-secret
OIDC_ADMIN_GROUP=client_manager
OIDC_ADMIN_GROUP_UUID=89aa3d8d-fcbd-3ec9-b99d-901a0cfc405e
OIDC_TRUST_PROXY=false
REGISTRATION_MANAGER_GROUP=registration_manager
USER_MANAGER_GROUP=user_manager
GROUP_MANAGER_GROUP=group_manager
AUDIT_VIEWER_GROUP=audit_viewer

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
.angular
.env
.env.local
coverage
npm-debug.log*
Dockerfile.tmp

79
README.md Normal file
View File

@@ -0,0 +1,79 @@
# LDAP Portal
Self-Service-Portal fuer LLDAP mit NestJS API und Angular Frontend.
## Funktionen
- Registrierung mit E-Mail-Verifikation
- Login gegen LDAP/LLDAP
- Passwortaenderung nach erfolgreichem LDAP-Login
- Passwort-Reset ueber eigene Tokens und SMTP
- Profilbearbeitung, E-Mail-Aenderung mit Verifikation und Account-Loeschanfrage
- Admin-Bereiche fuer Registrierungen, Nutzer, Gruppen und Audit
- Audit-Events fuer sicherheitsrelevante Aktionen in MySQL
- OpenID Connect Provider fuer Web-SSO
- Docker-Compose Setup fuer API und Web mit externer MySQL- und LLDAP-Anbindung
## Lokale Entwicklung
```bash
cp .env.example .env
npm install
npm run start:api
npm run start:web
```
Die API laeuft standardmaessig auf `http://localhost:3000`, das Frontend auf `http://localhost:4200`.
## Docker Compose
```bash
cp .env.example .env
docker compose up --build
```
Passe vor dem Start mindestens `DATABASE_URL`, `JWT_SECRET`, `TOKEN_SECRET`, `LLDAP_*` und `SMTP_*` an.
Fuer OIDC muessen zusaetzlich `OIDC_ISSUER`, `OIDC_COOKIE_SECRET`, `OIDC_ADMIN_GROUP` und `OIDC_ADMIN_GROUP_UUID` gesetzt werden.
## Externe Dienste
Die Anwendung bringt keine Datenbank und keinen LLDAP-Server mehr per Compose mit. Erwartet werden:
- eine externe MySQL-Datenbank, z. B. `mysql://ldap_portal:secret@mysql.example.com:3306/ldap_portal`
- ein externer LLDAP-HTTP-Endpunkt fuer GraphQL, z. B. `https://lldap.example.com`
- ein externer LDAP-Endpunkt fuer Bind/Login, z. B. `ldap://lldap.example.com:3890`
Setze `DATABASE_SSL=true`, wenn der MySQL-Server TLS verlangt. In `NODE_ENV=production` sollte `synchronize` nicht genutzt werden; fuer produktive Deployments sollten TypeORM-Migrationen ergaenzt werden.
## LLDAP-Hinweis
Die API nutzt LDAP-Bind fuer die Passwortpruefung und GraphQL fuer administrative User-Operationen. Falls sich die GraphQL-Mutationsnamen zwischen LLDAP-Versionen unterscheiden, muessen die Queries in `apps/api/src/lldap/lldap.service.ts` an die Zielversion angepasst werden.
## OpenID Connect
Die API stellt einen OIDC Provider bereit. Die wichtigsten Endpunkte:
- Discovery: `/.well-known/openid-configuration`
- Authorization: `/oidc/auth`
- Token: `/oidc/token`
- UserInfo: `/oidc/me`
- JWKS: `/oidc/jwks`
- Logout: `/oidc/session/end`
- Revocation: `/oidc/token/revocation`
- Introspection: `/oidc/token/introspection`
OIDC-Clients werden im Frontend unter `/admin/oidc-clients` verwaltet. Zugriff erhaelt nur ein eingeloggter Nutzer, der in der LLDAP-Gruppe `client_manager` ist. Standardmaessig wird zusaetzlich die Gruppen-UUID `89aa3d8d-fcbd-3ec9-b99d-901a0cfc405e` akzeptiert. Client Secrets werden nur direkt nach Erstellung angezeigt.
V1 unterstuetzt Authorization Code Flow mit verpflichtendem PKCE. Dynamic Client Registration und SAML sind nicht aktiviert.
## Admin-Rollen
Admin-Berechtigungen werden ueber LLDAP-Gruppen gesteuert:
- `client_manager`: OIDC-Clients verwalten.
- `registration_manager`: Registrierungen freigeben oder ablehnen.
- `user_manager`: Nutzer anzeigen, bearbeiten, loeschen und Gruppenmitgliedschaften aendern.
- `group_manager`: Gruppen anzeigen, erstellen, bearbeiten und loeschen.
- `audit_viewer`: Audit-Events anzeigen.
Die Registrierung laeuft in zwei Schritten: Nutzer bestaetigen zuerst ihre E-Mail-Adresse, danach muss ein `registration_manager` die Registrierung freigeben. Erst bei der Freigabe wird der LLDAP-User erstellt.

20
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
RUN npm install
FROM deps AS build
COPY tsconfig.base.json ./
COPY apps/api apps/api
RUN npm run build -w @ldap-portal/api
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules node_modules
COPY --from=build /app/apps/api/dist dist
COPY apps/api/package.json package.json
EXPOSE 3000
CMD ["node", "dist/main.js"]

7
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

51
apps/api/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "@ldap-portal/api",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"start:dev": "nest start --watch",
"test": "jest --passWithNoTests",
"lint": "eslint \"src/**/*.ts\""
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"handlebars": "^4.7.8",
"jose": "^6.2.3",
"ldapts": "^8.0.9",
"mysql2": "^3.11.3",
"nodemailer": "^8.0.5",
"oidc-provider": "^9.9.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.13",
"@types/node": "^22.7.4",
"@types/oidc-provider": "^9.5.0",
"@typescript-eslint/eslint-plugin": "^8.8.0",
"@typescript-eslint/parser": "^8.8.0",
"eslint": "^9.11.1",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"removeComments": true
}
}

11
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "test"]
}

16
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
RUN npm install
FROM deps AS build
COPY tsconfig.base.json ./
COPY apps/web apps/web
RUN npm run build -w @ldap-portal/web
FROM nginx:1.27-alpine
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist/web/browser /usr/share/nginx/html
EXPOSE 80

62
apps/web/angular.json Normal file
View File

@@ -0,0 +1,62 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"web": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/web",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": ["src/favicon.ico"],
"styles": ["src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
}
],
"outputHashing": "all"
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "web:build:production"
},
"development": {
"buildTarget": "web:build"
}
},
"defaultConfiguration": "development"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
}
}
},
"cli": {
"analytics": false
}
}

10
apps/web/nginx.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

31
apps/web/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@ldap-portal/web",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "ng serve --host 0.0.0.0 --port 4200",
"build": "ng build",
"lint": "eslint \"src/**/*.ts\""
},
"dependencies": {
"@angular/animations": "^20.0.0",
"@angular/common": "^20.3.26",
"@angular/compiler": "^20.0.0",
"@angular/core": "^20.0.0",
"@angular/forms": "^20.0.0",
"@angular/platform-browser": "^20.0.0",
"@angular/router": "^20.0.0",
"rxjs": "^7.8.1",
"tslib": "^2.8.0",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^20.0.0",
"@angular/cli": "^20.0.0",
"@angular/compiler-cli": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^8.8.0",
"@typescript-eslint/parser": "^8.8.0",
"eslint": "^9.11.1",
"typescript": "^5.8.0"
}
}

View File

@@ -0,0 +1,35 @@
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { AuthService } from './shared/auth.service';
@Component({
selector: 'app-root',
imports: [RouterLink, RouterOutlet],
template: `
<header class="topbar">
<a class="brand" routerLink="/login">LDAP Portal</a>
<nav>
@if (auth.username()) {
<a routerLink="/account">Account</a>
<a routerLink="/account/password">Passwort</a>
<a routerLink="/admin/oidc-clients">OIDC</a>
<a routerLink="/admin/registrations">Registrierungen</a>
<a routerLink="/admin/users">Nutzer</a>
<a routerLink="/admin/groups">Gruppen</a>
<a routerLink="/admin/audit">Audit</a>
<button type="button" class="link-button" (click)="auth.logout()">Abmelden</button>
} @else {
<a routerLink="/login">Login</a>
<a routerLink="/register">Registrieren</a>
}
</nav>
</header>
<main>
<router-outlet />
</main>
`,
})
export class AppComponent {
constructor(readonly auth: AuthService) {}
}

View File

@@ -0,0 +1,66 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-account-edit',
imports: [ReactiveFormsModule],
template: `
<section class="panel wide">
<h1>Profil bearbeiten</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="grid">
<label>Anzeigename <input formControlName="displayName"></label>
<label>Vorname <input formControlName="firstName"></label>
</div>
<label>Nachname <input formControlName="lastName"></label>
<label>Avatar JPEG Base64 <textarea rows="5" formControlName="avatar"></textarea></label>
@if (message()) { <p class="message" [class.error]="failed()">{{ message() }}</p> }
<button type="submit" [disabled]="form.invalid || loading()">Speichern</button>
</form>
</section>
`,
})
export class AccountEditComponent implements OnInit {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
displayName: ['', Validators.required],
firstName: [''],
lastName: [''],
avatar: [''],
});
}
ngOnInit(): void {
this.http.get<any>(`${this.apiBaseUrl}/account/me`).subscribe({
next: (account) => this.form.patchValue(account),
error: (error) => this.message.set(apiErrorMessage(error)),
});
}
submit(): void {
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http.patch(`${this.apiBaseUrl}/account/profile`, this.form.getRawValue()).subscribe({
next: () => this.message.set('Profil wurde aktualisiert.'),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,66 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-account-email',
imports: [ReactiveFormsModule],
template: `
<section class="panel">
<h1>E-Mail aendern</h1>
<form [formGroup]="form" (ngSubmit)="request()">
<label>Neue E-Mail <input type="email" formControlName="newEmail"></label>
@if (message()) { <p class="message" [class.error]="failed()">{{ message() }}</p> }
<button type="submit" [disabled]="form.invalid || loading()">Bestaetigung senden</button>
</form>
</section>
`,
})
export class AccountEmailComponent implements OnInit {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly route: ActivatedRoute,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
newEmail: ['', [Validators.required, Validators.email]],
});
}
ngOnInit(): void {
const token = this.route.snapshot.queryParamMap.get('token');
if (token) {
this.http.post<{ message: string }>(`${this.apiBaseUrl}/account/email-change/confirm`, { token }).subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
},
});
}
}
request(): void {
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http.post<{ message: string }>(`${this.apiBaseUrl}/account/email-change/request`, this.form.getRawValue()).subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,176 @@
import { HttpClient } from '@angular/common/http';
import { JsonPipe } from '@angular/common';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
interface AttributeSchema {
name: string;
attributeType: string;
isList: boolean;
isVisible: boolean;
isEditable: boolean;
isHardcoded: boolean;
isReadonly: boolean;
}
interface AttributeValue {
name: string;
value: string[];
schema: AttributeSchema;
}
interface AccountGroup {
id: number;
displayName: string;
creationDate: string;
uuid: string;
attributes: AttributeValue[];
}
interface AccountUser {
id: string;
email: string;
displayName: string;
firstName: string;
lastName: string;
avatar?: string | null;
creationDate: string;
uuid: string;
attributes: AttributeValue[];
groups: AccountGroup[];
}
@Component({
selector: 'app-account-overview',
imports: [JsonPipe, RouterLink],
template: `
<section class="account-layout">
<header class="account-header">
<div>
<h1>Account</h1>
@if (account()) {
<p>{{ account()?.displayName || account()?.id }}</p>
}
</div>
<div class="row-actions">
<a class="button-link" routerLink="/account/edit">Profil bearbeiten</a>
<a class="button-link" routerLink="/account/email">E-Mail aendern</a>
<a class="button-link" routerLink="/account/password">Passwort aendern</a>
</div>
</header>
@if (loading()) {
<section class="panel wide">
<p class="message">Accountdaten werden geladen.</p>
</section>
} @else if (message()) {
<section class="panel wide">
<p class="message error">{{ message() }}</p>
</section>
} @else if (account()) {
<section class="overview-grid">
<article class="info-panel">
<h2>Profil</h2>
@if (account()?.avatar) {
<img class="avatar" [src]="'data:image/jpeg;base64,' + account()?.avatar" alt="">
}
<dl>
<div><dt>Benutzername</dt><dd>{{ account()?.id }}</dd></div>
<div><dt>Anzeigename</dt><dd>{{ account()?.displayName || '-' }}</dd></div>
<div><dt>E-Mail</dt><dd>{{ account()?.email || '-' }}</dd></div>
<div><dt>Vorname</dt><dd>{{ account()?.firstName || '-' }}</dd></div>
<div><dt>Nachname</dt><dd>{{ account()?.lastName || '-' }}</dd></div>
<div><dt>UUID</dt><dd>{{ account()?.uuid }}</dd></div>
<div><dt>Erstellt</dt><dd>{{ account()?.creationDate }}</dd></div>
</dl>
</article>
<article class="info-panel">
<h2>Gruppen</h2>
@if (account()?.groups?.length) {
<div class="group-list">
@for (group of account()?.groups; track group.id) {
<div class="group-item">
<strong>{{ group.displayName }}</strong>
<span>#{{ group.id }}</span>
<small>{{ group.uuid }}</small>
@if (group.attributes.length) {
<div class="attribute-mini-list">
@for (attribute of group.attributes; track attribute.name) {
<span>{{ attribute.name }}: {{ formatValues(attribute.value) }}</span>
}
</div>
}
</div>
}
</div>
} @else {
<p class="muted">Keine Gruppenmitgliedschaften gefunden.</p>
}
</article>
</section>
<section class="info-panel full">
<h2>Attribute</h2>
@if (account()?.attributes?.length) {
<div class="attribute-table">
@for (attribute of account()?.attributes; track attribute.name) {
<div class="attribute-row">
<div>
<strong>{{ attribute.name }}</strong>
<small>{{ attribute.schema.attributeType }}{{ attribute.schema.isList ? ', Liste' : '' }}</small>
</div>
<div>{{ formatValues(attribute.value) }}</div>
<div class="flags">
@if (attribute.schema.isVisible) { <span>sichtbar</span> }
@if (attribute.schema.isEditable) { <span>editierbar</span> }
@if (attribute.schema.isReadonly) { <span>readonly</span> }
@if (attribute.schema.isHardcoded) { <span>system</span> }
</div>
</div>
}
</div>
} @else {
<p class="muted">Keine zusaetzlichen Attribute gefunden.</p>
}
</section>
<section class="info-panel full">
<h2>LLDAP Rohdaten</h2>
<pre>{{ account() | json }}</pre>
</section>
}
</section>
`,
})
export class AccountOverviewComponent implements OnInit {
readonly loading = signal(true);
readonly message = signal('');
readonly account = signal<AccountUser | null>(null);
constructor(
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {}
ngOnInit(): void {
this.http.get<AccountUser>(`${this.apiBaseUrl}/account/me`).subscribe({
next: (account) => this.account.set(account),
error: (error) => {
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
formatValues(values: string[]): string {
if (!values.length) {
return '-';
}
return values.map((value) => (value.length > 160 ? `${value.slice(0, 160)}...` : value)).join(', ');
}
}

View File

@@ -0,0 +1,73 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
import { AuthService } from '../shared/auth.service';
@Component({
selector: 'app-account-password',
imports: [ReactiveFormsModule],
template: `
<section class="panel">
<h1>Passwort aendern</h1>
<p class="account">{{ auth.username() }}</p>
<form [formGroup]="form" (ngSubmit)="submit()">
<label>
Aktuelles Passwort
<input type="password" formControlName="currentPassword" autocomplete="current-password">
</label>
<label>
Neues Passwort
<input type="password" formControlName="newPassword" autocomplete="new-password">
</label>
@if (message()) {
<p class="message" [class.error]="failed()">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">
{{ loading() ? 'Speichern laeuft' : 'Passwort aendern' }}
</button>
</form>
</section>
`,
})
export class AccountPasswordComponent {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
readonly auth: AuthService,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
currentPassword: ['', Validators.required],
newPassword: ['', [Validators.required, Validators.minLength(12)]],
});
}
submit(): void {
if (this.form.invalid) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http.post<{ message: string }>(`${this.apiBaseUrl}/password/change`, this.form.getRawValue()).subscribe({
next: (response) => {
this.message.set(response.message);
this.form.reset();
},
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,35 @@
import { JsonPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-admin-audit',
imports: [JsonPipe],
template: `
<section class="account-layout">
<h1>Audit</h1>
@if (message()) { <p class="message error">{{ message() }}</p> }
<section class="info-panel full">
<div class="client-list">
@for (event of events(); track event.id) {
<article class="client-item">
<strong>{{ event.type }}</strong>
<span>{{ event.username || '-' }} · {{ event.createdAt }}</span>
<pre>{{ event.metadata | json }}</pre>
</article>
}
</div>
</section>
</section>
`,
})
export class AdminAuditComponent implements OnInit {
readonly events = signal<any[]>([]);
readonly message = signal('');
constructor(private readonly http: HttpClient, @Inject(API_BASE_URL) private readonly apiBaseUrl: string) {}
ngOnInit(): void {
this.http.get<any[]>(`${this.apiBaseUrl}/admin/audit`).subscribe({ next: (events) => this.events.set(events), error: (e) => this.message.set(apiErrorMessage(e)) });
}
}

View File

@@ -0,0 +1,59 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-admin-groups',
imports: [ReactiveFormsModule],
template: `
<section class="account-layout">
<h1>Gruppen</h1>
<section class="overview-grid">
<article class="info-panel">
<h2>Neue Gruppe</h2>
<form [formGroup]="form" (ngSubmit)="create()">
<label>Name <input formControlName="displayName"></label>
<button type="submit" [disabled]="form.invalid">Erstellen</button>
</form>
</article>
<article class="info-panel">
@if (message()) { <p class="message error">{{ message() }}</p> }
</article>
</section>
<section class="info-panel full">
<div class="client-list">
@for (group of groups(); track group.id) {
<article class="client-item">
<strong>{{ group.displayName }}</strong>
<span>#{{ group.id }} · {{ group.uuid }}</span>
<small>{{ group.users?.length || 0 }} Mitglieder</small>
<div class="row-actions">
<button type="button" class="danger-action" (click)="delete(group.id)">Loeschen</button>
</div>
</article>
}
</div>
</section>
</section>
`,
})
export class AdminGroupsComponent implements OnInit {
readonly groups = signal<any[]>([]);
readonly message = signal('');
readonly form;
constructor(private readonly fb: FormBuilder, private readonly http: HttpClient, @Inject(API_BASE_URL) private readonly apiBaseUrl: string) {
this.form = this.fb.nonNullable.group({ displayName: ['', Validators.required] });
}
ngOnInit(): void { this.load(); }
create(): void {
this.http.post(`${this.apiBaseUrl}/admin/groups`, this.form.getRawValue()).subscribe({ next: () => { this.form.reset(); this.load(); }, error: (e) => this.message.set(apiErrorMessage(e)) });
}
delete(id: number): void {
this.http.delete(`${this.apiBaseUrl}/admin/groups/${id}`).subscribe({ next: () => this.load(), error: (e) => this.message.set(apiErrorMessage(e)) });
}
private load(): void {
this.http.get<any[]>(`${this.apiBaseUrl}/admin/groups`).subscribe({ next: (groups) => this.groups.set(groups), error: (e) => this.message.set(apiErrorMessage(e)) });
}
}

View File

@@ -0,0 +1,218 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
interface OidcClient {
id: string;
clientId: string;
clientName: string;
tokenEndpointAuthMethod: string;
redirectUris: string[];
postLogoutRedirectUris: string[];
grantTypes: string[];
responseTypes: string[];
scope: string;
firstParty: boolean;
enabled: boolean;
includeGroups: boolean;
}
interface CreatedOidcClient extends OidcClient {
clientSecret?: string;
}
@Component({
selector: 'app-admin-oidc-clients',
imports: [ReactiveFormsModule],
template: `
<section class="account-layout">
<header class="account-header">
<div>
<h1>OIDC Clients</h1>
<p>Clients fuer OpenID Connect Web-SSO verwalten.</p>
</div>
</header>
<section class="overview-grid">
<article class="info-panel">
<h2>Neuer Client</h2>
<form [formGroup]="form" (ngSubmit)="create()">
<label>
Name
<input formControlName="clientName">
</label>
<label>
Redirect URIs
<textarea formControlName="redirectUris" rows="4"></textarea>
</label>
<label>
Logout Redirect URIs
<textarea formControlName="postLogoutRedirectUris" rows="3"></textarea>
</label>
<label>
Scopes
<input formControlName="scope">
</label>
<label class="check-row">
<input type="checkbox" formControlName="publicClient">
Public Client ohne Secret
</label>
<label class="check-row">
<input type="checkbox" formControlName="firstParty">
First-Party Client
</label>
<label class="check-row">
<input type="checkbox" formControlName="includeGroups">
Gruppen-Claim ausgeben
</label>
@if (message()) {
<p class="message" [class.error]="failed()">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">Client erstellen</button>
</form>
@if (createdSecret()) {
<div class="secret-box">
<strong>Client Secret</strong>
<code>{{ createdSecret() }}</code>
</div>
}
</article>
<article class="info-panel">
<h2>Discovery</h2>
<dl>
<div><dt>Configuration</dt><dd>{{ apiBaseUrl }}/.well-known/openid-configuration</dd></div>
<div><dt>Authorize</dt><dd>{{ apiBaseUrl }}/oidc/auth</dd></div>
<div><dt>Token</dt><dd>{{ apiBaseUrl }}/oidc/token</dd></div>
<div><dt>UserInfo</dt><dd>{{ apiBaseUrl }}/oidc/me</dd></div>
<div><dt>JWKS</dt><dd>{{ apiBaseUrl }}/oidc/jwks</dd></div>
</dl>
</article>
</section>
<section class="info-panel full">
<h2>Registrierte Clients</h2>
@if (clients().length) {
<div class="client-list">
@for (client of clients(); track client.id) {
<article class="client-item">
<div>
<strong>{{ client.clientName }}</strong>
<code>{{ client.clientId }}</code>
</div>
<div class="flags">
<span>{{ client.enabled ? 'aktiv' : 'deaktiviert' }}</span>
<span>{{ client.tokenEndpointAuthMethod }}</span>
@if (client.firstParty) { <span>first-party</span> }
@if (client.includeGroups) { <span>groups</span> }
</div>
<dl>
<div><dt>Redirect URIs</dt><dd>{{ client.redirectUris.join(', ') }}</dd></div>
<div><dt>Scopes</dt><dd>{{ client.scope }}</dd></div>
</dl>
<div class="row-actions">
<button type="button" class="secondary-action" (click)="toggle(client)">
{{ client.enabled ? 'Deaktivieren' : 'Aktivieren' }}
</button>
<button type="button" class="danger-action" (click)="delete(client)">Loeschen</button>
</div>
</article>
}
</div>
} @else {
<p class="muted">Noch keine OIDC-Clients vorhanden.</p>
}
</section>
</section>
`,
})
export class AdminOidcClientsComponent implements OnInit {
readonly clients = signal<OidcClient[]>([]);
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly createdSecret = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
@Inject(API_BASE_URL) readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
clientName: ['', Validators.required],
redirectUris: ['http://localhost:8080/callback', Validators.required],
postLogoutRedirectUris: [''],
scope: ['openid profile email groups'],
publicClient: [false],
firstParty: [false],
includeGroups: [true],
});
}
ngOnInit(): void {
this.load();
}
create(): void {
if (this.form.invalid) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.createdSecret.set('');
const value = this.form.getRawValue();
this.http
.post<CreatedOidcClient>(`${this.apiBaseUrl}/admin/oidc/clients`, {
...value,
redirectUris: this.lines(value.redirectUris),
postLogoutRedirectUris: this.lines(value.postLogoutRedirectUris),
})
.subscribe({
next: (client) => {
this.createdSecret.set(client.clientSecret ?? '');
this.message.set('Client wurde erstellt.');
this.load();
},
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
toggle(client: OidcClient): void {
this.http
.patch<OidcClient>(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}`, { enabled: !client.enabled })
.subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) });
}
delete(client: OidcClient): void {
this.http
.delete<void>(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}`)
.subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) });
}
private load(): void {
this.http.get<OidcClient[]>(`${this.apiBaseUrl}/admin/oidc/clients`).subscribe({
next: (clients) => this.clients.set(clients),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
},
});
}
private lines(value: string): string[] {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
}

View File

@@ -0,0 +1,57 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-admin-registrations',
template: `
<section class="account-layout">
<h1>Registrierungen</h1>
@if (message()) { <p class="message" [class.error]="failed()">{{ message() }}</p> }
<section class="info-panel full">
<div class="client-list">
@for (item of registrations(); track item.id) {
<article class="client-item">
<strong>{{ item.username }}</strong>
<span>{{ item.email }} · {{ item.status }}</span>
<small>{{ item.createdAt }}</small>
@if (item.status === 'pending_approval') {
<div class="row-actions">
<button type="button" class="secondary-action" (click)="approve(item.id)">Freigeben</button>
<button type="button" class="danger-action" (click)="reject(item.id)">Ablehnen</button>
</div>
}
</article>
}
</div>
</section>
</section>
`,
})
export class AdminRegistrationsComponent implements OnInit {
readonly registrations = signal<any[]>([]);
readonly message = signal('');
readonly failed = signal(false);
constructor(private readonly http: HttpClient, @Inject(API_BASE_URL) private readonly apiBaseUrl: string) {}
ngOnInit(): void { this.load(); }
approve(id: string): void {
this.http.post(`${this.apiBaseUrl}/admin/registrations/${id}/approve`, {}).subscribe({ next: () => this.load(), error: (e) => this.error(e) });
}
reject(id: string): void {
this.http.post(`${this.apiBaseUrl}/admin/registrations/${id}/reject`, {}).subscribe({ next: () => this.load(), error: (e) => this.error(e) });
}
private load(): void {
this.http.get<any[]>(`${this.apiBaseUrl}/admin/registrations`).subscribe({ next: (items) => this.registrations.set(items), error: (e) => this.error(e) });
}
private error(error: unknown): void {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
}
}

View File

@@ -0,0 +1,94 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-admin-users',
imports: [ReactiveFormsModule],
template: `
<section class="account-layout">
<h1>Nutzer</h1>
@if (message()) { <p class="message error">{{ message() }}</p> }
<section class="info-panel full">
<div class="client-list">
@for (user of users(); track user.id) {
<article class="client-item">
<strong>{{ user.displayName || user.id }}</strong>
<span>{{ user.id }} · {{ user.email }}</span>
<div class="flags">
@for (group of user.groups; track group.id) { <span>{{ group.displayName }}</span> }
</div>
<form class="inline-form" [formGroup]="groupForms[user.id]" (ngSubmit)="addGroup(user.id)">
<select formControlName="groupId">
<option value="">Gruppe auswaehlen</option>
@for (group of groups(); track group.id) {
<option [value]="group.id">{{ group.displayName }}</option>
}
</select>
<button type="submit" class="secondary-action">Hinzufuegen</button>
</form>
<div class="row-actions">
@for (group of user.groups; track group.id) {
<button type="button" class="secondary-action" (click)="removeGroup(user.id, group.id)">
{{ group.displayName }} entfernen
</button>
}
<button type="button" class="danger-action" (click)="delete(user.id)">Loeschen</button>
</div>
</article>
}
</div>
</section>
</section>
`,
})
export class AdminUsersComponent implements OnInit {
readonly users = signal<any[]>([]);
readonly groups = signal<any[]>([]);
readonly message = signal('');
readonly groupForms: Record<string, any> = {};
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {}
ngOnInit(): void {
this.load();
this.http.get<any[]>(`${this.apiBaseUrl}/admin/groups`).subscribe({
next: (groups) => this.groups.set(groups),
error: (e) => this.message.set(apiErrorMessage(e)),
});
}
delete(id: string): void {
this.http.delete(`${this.apiBaseUrl}/admin/users/${id}`).subscribe({ next: () => this.load(), error: (e) => this.message.set(apiErrorMessage(e)) });
}
addGroup(id: string): void {
const groupId = this.groupForms[id]?.value.groupId;
if (!groupId) {
return;
}
this.http.patch(`${this.apiBaseUrl}/admin/users/${id}/groups/${groupId}`, {}).subscribe({
next: () => this.load(),
error: (e) => this.message.set(apiErrorMessage(e)),
});
}
removeGroup(id: string, groupId: number): void {
this.http.delete(`${this.apiBaseUrl}/admin/users/${id}/groups/${groupId}`).subscribe({
next: () => this.load(),
error: (e) => this.message.set(apiErrorMessage(e)),
});
}
private load(): void {
this.http.get<any[]>(`${this.apiBaseUrl}/admin/users`).subscribe({
next: (users) => {
users.forEach((user) => {
this.groupForms[user.id] ??= this.fb.nonNullable.group({ groupId: ['', Validators.required] });
});
this.users.set(users);
},
error: (e) => this.message.set(apiErrorMessage(e)),
});
}
}

View File

@@ -0,0 +1,68 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-forgot-password',
imports: [ReactiveFormsModule, RouterLink],
template: `
<section class="panel">
<h1>Passwort zuruecksetzen</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<label>
E-Mail
<input type="email" formControlName="email" autocomplete="email">
</label>
@if (message()) {
<p class="message" [class.error]="failed()">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">
{{ loading() ? 'Senden laeuft' : 'Reset-Link senden' }}
</button>
</form>
<div class="actions">
<a routerLink="/login">Zum Login</a>
</div>
</section>
`,
})
export class ForgotPasswordComponent {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
});
}
submit(): void {
if (this.form.invalid) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http
.post<{ message: string }>(`${this.apiBaseUrl}/password/reset/request`, this.form.getRawValue())
.subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,68 @@
import { Component, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { AuthService } from '../shared/auth.service';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule, RouterLink],
template: `
<section class="panel">
<h1>Anmelden</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<label>
Benutzername
<input formControlName="username" autocomplete="username">
</label>
<label>
Passwort
<input type="password" formControlName="password" autocomplete="current-password">
</label>
@if (message()) {
<p class="message error">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">
{{ loading() ? 'Anmeldung laeuft' : 'Anmelden' }}
</button>
</form>
<div class="actions">
<a routerLink="/forgot-password">Passwort vergessen</a>
<a routerLink="/register">Neues Konto</a>
</div>
</section>
`,
})
export class LoginComponent {
readonly loading = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly auth: AuthService,
private readonly router: Router,
) {
this.form = this.fb.nonNullable.group({
username: ['', Validators.required],
password: ['', Validators.required],
});
}
submit(): void {
if (this.form.invalid) {
return;
}
this.loading.set(true);
this.message.set('');
this.auth.login(this.form.value.username ?? '', this.form.value.password ?? '').subscribe({
next: () => void this.router.navigateByUrl('/account'),
error: (error) => {
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,83 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-register',
imports: [ReactiveFormsModule, RouterLink],
template: `
<section class="panel wide">
<h1>Registrieren</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="grid">
<label>
Benutzername
<input formControlName="username" autocomplete="username">
</label>
<label>
Anzeigename
<input formControlName="displayName" autocomplete="name">
</label>
</div>
<label>
E-Mail
<input type="email" formControlName="email" autocomplete="email">
</label>
<label>
Passwort
<input type="password" formControlName="password" autocomplete="new-password">
</label>
@if (message()) {
<p class="message" [class.error]="failed()">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">
{{ loading() ? 'Registrierung laeuft' : 'Registrieren' }}
</button>
</form>
<div class="actions">
<a routerLink="/login">Zum Login</a>
</div>
</section>
`,
})
export class RegisterComponent {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
username: ['', [Validators.required, Validators.minLength(3), Validators.pattern(/^[a-zA-Z0-9._-]+$/)]],
displayName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(12)]],
});
}
submit(): void {
if (this.form.invalid) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http.post<{ message: string }>(`${this.apiBaseUrl}/registration`, this.form.getRawValue()).subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,81 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-reset-password',
imports: [ReactiveFormsModule, RouterLink],
template: `
<section class="panel">
<h1>Neues Passwort</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<label>
Passwort
<input type="password" formControlName="newPassword" autocomplete="new-password">
</label>
@if (message()) {
<p class="message" [class.error]="failed()">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading() || !token()">
{{ loading() ? 'Speichern laeuft' : 'Passwort speichern' }}
</button>
</form>
<div class="actions">
<a routerLink="/login">Zum Login</a>
</div>
</section>
`,
})
export class ResetPasswordComponent implements OnInit {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly token = signal<string | null>(null);
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly route: ActivatedRoute,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
newPassword: ['', [Validators.required, Validators.minLength(12)]],
});
}
ngOnInit(): void {
this.token.set(this.route.snapshot.queryParamMap.get('token'));
if (!this.token()) {
this.failed.set(true);
this.message.set('Der Reset-Link ist unvollstaendig.');
}
}
submit(): void {
if (this.form.invalid || !this.token()) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http
.post<{ message: string }>(`${this.apiBaseUrl}/password/reset/confirm`, {
token: this.token(),
newPassword: this.form.value.newPassword,
})
.subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,54 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-verify-email',
imports: [RouterLink],
template: `
<section class="panel">
<h1>E-Mail bestaetigen</h1>
@if (loading()) {
<p class="message">Bestaetigung wird verarbeitet.</p>
} @else {
<p class="message" [class.error]="failed()">{{ message() }}</p>
<div class="actions">
<a routerLink="/login">Zum Login</a>
</div>
}
</section>
`,
})
export class VerifyEmailComponent implements OnInit {
readonly loading = signal(true);
readonly failed = signal(false);
readonly message = signal('');
constructor(
private readonly route: ActivatedRoute,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {}
ngOnInit(): void {
const token = this.route.snapshot.queryParamMap.get('token');
if (!token) {
this.failed.set(true);
this.loading.set(false);
this.message.set('Der Bestaetigungslink ist unvollstaendig.');
return;
}
this.http.post<{ message: string }>(`${this.apiBaseUrl}/registration/verify`, { token }).subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,3 @@
import { InjectionToken } from '@angular/core';
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');

View File

@@ -0,0 +1,15 @@
import { HttpErrorResponse } from '@angular/common/http';
export function apiErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const message = error.error?.message;
if (Array.isArray(message)) {
return message.join(' ');
}
if (typeof message === 'string') {
return message;
}
}
return 'Die Anfrage konnte nicht verarbeitet werden.';
}

View File

@@ -0,0 +1,42 @@
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { tap } from 'rxjs';
import { API_BASE_URL } from './api-base-url';
interface LoginResponse {
accessToken: string;
user: {
username: string;
};
}
@Injectable({ providedIn: 'root' })
export class AuthService {
readonly username = signal<string | null>(localStorage.getItem('username'));
constructor(
private readonly http: HttpClient,
private readonly router: Router,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {}
login(username: string, password: string) {
return this.http
.post<LoginResponse>(`${this.apiBaseUrl}/auth/login`, { username, password })
.pipe(
tap((response) => {
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('username', response.user.username);
this.username.set(response.user.username);
}),
);
}
logout(): void {
localStorage.removeItem('accessToken');
localStorage.removeItem('username');
this.username.set(null);
void this.router.navigateByUrl('/login');
}
}

1
apps/web/src/favicon.ico Normal file
View File

@@ -0,0 +1 @@

12
apps/web/src/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>LDAP Portal</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<app-root></app-root>
</body>
</html>

63
apps/web/src/main.ts Normal file
View File

@@ -0,0 +1,63 @@
import { HttpInterceptorFn, provideHttpClient, withInterceptors } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, Routes } from '@angular/router';
import { AppComponent } from './app/app.component';
import { AccountPasswordComponent } from './app/pages/account-password.component';
import { AccountOverviewComponent } from './app/pages/account-overview.component';
import { AccountEditComponent } from './app/pages/account-edit.component';
import { AccountEmailComponent } from './app/pages/account-email.component';
import { AdminAuditComponent } from './app/pages/admin-audit.component';
import { AdminGroupsComponent } from './app/pages/admin-groups.component';
import { AdminOidcClientsComponent } from './app/pages/admin-oidc-clients.component';
import { AdminRegistrationsComponent } from './app/pages/admin-registrations.component';
import { AdminUsersComponent } from './app/pages/admin-users.component';
import { ForgotPasswordComponent } from './app/pages/forgot-password.component';
import { LoginComponent } from './app/pages/login.component';
import { RegisterComponent } from './app/pages/register.component';
import { ResetPasswordComponent } from './app/pages/reset-password.component';
import { VerifyEmailComponent } from './app/pages/verify-email.component';
import { API_BASE_URL } from './app/shared/api-base-url';
import { AuthService } from './app/shared/auth.service';
const authInterceptor: HttpInterceptorFn = (request, next) => {
const token = localStorage.getItem('accessToken');
if (!token) {
return next(request);
}
return next(
request.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
}),
);
};
const routes: Routes = [
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'verify-email', component: VerifyEmailComponent },
{ path: 'forgot-password', component: ForgotPasswordComponent },
{ path: 'reset-password', component: ResetPasswordComponent },
{ path: 'account', component: AccountOverviewComponent },
{ path: 'account/edit', component: AccountEditComponent },
{ path: 'account/email', component: AccountEmailComponent },
{ path: 'account/password', component: AccountPasswordComponent },
{ path: 'admin/oidc-clients', component: AdminOidcClientsComponent },
{ path: 'admin/registrations', component: AdminRegistrationsComponent },
{ path: 'admin/users', component: AdminUsersComponent },
{ path: 'admin/groups', component: AdminGroupsComponent },
{ path: 'admin/audit', component: AdminAuditComponent },
{ path: '**', redirectTo: 'login' },
];
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
AuthService,
{ provide: API_BASE_URL, useValue: 'http://localhost:3000' },
],
}).catch((error) => console.error(error));

441
apps/web/src/styles.css Normal file
View File

@@ -0,0 +1,441 @@
:root {
color-scheme: light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f7f9;
color: #18202a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
a {
color: #0f6b6e;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.topbar {
align-items: center;
background: #ffffff;
border-bottom: 1px solid #d8e0e7;
display: flex;
gap: 24px;
justify-content: space-between;
min-height: 64px;
padding: 0 32px;
}
.brand {
color: #18202a;
font-weight: 700;
}
nav {
align-items: center;
display: flex;
gap: 18px;
}
.link-button {
background: transparent;
border: 0;
color: #0f6b6e;
cursor: pointer;
font: inherit;
padding: 0;
}
main {
display: grid;
min-height: calc(100vh - 64px);
padding: 48px 20px;
place-items: start center;
}
.panel {
background: #ffffff;
border: 1px solid #d8e0e7;
border-radius: 8px;
box-shadow: 0 16px 40px rgb(24 32 42 / 8%);
max-width: 440px;
padding: 28px;
width: 100%;
}
.panel.wide {
max-width: 640px;
}
h1 {
font-size: 1.5rem;
line-height: 1.2;
margin: 0 0 24px;
}
form {
display: grid;
gap: 18px;
}
.grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
color: #3a4551;
display: grid;
font-size: 0.92rem;
font-weight: 600;
gap: 7px;
}
input {
border: 1px solid #bcc8d3;
border-radius: 6px;
color: #18202a;
font: inherit;
min-height: 44px;
padding: 10px 12px;
width: 100%;
}
textarea {
border: 1px solid #bcc8d3;
border-radius: 6px;
color: #18202a;
font: inherit;
padding: 10px 12px;
resize: vertical;
width: 100%;
}
input:focus {
border-color: #0f6b6e;
box-shadow: 0 0 0 3px rgb(15 107 110 / 15%);
outline: 0;
}
textarea:focus {
border-color: #0f6b6e;
box-shadow: 0 0 0 3px rgb(15 107 110 / 15%);
outline: 0;
}
button[type="submit"] {
background: #0f6b6e;
border: 1px solid #0f6b6e;
border-radius: 6px;
color: #ffffff;
cursor: pointer;
font: inherit;
font-weight: 700;
min-height: 44px;
padding: 10px 14px;
}
button[type="submit"]:disabled {
background: #a9b8c4;
border-color: #a9b8c4;
cursor: not-allowed;
}
.message {
background: #edf7f4;
border: 1px solid #b8ddd3;
border-radius: 6px;
color: #24564f;
margin: 0;
padding: 12px;
}
.message.error {
background: #fff1f0;
border-color: #efb5ae;
color: #8d2b20;
}
.actions {
display: flex;
gap: 18px;
justify-content: space-between;
margin-top: 22px;
}
.account {
color: #637083;
margin: -12px 0 24px;
}
.account-layout {
display: grid;
gap: 22px;
max-width: 1120px;
width: 100%;
}
.account-header {
align-items: end;
display: flex;
gap: 16px;
justify-content: space-between;
}
.account-header h1,
.account-header p {
margin: 0;
}
.account-header p {
color: #637083;
margin-top: 6px;
}
.button-link {
background: #0f6b6e;
border-radius: 6px;
color: #ffffff;
font-weight: 700;
padding: 10px 14px;
}
.button-link:hover {
text-decoration: none;
}
.overview-grid {
display: grid;
gap: 22px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.info-panel {
background: #ffffff;
border: 1px solid #d8e0e7;
border-radius: 8px;
padding: 22px;
}
.info-panel.full {
width: 100%;
}
.info-panel h2 {
font-size: 1.08rem;
margin: 0 0 16px;
}
.avatar {
border-radius: 8px;
display: block;
height: 96px;
margin-bottom: 16px;
object-fit: cover;
width: 96px;
}
dl {
display: grid;
gap: 12px;
margin: 0;
}
dl div,
.attribute-row {
display: grid;
gap: 6px;
}
dt {
color: #637083;
font-size: 0.82rem;
font-weight: 700;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
.group-list,
.attribute-table,
.client-list {
display: grid;
gap: 12px;
}
.group-item,
.attribute-row,
.client-item {
border: 1px solid #d8e0e7;
border-radius: 6px;
padding: 12px;
}
.group-item {
display: grid;
gap: 5px;
}
.client-item {
display: grid;
gap: 12px;
}
.group-item span,
.group-item small,
.attribute-row small,
.muted {
color: #637083;
}
.attribute-mini-list,
.flags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.attribute-mini-list span,
.flags span {
background: #edf2f5;
border-radius: 999px;
color: #3a4551;
font-size: 0.78rem;
padding: 4px 8px;
}
pre {
background: #18202a;
border-radius: 6px;
color: #edf7f4;
margin: 0;
max-height: 420px;
overflow: auto;
padding: 14px;
white-space: pre-wrap;
word-break: break-word;
}
.check-row {
align-items: center;
display: flex;
flex-direction: row;
gap: 10px;
}
.check-row input {
min-height: auto;
width: auto;
}
.secret-box {
background: #fff9e8;
border: 1px solid #e7cf86;
border-radius: 6px;
display: grid;
gap: 8px;
margin-top: 18px;
padding: 12px;
}
code {
background: #edf2f5;
border-radius: 4px;
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
padding: 3px 6px;
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.inline-form {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
select {
border: 1px solid #bcc8d3;
border-radius: 6px;
color: #18202a;
font: inherit;
min-height: 38px;
padding: 8px 10px;
}
.secondary-action,
.danger-action {
border-radius: 6px;
cursor: pointer;
font: inherit;
min-height: 38px;
padding: 8px 12px;
}
.secondary-action {
background: #ffffff;
border: 1px solid #0f6b6e;
color: #0f6b6e;
}
.danger-action {
background: #fff1f0;
border: 1px solid #efb5ae;
color: #8d2b20;
}
@media (max-width: 560px) {
.topbar {
align-items: flex-start;
flex-direction: column;
gap: 10px;
padding: 16px 20px;
}
nav,
.actions {
flex-wrap: wrap;
}
main {
min-height: calc(100vh - 104px);
padding: 24px 14px;
}
.panel {
padding: 22px;
}
.grid {
grid-template-columns: 1fr;
}
.account-header,
.overview-grid {
align-items: stretch;
grid-template-columns: 1fr;
}
.account-header {
display: grid;
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

15
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ES2022",
"outDir": "./out-tsc/app",
"types": []
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
}
]
}

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
api:
build:
context: .
dockerfile: apps/api/Dockerfile
env_file:
- .env
ports:
- "3000:3000"
web:
build:
context: .
dockerfile: apps/web/Dockerfile
environment:
API_BASE_URL: http://localhost:3000
ports:
- "4200:80"
depends_on:
- api

Some files were not shown because too many files have changed in this diff Show More