asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 52s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-10 22:44:28 +02:00
parent a8f4a4b36a
commit cd5525ff2e
20 changed files with 1951 additions and 351 deletions

View File

@@ -181,6 +181,7 @@ final class KeycloakAuth
'username' => (string) ($userInfo['preferred_username'] ?? ''),
'name' => (string) ($userInfo['name'] ?? ''),
'email' => (string) ($userInfo['email'] ?? ''),
'groups' => $this->extractGroups($tokenPayload, $userInfo),
],
];
}
@@ -312,6 +313,65 @@ final class KeycloakAuth
return strtolower($first);
}
/**
* @param array<string, mixed> $tokenPayload
* @param array<string, mixed> $userInfo
* @return array<int, string>
*/
private function extractGroups(array $tokenPayload, array $userInfo): array
{
$groups = [];
foreach ([$userInfo, $this->decodeJwtClaims((string) ($tokenPayload['access_token'] ?? '')), $this->decodeJwtClaims((string) ($tokenPayload['id_token'] ?? ''))] as $source) {
$candidateGroups = $source['groups'] ?? [];
if (!is_array($candidateGroups)) {
continue;
}
foreach ($candidateGroups as $group) {
$group = trim((string) $group);
if ($group === '') {
continue;
}
$group = trim($group, '/');
$groups[$group] = $group;
}
}
return array_values($groups);
}
/**
* @return array<string, mixed>
*/
private function decodeJwtClaims(string $jwt): array
{
if ($jwt === '') {
return [];
}
$parts = explode('.', $jwt);
if (count($parts) < 2) {
return [];
}
$payload = $parts[1];
$payload .= str_repeat('=', (4 - strlen($payload) % 4) % 4);
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
if ($decoded === false) {
return [];
}
$claims = json_decode($decoded, true);
return is_array($claims) ? $claims : [];
}
/**
* @param array<string, string> $fields
* @return array{success: bool, data?: array<string, mixed>, error?: string}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App;
final class RegistrationAccess
{
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly array $config,
) {
}
public function canManage(): bool
{
$auth = $_SESSION['desktop_auth'] ?? null;
if (!is_array($auth)) {
return false;
}
$user = is_array($auth['user'] ?? null) ? $auth['user'] : [];
$username = strtolower((string) ($user['username'] ?? ''));
$groups = array_map('strtolower', array_values(array_map('strval', (array) ($user['groups'] ?? []))));
$approverGroups = array_map('strtolower', array_values(array_map('strval', (array) ($this->config['approver_groups'] ?? []))));
$approverUsernames = array_map('strtolower', array_values(array_map('strval', (array) ($this->config['approver_usernames'] ?? []))));
if ($username !== '' && in_array($username, $approverUsernames, true)) {
return true;
}
return array_intersect($groups, $approverGroups) !== [];
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App;
final class RegistrationLdifBuilder
{
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly array $config,
) {
}
/**
* @param array<string, mixed> $request
* @param array<string, mixed> $provisioning
*/
public function build(array $request, array $provisioning): string
{
$username = (string) ($request['username'] ?? '');
$usersDn = (string) ($this->config['ldap']['users_dn'] ?? '');
$uidNumber = trim((string) ($provisioning['uid_number'] ?? ''));
$gidNumber = trim((string) ($provisioning['gid_number'] ?? ''));
$homeDirectory = trim((string) ($provisioning['home_directory'] ?? ''));
$loginShell = trim((string) ($provisioning['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')));
$memberOf = array_values(array_filter(array_map('trim', explode(',', (string) ($provisioning['member_of_csv'] ?? '')))));
$displayName = trim((string) ($provisioning['display_name'] ?? $request['display_name'] ?? ''));
$givenName = trim((string) ($request['given_name'] ?? ''));
$familyName = trim((string) ($request['family_name'] ?? ''));
$cn = trim((string) ($provisioning['cn'] ?? ($displayName !== '' ? $displayName : $username)));
$gecos = trim((string) ($provisioning['gecos'] ?? ($displayName !== '' ? $displayName : trim($givenName . ' ' . $familyName))));
$mail = trim((string) ($request['email'] ?? ''));
$appleGeneratedUid = trim((string) ($provisioning['apple_generateduid'] ?? $this->generateUuidV4()));
$sambaSid = trim((string) ($provisioning['samba_sid'] ?? ''));
$shadowExpire = (string) ($this->config['ldap']['shadow_expire'] ?? 1);
$shadowFlag = (string) ($this->config['ldap']['shadow_flag'] ?? 0);
$shadowInactive = (string) ($this->config['ldap']['shadow_inactive'] ?? 0);
$shadowMax = (string) ($this->config['ldap']['shadow_max'] ?? 99999);
$shadowMin = (string) ($this->config['ldap']['shadow_min'] ?? 0);
$shadowWarning = (string) ($this->config['ldap']['shadow_warning'] ?? 7);
$placeholderPassword = $this->generatePlaceholderPassword();
$passwordHash = $this->cryptSha512($placeholderPassword);
$warnings = [];
if ($uidNumber === '') {
$warnings[] = '# WARNUNG: uidNumber fehlt und muss vor dem LDAP-Import gesetzt werden.';
}
if ($gidNumber === '') {
$warnings[] = '# WARNUNG: gidNumber fehlt und muss vor dem LDAP-Import gesetzt werden.';
}
if ($sambaSid === '') {
$warnings[] = '# WARNUNG: sambaSID fehlt. Viele NAS-Setups erwarten diesen Wert.';
}
if ($memberOf === []) {
$memberOf = array_values(array_map('strval', (array) ($this->config['ldap']['default_groups'] ?? [])));
}
$lines = [
'version: 1',
'',
];
foreach ($warnings as $warning) {
$lines[] = $warning;
}
if ($warnings !== []) {
$lines[] = '# Das Passwort unten ist ein zufälliger Platzhalter für den inaktiven Zustand.';
$lines[] = '# Platzhalter-Passwort: ' . $placeholderPassword;
$lines[] = '';
}
$lines[] = 'dn: uid=' . $username . ',' . $usersDn;
$lines[] = 'objectClass: apple-user';
$lines[] = 'objectClass: extensibleObject';
$lines[] = 'objectClass: inetOrgPerson';
$lines[] = 'objectClass: organizationalPerson';
$lines[] = 'objectClass: person';
$lines[] = 'objectClass: posixAccount';
$lines[] = 'objectClass: sambaIdmapEntry';
$lines[] = 'objectClass: sambaSamAccount';
$lines[] = 'objectClass: shadowAccount';
$lines[] = 'objectClass: top';
$lines[] = 'cn: ' . $cn;
if ($gidNumber !== '') {
$lines[] = 'gidNumber: ' . $gidNumber;
}
$lines[] = 'homeDirectory: ' . $homeDirectory;
if ($sambaSid !== '') {
$lines[] = 'sambaSID: ' . $sambaSid;
}
$lines[] = 'sn: ' . ($familyName !== '' ? $familyName : $username);
$lines[] = 'uid: ' . $username;
if ($uidNumber !== '') {
$lines[] = 'uidNumber: ' . $uidNumber;
}
$lines[] = 'apple-generateduid: ' . strtoupper($appleGeneratedUid);
$lines[] = 'authAuthority: ;basic;';
$lines[] = 'displayName: ' . ($displayName !== '' ? $displayName : $username);
if ($gecos !== '') {
$lines[] = 'gecos: ' . $gecos;
}
if ($givenName !== '') {
$lines[] = 'givenName: ' . $givenName;
}
$lines[] = 'loginShell: ' . $loginShell;
$lines[] = 'mail: ' . $mail;
foreach ($memberOf as $groupDn) {
$lines[] = 'memberOf: ' . $groupDn;
}
$lines[] = 'shadowExpire: ' . $shadowExpire;
$lines[] = 'shadowFlag: ' . $shadowFlag;
$lines[] = 'shadowInactive: ' . $shadowInactive;
$lines[] = 'shadowLastChange: 1';
$lines[] = 'shadowMax: ' . $shadowMax;
$lines[] = 'shadowMin: ' . $shadowMin;
$lines[] = 'shadowWarning: ' . $shadowWarning;
$lines[] = 'userPassword: ' . $passwordHash;
return implode(PHP_EOL, $lines) . PHP_EOL;
}
private function generateUuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
private function generatePlaceholderPassword(): string
{
return bin2hex(random_bytes(12));
}
private function cryptSha512(string $password): string
{
$salt = '$6$' . bin2hex(random_bytes(8)) . '$';
$hash = crypt($password, $salt);
return str_starts_with($hash, '$6$') ? '{CRYPT}' . $hash : $hash;
}
}

View File

@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace App;
final class RegistrationService
{
private RegistrationStore $store;
private RegistrationLdifBuilder $ldifBuilder;
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly string $projectRoot,
private readonly array $config,
) {
$storagePath = $this->resolveProjectPath((string) ($this->config['storage_path'] ?? 'data/registration-requests.json'));
$this->store = new RegistrationStore($storagePath);
$this->ldifBuilder = new RegistrationLdifBuilder($this->config);
}
public function isEnabled(): bool
{
return (bool) ($this->config['enabled'] ?? false);
}
/**
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
return $this->store->all();
}
/**
* @return array<string, mixed>|null
*/
public function find(string $id): ?array
{
return $this->store->find($id);
}
/**
* @param array<string, string> $input
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
*/
public function submit(array $input): array
{
$username = strtolower(trim((string) ($input['username'] ?? '')));
$givenName = trim((string) ($input['given_name'] ?? ''));
$familyName = trim((string) ($input['family_name'] ?? ''));
$email = strtolower(trim((string) ($input['email'] ?? '')));
$note = trim((string) ($input['note'] ?? ''));
$displayName = trim($givenName . ' ' . $familyName);
$errors = [];
if (!preg_match('/^[a-z0-9._-]{3,32}$/', $username)) {
$errors[] = 'Der Benutzername muss 3 bis 32 Zeichen lang sein und darf nur Kleinbuchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.';
}
if ($givenName === '') {
$errors[] = 'Der Vorname ist erforderlich.';
}
if ($familyName === '') {
$errors[] = 'Der Nachname ist erforderlich.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Bitte gib eine gültige E-Mail-Adresse an.';
}
if ($note !== '' && mb_strlen($note) > 2000) {
$errors[] = 'Die Zusatzinfo ist zu lang.';
}
$conflict = $this->store->findConflict($username, $email);
if ($conflict !== null) {
$errors[] = 'Für diesen Benutzernamen oder diese E-Mail existiert bereits eine offene oder freigegebene Registrierung.';
}
if ($errors !== []) {
return [
'success' => false,
'errors' => $errors,
];
}
$request = [
'id' => bin2hex(random_bytes(8)),
'status' => 'pending',
'created_at' => gmdate('c'),
'updated_at' => gmdate('c'),
'username' => $username,
'given_name' => $givenName,
'family_name' => $familyName,
'display_name' => $displayName,
'email' => $email,
'note' => $note,
'origin' => [
'ip' => (string) ($_SERVER['REMOTE_ADDR'] ?? ''),
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
'host' => (string) ($_SERVER['HTTP_HOST'] ?? ''),
],
'review' => null,
'provisioning' => null,
];
return [
'success' => true,
'request' => $this->store->create($request),
];
}
/**
* @param array<string, string> $input
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
*/
public function approve(string $id, array $input, string $reviewer): array
{
$request = $this->store->find($id);
if ($request === null) {
return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']];
}
$uidNumber = trim((string) ($input['uid_number'] ?? ''));
$gidNumber = trim((string) ($input['gid_number'] ?? ''));
$homeDirectory = trim((string) ($input['home_directory'] ?? ''));
$sambaSid = trim((string) ($input['samba_sid'] ?? ''));
$memberOfCsv = trim((string) ($input['member_of_csv'] ?? ''));
$adminNote = trim((string) ($input['admin_note'] ?? ''));
$errors = [];
if ($uidNumber === '' || !ctype_digit($uidNumber)) {
$errors[] = 'uidNumber ist erforderlich und muss numerisch sein.';
}
if ($gidNumber === '' || !ctype_digit($gidNumber)) {
$errors[] = 'gidNumber ist erforderlich und muss numerisch sein.';
}
if ($homeDirectory === '') {
$errors[] = 'homeDirectory ist erforderlich.';
}
if ($errors !== []) {
return ['success' => false, 'errors' => $errors];
}
$provisioning = [
'uid_number' => $uidNumber,
'gid_number' => $gidNumber,
'home_directory' => $homeDirectory,
'login_shell' => trim((string) ($input['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh'))),
'member_of_csv' => $memberOfCsv,
'samba_sid' => $sambaSid,
'apple_generateduid' => trim((string) ($input['apple_generateduid'] ?? '')),
'display_name' => trim((string) ($input['display_name'] ?? (string) ($request['display_name'] ?? ''))),
'cn' => trim((string) ($input['cn'] ?? (string) ($request['display_name'] ?? $request['username'] ?? ''))),
'gecos' => trim((string) ($input['gecos'] ?? (string) ($request['display_name'] ?? ''))),
'admin_note' => $adminNote,
'ldif_generated_at' => gmdate('c'),
];
$request = $this->store->update(
$id,
static function (array $item) use ($provisioning, $reviewer, $adminNote): array {
$item['status'] = 'approved_pending_provisioning';
$item['updated_at'] = gmdate('c');
$item['review'] = [
'reviewed_at' => gmdate('c'),
'reviewed_by' => $reviewer,
'decision' => 'approved',
'note' => $adminNote,
];
$item['provisioning'] = $provisioning;
return $item;
}
);
if ($request === null) {
return ['success' => false, 'errors' => ['Die Registrierung konnte nicht aktualisiert werden.']];
}
$ldif = $this->ldifBuilder->build($request, $provisioning);
$ldifPath = $this->writeLdifDraft((string) ($request['id'] ?? ''), (string) ($request['username'] ?? ''), $ldif);
$request = $this->store->update(
$id,
static function (array $item) use ($ldifPath): array {
$item['updated_at'] = gmdate('c');
$item['provisioning']['ldif_path'] = $ldifPath;
$item['provisioning']['status'] = 'ldif_generated';
return $item;
}
);
return [
'success' => true,
'request' => $request,
];
}
/**
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
*/
public function reject(string $id, string $reason, string $reviewer): array
{
$reason = trim($reason);
if ($reason === '') {
return ['success' => false, 'errors' => ['Bitte gib einen Ablehnungsgrund an.']];
}
$request = $this->store->update(
$id,
static function (array $item) use ($reason, $reviewer): array {
$item['status'] = 'rejected';
$item['updated_at'] = gmdate('c');
$item['review'] = [
'reviewed_at' => gmdate('c'),
'reviewed_by' => $reviewer,
'decision' => 'rejected',
'note' => $reason,
];
return $item;
}
);
if ($request === null) {
return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']];
}
return [
'success' => true,
'request' => $request,
];
}
private function writeLdifDraft(string $id, string $username, string $content): string
{
$directory = $this->resolveProjectPath((string) ($this->config['ldif_export_dir'] ?? 'data/registration-ldif'));
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$filename = $id . '-' . preg_replace('/[^a-z0-9._-]+/i', '-', $username) . '.ldif';
$path = rtrim($directory, '/') . '/' . $filename;
file_put_contents($path, $content, LOCK_EX);
return $path;
}
private function resolveProjectPath(string $path): string
{
if ($path === '') {
return $this->projectRoot . '/data/registration-requests.json';
}
if (str_starts_with($path, '/')) {
return $path;
}
return $this->projectRoot . '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App;
final class RegistrationStore
{
public function __construct(
private readonly string $storagePath,
) {
}
/**
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
$items = $this->read();
usort(
$items,
static fn (array $left, array $right): int => strcmp(
(string) ($right['created_at'] ?? ''),
(string) ($left['created_at'] ?? '')
)
);
return $items;
}
/**
* @return array<string, mixed>|null
*/
public function find(string $id): ?array
{
foreach ($this->read() as $item) {
if ((string) ($item['id'] ?? '') === $id) {
return $item;
}
}
return null;
}
/**
* @return array<string, mixed>|null
*/
public function findConflict(string $username, string $email): ?array
{
$username = strtolower(trim($username));
$email = strtolower(trim($email));
foreach ($this->read() as $item) {
$status = (string) ($item['status'] ?? 'pending');
if ($status === 'rejected') {
continue;
}
if (strtolower((string) ($item['username'] ?? '')) === $username) {
return $item;
}
if (strtolower((string) ($item['email'] ?? '')) === $email) {
return $item;
}
}
return null;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function create(array $payload): array
{
$items = $this->read();
$items[] = $payload;
$this->write($items);
return $payload;
}
/**
* @param callable(array<string, mixed>): array<string, mixed> $mutator
* @return array<string, mixed>|null
*/
public function update(string $id, callable $mutator): ?array
{
$items = $this->read();
$updated = null;
foreach ($items as $index => $item) {
if ((string) ($item['id'] ?? '') !== $id) {
continue;
}
$items[$index] = $mutator($item);
$updated = $items[$index];
break;
}
if ($updated === null) {
return null;
}
$this->write($items);
return $updated;
}
/**
* @return array<int, array<string, mixed>>
*/
private function read(): array
{
if (!is_file($this->storagePath)) {
return [];
}
$raw = file_get_contents($this->storagePath);
if ($raw === false || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? array_values(array_filter($decoded, 'is_array')) : [];
}
/**
* @param array<int, array<string, mixed>> $items
*/
private function write(array $items): void
{
$directory = dirname($this->storagePath);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents(
$this->storagePath,
json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL,
LOCK_EX
);
}
}