asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 1m3s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-15 02:05:36 +02:00
parent e9b86eb7a3
commit 9325b93404
18 changed files with 1102 additions and 268 deletions

View File

@@ -77,9 +77,7 @@
</div>
<div class="kb-form-links">
<#if (url.loginResetCredentialsUrl!'')?has_content>
<a tabindex="6" href="${url.loginResetCredentialsUrl}">Passwort vergessen?</a>
</#if>
<a tabindex="6" id="kb-forgot-password-link" href="#" hidden>Passwort vergessen?</a>
</div>
</div>
@@ -90,7 +88,7 @@
<div id="kc-registration-container" class="kb-registration">
<div id="kc-registration">
<a class="kb-register-button" id="kb-register-button" tabindex="8" href="#" hidden>Registrieren</a>
<a class="kb-register-button" id="kb-register-button" tabindex="8" href="#" hidden>Neue Benutzer</a>
</div>
</div>
</form>

View File

@@ -1,7 +1,8 @@
(function () {
const registerLink = document.getElementById('kb-register-button');
const forgotPasswordLink = document.getElementById('kb-forgot-password-link');
if (!registerLink) {
if (!registerLink && !forgotPasswordLink) {
return;
}
@@ -14,8 +15,14 @@
try {
const target = new URL(redirectUri);
registerLink.href = target.origin + '/auth/register';
registerLink.hidden = false;
if (registerLink) {
registerLink.href = target.origin + '/auth/register/';
registerLink.hidden = false;
}
if (forgotPasswordLink) {
forgotPasswordLink.href = target.origin + '/auth/forgot-password/';
forgotPasswordLink.hidden = false;
}
} catch (error) {
console.warn('Registrierungslink konnte nicht vorbereitet werden.', error);
}

View File

@@ -55,6 +55,21 @@ return [
'module_name' => 'admin',
'summary' => 'Platzhalter fuer globale Verwaltung von Apps, Widgets, Integrationen und Suchmaschinen.',
],
[
'app_id' => 'user-management',
'title' => 'User Management',
'icon' => 'UM',
'entry_route' => '/admin/users/',
'window_mode' => 'window',
'content_mode' => 'iframe',
'default_width' => 1180,
'default_height' => 760,
'supports_widget' => false,
'supports_tray' => true,
'module_name' => 'admin',
'required_groups' => ['administrators'],
'summary' => 'Benutzerverwaltung fuer Freischaltungen, Aktivierung, Deaktivierung und Passwort-Reset.',
],
[
'app_id' => 'desktop-login',
'title' => 'Desktop Login',

View File

@@ -6,9 +6,18 @@ return [
'enabled' => true,
'storage_path' => 'data/registration-requests.json',
'ldif_export_dir' => 'data/registration-ldif',
'identity_counter_path' => 'data/registration-identity-counter.json',
'reset_token_path' => 'data/password-reset-tokens.json',
'approver_groups' => ['administrators', 'appadmin'],
'approver_usernames' => [],
'password_crypto_key' => getenv('REGISTRATION_PASSWORD_KEY') ?: '',
'reset_link_ttl_hours' => 8,
'mail' => [
'enabled' => true,
'from_email' => getenv('MAIL_FROM_EMAIL') ?: 'noreply@desktop.kusche.berlin',
'from_name' => getenv('MAIL_FROM_NAME') ?: 'Kusche.Berlin Desktop',
'log_path' => 'data/mail.log',
],
'ldap' => [
'enabled' => true,
'host' => getenv('LDAP_HOST') ?: '127.0.0.1',
@@ -20,10 +29,24 @@ return [
'default_groups' => [
'cn=generaluser,cn=groups,dc=nas,dc=kusche,dc=berlin',
],
'pending_group_dn' => getenv('LDAP_PENDING_GROUP_DN') ?: 'cn=users-pending,cn=groups,dc=nas,dc=kusche,dc=berlin',
'available_groups' => [
['dn' => 'cn=administrators,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'Administrators'],
['dn' => 'cn=appadmin,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'App Admin'],
['dn' => 'cn=dockers,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'Dockers'],
['dn' => 'cn=family,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'Family'],
['dn' => 'cn=generaluser,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'General User'],
['dn' => 'cn=internalfamily,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'Internal Family'],
['dn' => 'cn=users,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'Users'],
],
'group_assignment_mode' => getenv('LDAP_GROUP_ASSIGNMENT_MODE') ?: 'group_memberuid',
'group_member_attribute' => getenv('LDAP_GROUP_MEMBER_ATTRIBUTE') ?: 'memberUid',
'default_login_shell' => '/bin/sh',
'home_directory_prefix' => '/home',
'uid_number_start' => (int) (getenv('LDAP_UID_NUMBER_START') ?: 1000010),
'gid_number_default' => (int) (getenv('LDAP_GID_NUMBER_DEFAULT') ?: 1000012),
'samba_domain_sid' => getenv('LDAP_SAMBA_DOMAIN_SID') ?: 'S-1-5-21-2039032496-2228230393-4197958046',
'samba_rid_start' => (int) (getenv('LDAP_SAMBA_RID_START') ?: 1025),
'shadow_expire' => 1,
'active_shadow_expire' => -1,
'shadow_flag' => 0,

View File

@@ -7,6 +7,7 @@ $headline = (string) ($loginScreen['branding']['headline'] ?? 'Desktop Login');
$subheadline = (string) ($loginScreen['branding']['subheadline'] ?? '');
$loginUrl = $loginScreen['login_url'] ?? null;
$registerUrl = $loginScreen['register_url'] ?? null;
$forgotPasswordUrl = $loginScreen['forgot_password_url'] ?? null;
$previewUrl = $loginScreen['preview_url'] ?? null;
$keycloakReady = (bool) ($loginScreen['keycloak_ready'] ?? false);
$callbackUrl = (string) ($loginScreen['callback_url'] ?? '');
@@ -41,6 +42,10 @@ $callbackUrl = (string) ($loginScreen['callback_url'] ?? '');
<?php if ($registerUrl !== null): ?>
<a class="login-button login-button-secondary" href="<?= htmlspecialchars($registerUrl, ENT_QUOTES) ?>">Registrierung anfragen</a>
<?php endif; ?>
<?php if ($forgotPasswordUrl !== null): ?>
<a class="login-button login-button-secondary" href="<?= htmlspecialchars($forgotPasswordUrl, ENT_QUOTES) ?>">Passwort vergessen</a>
<?php endif; ?>
</div>
<dl class="login-meta">

View File

@@ -1,177 +1,3 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
use App\ConfigLoader;
use App\LdapProvisioner;
$projectRoot = dirname(__DIR__, 3);
$config = ConfigLoader::load($projectRoot, 'registration');
$ldap = new LdapProvisioner($config);
$diagnostics = $ldap->diagnostics();
$serviceResult = $ldap->testServiceBind();
$messages = [];
$errors = [];
$form = ['username' => '', 'password' => '', 'new_password' => ''];
$attempt = null;
$currentUserDn = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = (string) ($_POST['action'] ?? '');
$form['username'] = trim((string) ($_POST['username'] ?? ''));
$form['password'] = (string) ($_POST['password'] ?? '');
$form['new_password'] = (string) ($_POST['new_password'] ?? '');
if ($form['username'] === '') {
$errors[] = 'Benutzername ist erforderlich.';
}
if ($form['password'] === '') {
$errors[] = 'Passwort ist erforderlich.';
}
if ($action === 'change-password' && strlen($form['new_password']) < 12) {
$errors[] = 'Das neue Passwort muss mindestens 12 Zeichen lang sein.';
}
if ($errors === []) {
$currentUserDn = $ldap->userDnForUsername($form['username']);
if ($action === 'test-login') {
$attempt = [
'action' => 'Login-Test',
'username' => $form['username'],
'user_dn' => $currentUserDn,
];
$result = $ldap->testUserCredentials($form['username'], $form['password']);
if ($result['success'] ?? false) {
$messages[] = $result['message'] . ' DN: ' . (string) ($result['dn'] ?? '');
} else {
$errors[] = (string) ($result['message'] ?? 'LDAP-Login fehlgeschlagen.');
}
} elseif ($action === 'change-password') {
$attempt = [
'action' => 'Passwortänderung',
'username' => $form['username'],
'user_dn' => $currentUserDn,
'new_password_length' => (string) strlen($form['new_password']),
];
$loginCheck = $ldap->testUserCredentials($form['username'], $form['password']);
if (!(bool) ($loginCheck['success'] ?? false)) {
$errors[] = 'Der Login mit aktuellem Passwort ist fehlgeschlagen: ' . (string) ($loginCheck['message'] ?? '');
} else {
$result = $ldap->updateUserPassword($form['username'], $form['new_password']);
if ($result['success'] ?? false) {
$messages[] = $result['message'] . ' DN: ' . (string) ($result['dn'] ?? '');
$form['password'] = '';
$form['new_password'] = '';
} else {
$errors[] = (string) ($result['message'] ?? 'Passwortänderung fehlgeschlagen.');
}
}
}
}
}
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LDAP-Test</title>
<link rel="stylesheet" href="/assets/auth/login.css">
</head>
<body>
<main class="login-shell">
<section class="login-panel login-panel-wide">
<div class="login-panel-top">
<p class="login-kicker">Kusche.Berlin</p>
<h1>LDAP-Test</h1>
<p class="login-copy">Diese Testseite läuft unabhängig vom eigentlichen Desktop-Login. Hier kannst du nur LDAP-Login und Passwortänderung inklusive Samba-Feldern prüfen.</p>
</div>
<div class="login-notice <?= ($serviceResult['success'] ?? false) ? 'login-notice-success' : 'login-notice-error' ?>">
<strong>Service-Bind</strong>
<p><?= htmlspecialchars((string) ($serviceResult['message'] ?? ''), ENT_QUOTES) ?></p>
</div>
<div class="login-notice login-notice-info">
<strong>Verbindungsdaten</strong>
<ul class="login-list">
<li>Host: <?= htmlspecialchars((string) ($diagnostics['host'] ?? ''), ENT_QUOTES) ?></li>
<li>Port: <?= htmlspecialchars((string) ($diagnostics['port'] ?? ''), ENT_QUOTES) ?></li>
<li>Bind-DN: <?= htmlspecialchars((string) ($diagnostics['bind_dn'] ?? ''), ENT_QUOTES) ?></li>
<li>Users-DN: <?= htmlspecialchars((string) ($diagnostics['users_dn'] ?? ''), ENT_QUOTES) ?></li>
<li>StartTLS: <?= htmlspecialchars((string) ($diagnostics['starttls'] ?? ''), ENT_QUOTES) ?></li>
<li>Gruppenmodus: <?= htmlspecialchars((string) ($diagnostics['group_assignment_mode'] ?? ''), ENT_QUOTES) ?></li>
<li>Gruppenattribut: <?= htmlspecialchars((string) ($diagnostics['group_member_attribute'] ?? ''), ENT_QUOTES) ?></li>
<li>PHP LDAP-Erweiterung: <?= htmlspecialchars((string) ($diagnostics['ldap_extension'] ?? ''), ENT_QUOTES) ?></li>
</ul>
</div>
<?php if ($messages !== []): ?>
<div class="login-notice login-notice-success">
<strong>Ergebnis</strong>
<ul class="login-list">
<?php foreach ($messages as $message): ?>
<li><?= htmlspecialchars($message, ENT_QUOTES) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($errors !== []): ?>
<div class="login-notice login-notice-error">
<strong>Bitte prüfen</strong>
<ul class="login-list">
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error, ENT_QUOTES) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if (is_array($attempt)): ?>
<div class="login-notice login-notice-info">
<strong>Letzter Versuch</strong>
<ul class="login-list">
<li>Aktion: <?= htmlspecialchars((string) ($attempt['action'] ?? ''), ENT_QUOTES) ?></li>
<li>Benutzername: <?= htmlspecialchars((string) ($attempt['username'] ?? ''), ENT_QUOTES) ?></li>
<li>User-DN: <?= htmlspecialchars((string) ($attempt['user_dn'] ?? ''), ENT_QUOTES) ?></li>
<?php if (isset($attempt['new_password_length'])): ?>
<li>Länge neues Passwort: <?= htmlspecialchars((string) ($attempt['new_password_length'] ?? ''), ENT_QUOTES) ?></li>
<?php endif; ?>
</ul>
</div>
<?php endif; ?>
<form class="login-form" method="post" action="/admin/ldap-test/">
<label class="login-field">
<span>Benutzername</span>
<input type="text" name="username" value="<?= htmlspecialchars($form['username'], ENT_QUOTES) ?>" required>
</label>
<label class="login-field">
<span>Passwort</span>
<input type="password" name="password" value="" required>
</label>
<label class="login-field">
<span>Neues Passwort</span>
<input type="password" name="new_password" value="" placeholder="Nur für Passwortänderung erforderlich">
</label>
<div class="login-actions">
<button class="login-button login-button-primary" type="submit" name="action" value="test-login">Login-Test</button>
<button class="login-button login-button-secondary" type="submit" name="action" value="change-password">Neues Passwort setzen</button>
</div>
</form>
</section>
</main>
</body>
</html>
http_response_code(404);
echo 'LDAP-Test ist deaktiviert.';

View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
use App\ConfigLoader;
use App\RegistrationService;
session_start();
$projectRoot = dirname(__DIR__, 3);
$registration = new RegistrationService($projectRoot, ConfigLoader::load($projectRoot, 'registration'));
$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : [];
$currentGroups = array_map(static fn (string $group): string => strtolower(trim($group, '/')), array_values(array_map('strval', (array) ($currentUser['groups'] ?? []))));
if (!in_array('administrators', $currentGroups, true)) {
http_response_code(403);
echo 'Kein Zugriff auf User Management.';
exit;
}
$csrfToken = (string) ($_SESSION['user_management_token'] ?? '');
if ($csrfToken === '') {
$csrfToken = bin2hex(random_bytes(16));
$_SESSION['user_management_token'] = $csrfToken;
}
$messages = [];
$errors = [];
$selectedId = trim((string) ($_GET['id'] ?? $_POST['selected_id'] ?? ''));
$manualUsername = trim((string) ($_POST['manual_username'] ?? ''));
$manualResetUsername = trim((string) ($_POST['reset_username'] ?? ''));
$manualResetPassword = (string) ($_POST['reset_password'] ?? '');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$postedToken = (string) ($_POST['csrf_token'] ?? '');
if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) {
$errors[] = 'Die Formularprüfung ist fehlgeschlagen.';
} else {
$action = (string) ($_POST['action'] ?? '');
if ($action === 'approve-registration') {
$groupDns = array_values(array_filter(array_map('strval', (array) ($_POST['group_dns'] ?? []))));
$memberOfList = implode(PHP_EOL, $groupDns);
$result = $registration->approve($selectedId, [
'member_of_list' => $memberOfList,
'admin_note' => (string) ($_POST['admin_note'] ?? ''),
], (string) ($currentUser['username'] ?? 'administrator'));
if (($result['success'] ?? false) !== true) {
$errors = array_merge($errors, array_values(array_map('strval', (array) ($result['errors'] ?? []))));
} else {
$messages[] = 'Registrierung wurde freigeschaltet.';
}
} elseif ($action === 'deactivate-user') {
$result = $registration->deactivateUser($manualUsername);
($result['success'] ?? false) ? $messages[] = (string) ($result['message'] ?? '') : $errors[] = (string) ($result['message'] ?? '');
} elseif ($action === 'activate-user') {
$groupDns = array_values(array_filter(array_map('strval', (array) ($_POST['manual_group_dns'] ?? []))));
$result = $registration->activateExistingUser($manualUsername, $groupDns);
($result['success'] ?? false) ? $messages[] = (string) ($result['message'] ?? '') : $errors[] = (string) ($result['message'] ?? '');
} elseif ($action === 'send-reset-mail') {
$origin = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
$result = $registration->sendPasswordReset($manualResetUsername, $origin);
($result['success'] ?? false) ? $messages[] = (string) ($result['message'] ?? '') : $errors[] = (string) ($result['message'] ?? '');
} elseif ($action === 'set-password') {
if (strlen($manualResetPassword) < 12) {
$errors[] = 'Das neue Passwort muss mindestens 12 Zeichen lang sein.';
} else {
$result = $registration->setUserPassword($manualResetUsername, $manualResetPassword);
($result['success'] ?? false) ? $messages[] = (string) ($result['message'] ?? '') : $errors[] = (string) ($result['message'] ?? '');
}
}
}
}
$pendingRequests = $registration->pendingApprovals();
$availableGroups = $registration->availableGroups();
$selected = $selectedId !== ''
? $registration->find($selectedId)
: ($pendingRequests[0] ?? null);
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>User Management</title>
<link rel="stylesheet" href="/assets/auth/login.css">
</head>
<body>
<main class="login-shell">
<section class="login-panel login-panel-admin">
<div class="login-panel-top">
<p class="login-kicker">Kusche.Berlin</p>
<h1>User Management</h1>
<p class="login-copy">Nur für Benutzer in der Gruppe <strong>administrators</strong>. Hier werden Neuregistrierungen freigeschaltet sowie bestehende LDAP-Benutzer aktiviert, deaktiviert oder zurückgesetzt.</p>
</div>
<?php if ($messages !== []): ?>
<div class="login-notice login-notice-success">
<strong>Ergebnis</strong>
<ul class="login-list">
<?php foreach ($messages as $message): ?>
<li><?= htmlspecialchars($message, ENT_QUOTES) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($errors !== []): ?>
<div class="login-notice login-notice-error">
<strong>Bitte prüfen</strong>
<ul class="login-list">
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error, ENT_QUOTES) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="admin-layout">
<aside class="admin-sidebar">
<h2>Offene Registrierungen</h2>
<div class="admin-request-list">
<?php foreach ($pendingRequests as $request): ?>
<a class="admin-request-card<?= (($selected['id'] ?? '') === ($request['id'] ?? '')) ? ' is-active' : '' ?>" href="/admin/users/?id=<?= urlencode((string) ($request['id'] ?? '')) ?>">
<strong><?= htmlspecialchars((string) ($request['username'] ?? ''), ENT_QUOTES) ?></strong>
<span><?= htmlspecialchars((string) ($request['email'] ?? ''), ENT_QUOTES) ?></span>
<span class="admin-status"><?= htmlspecialchars((string) ($request['status'] ?? ''), ENT_QUOTES) ?></span>
</a>
<?php endforeach; ?>
</div>
</aside>
<section class="admin-detail">
<?php if (is_array($selected)): ?>
<div class="admin-summary">
<h2>Freischaltung</h2>
<p>Benutzer: <?= htmlspecialchars((string) ($selected['username'] ?? ''), ENT_QUOTES) ?></p>
<p>E-Mail: <?= htmlspecialchars((string) ($selected['email'] ?? ''), ENT_QUOTES) ?></p>
<p>Status: <?= htmlspecialchars((string) ($selected['status'] ?? ''), ENT_QUOTES) ?></p>
</div>
<form class="login-form" method="post" action="/admin/users/">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
<input type="hidden" name="selected_id" value="<?= htmlspecialchars((string) ($selected['id'] ?? ''), ENT_QUOTES) ?>">
<input type="hidden" name="action" value="approve-registration">
<label class="login-field">
<span>Zielgruppen für Freischaltung</span>
<div class="admin-request-list">
<?php foreach ($availableGroups as $group): ?>
<label class="login-field">
<span>
<input type="checkbox" name="group_dns[]" value="<?= htmlspecialchars($group['dn'], ENT_QUOTES) ?>">
<?= htmlspecialchars($group['label'], ENT_QUOTES) ?>
</span>
</label>
<?php endforeach; ?>
</div>
</label>
<label class="login-field">
<span>Admin-Notiz</span>
<textarea name="admin_note" rows="3"></textarea>
</label>
<div class="login-actions">
<button class="login-button login-button-primary" type="submit">Freischaltung bestätigen</button>
</div>
</form>
<?php endif; ?>
<div class="admin-summary">
<h2>Bestehenden Benutzer verwalten</h2>
</div>
<form class="login-form" method="post" action="/admin/users/">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
<label class="login-field">
<span>Benutzername</span>
<input type="text" name="manual_username" value="<?= htmlspecialchars($manualUsername, ENT_QUOTES) ?>" required>
</label>
<label class="login-field">
<span>Gruppen für Aktivierung</span>
<div class="admin-request-list">
<?php foreach ($availableGroups as $group): ?>
<label class="login-field">
<span>
<input type="checkbox" name="manual_group_dns[]" value="<?= htmlspecialchars($group['dn'], ENT_QUOTES) ?>">
<?= htmlspecialchars($group['label'], ENT_QUOTES) ?>
</span>
</label>
<?php endforeach; ?>
</div>
</label>
<div class="login-actions">
<button class="login-button login-button-primary" type="submit" name="action" value="activate-user">Benutzer aktivieren</button>
<button class="login-button login-button-danger" type="submit" name="action" value="deactivate-user">Benutzer deaktivieren</button>
</div>
</form>
<div class="admin-summary">
<h2>Passwort zurücksetzen</h2>
</div>
<form class="login-form" method="post" action="/admin/users/">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
<label class="login-field">
<span>Benutzername</span>
<input type="text" name="reset_username" value="<?= htmlspecialchars($manualResetUsername, ENT_QUOTES) ?>" required>
</label>
<label class="login-field">
<span>Neues Passwort für direkten Reset</span>
<input type="password" name="reset_password" value="" placeholder="Optional für direkten Admin-Reset">
</label>
<div class="login-actions">
<button class="login-button login-button-secondary" type="submit" name="action" value="send-reset-mail">Reset-Mail senden</button>
<button class="login-button login-button-primary" type="submit" name="action" value="set-password">Passwort direkt setzen</button>
</div>
</form>
</section>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
use App\ConfigLoader;
use App\RegistrationService;
$projectRoot = dirname(__DIR__, 3);
$registration = new RegistrationService($projectRoot, ConfigLoader::load($projectRoot, 'registration'));
$message = null;
$error = null;
$username = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim((string) ($_POST['username'] ?? ''));
$origin = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
$result = $registration->sendPasswordReset($username, $origin);
if ($result['success'] ?? false) {
$message = (string) ($result['message'] ?? '');
} else {
$error = (string) ($result['message'] ?? '');
}
}
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Passwort vergessen</title>
<link rel="stylesheet" href="/assets/auth/login.css">
</head>
<body>
<main class="login-shell">
<section class="login-panel">
<div class="login-panel-top">
<p class="login-kicker">Kusche.Berlin</p>
<h1>Passwort vergessen</h1>
<p class="login-copy">Gib deinen Benutzernamen an. Wenn der LDAP-Benutzer eine E-Mail-Adresse hat, senden wir einen 8 Stunden gültigen Reset-Link.</p>
</div>
<?php if ($message !== null): ?>
<div class="login-notice login-notice-success"><p><?= htmlspecialchars($message, ENT_QUOTES) ?></p></div>
<?php endif; ?>
<?php if ($error !== null): ?>
<div class="login-notice login-notice-error"><p><?= htmlspecialchars($error, ENT_QUOTES) ?></p></div>
<?php endif; ?>
<form class="login-form" method="post" action="/auth/forgot-password/">
<label class="login-field">
<span>Benutzername</span>
<input type="text" name="username" value="<?= htmlspecialchars($username, ENT_QUOTES) ?>" required>
</label>
<div class="login-actions">
<button class="login-button login-button-primary" type="submit">Reset-Link senden</button>
<a class="login-button login-button-secondary" href="/auth/login">Zurück zum Login</a>
</div>
</form>
</section>
</main>
</body>
</html>

View File

@@ -19,6 +19,7 @@ $loginScreen = [
'branding' => $auth->branding(),
'login_url' => $auth->isConfigured() ? '/auth/keycloak' : null,
'register_url' => '/auth/register',
'forgot_password_url' => '/auth/forgot-password/',
'preview_url' => $previewUrl,
'keycloak_ready' => $auth->isConfigured(),
'callback_url' => $auth->redirectUri(),

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
use App\ConfigLoader;
use App\PasswordResetService;
$projectRoot = dirname(__DIR__, 3);
$service = new PasswordResetService($projectRoot, ConfigLoader::load($projectRoot, 'registration'));
$token = trim((string) ($_GET['token'] ?? $_POST['token'] ?? ''));
$record = $service->validate($token);
$message = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$result = $service->complete(
$token,
(string) ($_POST['password'] ?? ''),
(string) ($_POST['password_confirm'] ?? '')
);
if ($result['success'] ?? false) {
$message = (string) ($result['message'] ?? '');
$record = null;
} else {
$error = (string) ($result['message'] ?? '');
}
}
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Passwort zurücksetzen</title>
<link rel="stylesheet" href="/assets/auth/login.css">
</head>
<body>
<main class="login-shell">
<section class="login-panel">
<div class="login-panel-top">
<p class="login-kicker">Kusche.Berlin</p>
<h1>Passwort zurücksetzen</h1>
<p class="login-copy">Der Link ist 8 Stunden gültig und setzt sowohl LDAP- als auch Samba-Passwort neu.</p>
</div>
<?php if ($message !== null): ?>
<div class="login-notice login-notice-success"><p><?= htmlspecialchars($message, ENT_QUOTES) ?></p></div>
<?php endif; ?>
<?php if ($error !== null): ?>
<div class="login-notice login-notice-error"><p><?= htmlspecialchars($error, ENT_QUOTES) ?></p></div>
<?php endif; ?>
<?php if ($record !== null): ?>
<form class="login-form" method="post" action="/auth/reset-password/">
<input type="hidden" name="token" value="<?= htmlspecialchars($token, ENT_QUOTES) ?>">
<label class="login-field">
<span>Neues Passwort</span>
<input type="password" name="password" required>
</label>
<label class="login-field">
<span>Neues Passwort wiederholen</span>
<input type="password" name="password_confirm" required>
</label>
<div class="login-actions">
<button class="login-button login-button-primary" type="submit">Passwort zurücksetzen</button>
</div>
</form>
<?php else: ?>
<div class="login-notice login-notice-info"><p>Der Token ist ungültig oder bereits verwendet.</p></div>
<?php endif; ?>
</section>
</main>
</body>
</html>

View File

@@ -29,13 +29,15 @@ final class App
$widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php');
$skins = SkinResolver::all();
$activeSkin = SkinResolver::resolve($_GET['skin'] ?? null);
$apps = AppIconResolver::decorate($registry->all(), $activeSkin);
$windows = (new WindowManager($registry))->defaultWindows();
$activeWidgetIds = DesktopState::defaultWidgetIds();
$isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']);
$authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null)
? $_SESSION['desktop_auth']['user']
: [];
$authGroups = array_values(array_map('strval', (array) ($authUser['groups'] ?? [])));
$visibleApps = $this->filterAppsForGroups($registry->all(), $authGroups);
$apps = AppIconResolver::decorate($visibleApps, $activeSkin);
$windows = (new WindowManager($registry))->defaultWindows();
$activeWidgetIds = DesktopState::defaultWidgetIds();
$displayName = (string) ($authUser['name'] ?? $authUser['username'] ?? 'Gast');
return [
@@ -80,4 +82,30 @@ final class App
],
];
}
/**
* @param array<int, array<string, mixed>> $apps
* @param array<int, string> $groups
* @return array<int, array<string, mixed>>
*/
private function filterAppsForGroups(array $apps, array $groups): array
{
$normalizedGroups = array_map(static fn (string $group): string => strtolower(trim($group, '/')), $groups);
return array_values(array_filter($apps, static function (array $app) use ($normalizedGroups): bool {
$requiredGroups = array_values(array_map('strval', (array) ($app['required_groups'] ?? [])));
if ($requiredGroups === []) {
return true;
}
foreach ($requiredGroups as $group) {
if (in_array(strtolower(trim($group, '/')), $normalizedGroups, true)) {
return true;
}
}
return false;
}));
}
}

51
src/App/JsonStore.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App;
final class JsonStore
{
public function __construct(
private readonly string $storagePath,
) {
}
/**
* @return array<int|string, mixed>
*/
public 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) ? $decoded : [];
}
/**
* @param array<int|string, mixed> $payload
*/
public function write(array $payload): void
{
$directory = dirname($this->storagePath);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents(
$this->storagePath,
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL,
LOCK_EX
);
}
}

View File

@@ -56,6 +56,60 @@ final class LdapProvisioner
return 'uid=' . $username . ',' . $baseDn;
}
public function usernameExists(string $username): bool
{
return $this->getUserByUsername($username, ['uid']) !== null;
}
/**
* @param array<int, string> $attributes
* @return array<string, mixed>|null
*/
public function getUserByUsername(string $username, array $attributes = []): ?array
{
try {
$link = $this->connectAsService();
$userDn = $this->userDnForUsername($username);
$read = @ldap_read($link, $userDn, '(objectClass=*)', $attributes === [] ? ['*'] : $attributes);
if ($read === false) {
return null;
}
$entries = ldap_get_entries($link, $read);
if (!is_array($entries) || (int) ($entries['count'] ?? 0) < 1) {
return null;
}
$entry = ['dn' => $userDn];
foreach ($entries[0] as $key => $value) {
if (is_int($key) || $key === 'count') {
continue;
}
if (is_array($value)) {
if ((int) ($value['count'] ?? 0) === 1) {
$entry[$key] = (string) ($value[0] ?? '');
} else {
$items = [];
foreach ($value as $subKey => $subValue) {
if (is_int($subKey)) {
$items[] = (string) $subValue;
}
}
$entry[$key] = $items;
}
}
}
return $entry;
} catch (\Throwable) {
return null;
}
}
/**
* @return array{success: bool, message: string}
*/
@@ -172,7 +226,12 @@ final class LdapProvisioner
throw new RuntimeException('LDAP-Benutzer konnte nicht angelegt werden: ' . $this->lastError($link));
}
$groupUpdates = $this->assignGroups($link, (string) ($request['username'] ?? ''), (string) $userDn, $provisioning);
$groupUpdates = $this->syncGroupsForUser(
$link,
(string) ($request['username'] ?? ''),
(string) $userDn,
$this->resolveTargetGroupsFromProvisioning($provisioning)
);
return [
'dn' => $userDn,
@@ -180,6 +239,64 @@ final class LdapProvisioner
];
}
/**
* @param array<int, string> $targetGroups
* @return array{success: bool, message: string, dn?: string}
*/
public function activateUser(string $username, array $targetGroups): array
{
try {
$link = $this->connectAsService();
$userDn = $this->userDnForUsername($username);
$groupUpdates = $this->syncGroupsForUser($link, $username, $userDn, $targetGroups);
if (!@ldap_modify($link, $userDn, [
'shadowExpire' => (string) ($this->config['ldap']['active_shadow_expire'] ?? -1),
])) {
throw new RuntimeException('Aktivierung im LDAP fehlgeschlagen: ' . $this->lastError($link));
}
return [
'success' => true,
'message' => 'Benutzer wurde aktiviert.',
'dn' => $userDn,
];
} catch (\Throwable $exception) {
return [
'success' => false,
'message' => $exception->getMessage(),
];
}
}
/**
* @return array{success: bool, message: string, dn?: string}
*/
public function deactivateUser(string $username): array
{
try {
$link = $this->connectAsService();
$userDn = $this->userDnForUsername($username);
if (!@ldap_modify($link, $userDn, [
'shadowExpire' => (string) ($this->config['ldap']['shadow_expire'] ?? 1),
])) {
throw new RuntimeException('Deaktivierung im LDAP fehlgeschlagen: ' . $this->lastError($link));
}
return [
'success' => true,
'message' => 'Benutzer wurde deaktiviert.',
'dn' => $userDn,
];
} catch (\Throwable $exception) {
return [
'success' => false,
'message' => $exception->getMessage(),
];
}
}
/**
* @param array<string, mixed> $request
* @param array<string, mixed> $provisioning
@@ -255,14 +372,20 @@ final class LdapProvisioner
*/
private function assignGroups($link, string $username, string $userDn, array $provisioning): array
{
$list = trim((string) ($provisioning['member_of_list'] ?? ''));
$groupDns = $list !== ''
? $this->parseGroupDns($list)
: array_values(array_map('strval', (array) ($this->config['ldap']['default_groups'] ?? [])));
return $this->syncGroupsForUser($link, $username, $userDn, $this->resolveTargetGroupsFromProvisioning($provisioning));
}
/**
* @param resource|\LDAP\Connection $link
* @param array<int, string> $targetGroups
* @return array<int, string>
*/
private function syncGroupsForUser($link, string $username, string $userDn, array $targetGroups): array
{
$mode = (string) ($this->config['ldap']['group_assignment_mode'] ?? 'group_memberuid');
$updated = [];
if ($groupDns === [] || $mode === 'none') {
if ($targetGroups === [] || $mode === 'none') {
return [];
}
@@ -272,8 +395,22 @@ final class LdapProvisioner
$attribute = (string) ($this->config['ldap']['group_member_attribute'] ?? 'memberUid');
$value = $attribute === 'member' ? $userDn : $username;
$knownGroups = array_values(array_unique(array_filter(array_merge(
$targetGroups,
array_map(static fn (array $group): string => (string) ($group['dn'] ?? ''), (array) ($this->config['ldap']['available_groups'] ?? [])),
array_filter([(string) ($this->config['ldap']['pending_group_dn'] ?? '')]),
array_values(array_map('strval', (array) ($this->config['ldap']['default_groups'] ?? [])))
))));
foreach ($groupDns as $groupDn) {
foreach ($knownGroups as $groupDn) {
if (in_array($groupDn, $targetGroups, true)) {
continue;
}
@ldap_mod_del($link, $groupDn, [$attribute => [$value]]);
}
foreach ($targetGroups as $groupDn) {
$result = @ldap_mod_add($link, $groupDn, [$attribute => [$value]]);
if ($result === false) {
@@ -293,6 +430,21 @@ final class LdapProvisioner
return $updated;
}
/**
* @param array<string, mixed> $provisioning
* @return array<int, string>
*/
private function resolveTargetGroupsFromProvisioning(array $provisioning): array
{
$list = trim((string) ($provisioning['member_of_list'] ?? ''));
if ($list !== '') {
return $this->parseGroupDns($list);
}
return array_values(array_map('strval', (array) ($this->config['ldap']['default_groups'] ?? [])));
}
/**
* @return resource|\LDAP\Connection
*/

71
src/App/Mailer.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App;
final class Mailer
{
private JsonStore $logStore;
/**
* @param array<string, mixed> $config
*/
public function __construct(
string $projectRoot,
private readonly array $config,
) {
$logPath = $projectRoot . '/' . ltrim((string) ($this->config['mail']['log_path'] ?? 'data/mail.log'), '/');
$this->logStore = new JsonStore($logPath);
}
/**
* @return array{success: bool, message: string}
*/
public function send(string $toEmail, string $subject, string $body): array
{
$fromEmail = (string) ($this->config['mail']['from_email'] ?? '');
$fromName = (string) ($this->config['mail']['from_name'] ?? '');
$headers = [
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
];
if ($fromEmail !== '') {
$headers[] = 'From: ' . ($fromName !== '' ? sprintf('%s <%s>', $fromName, $fromEmail) : $fromEmail);
}
$success = false;
if ((bool) ($this->config['mail']['enabled'] ?? false) && function_exists('mail')) {
$success = @mail($toEmail, $subject, $body, implode("\r\n", $headers));
}
$logged = $this->logMail($toEmail, $subject, $body);
if ($success) {
return ['success' => true, 'message' => 'E-Mail wurde versendet.'];
}
return [
'success' => false,
'message' => $logged
? 'Mailversand war nicht erfolgreich. Die Nachricht wurde im Mail-Log abgelegt.'
: 'Mailversand war nicht erfolgreich.',
];
}
private function logMail(string $toEmail, string $subject, string $body): bool
{
$payload = $this->logStore->read();
$payload[] = [
'created_at' => gmdate('c'),
'to' => $toEmail,
'subject' => $subject,
'body' => $body,
];
$this->logStore->write($payload);
return true;
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App;
final class PasswordResetService
{
private PasswordResetTokenStore $store;
private Mailer $mailer;
private LdapProvisioner $ldap;
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly string $projectRoot,
private readonly array $config,
) {
$tokenPath = $this->projectRoot . '/' . ltrim((string) ($this->config['reset_token_path'] ?? 'data/password-reset-tokens.json'), '/');
$this->store = new PasswordResetTokenStore($tokenPath);
$this->mailer = new Mailer($projectRoot, $config);
$this->ldap = new LdapProvisioner($config);
}
/**
* @return array{success: bool, message: string}
*/
public function request(string $username, string $origin): array
{
$username = strtolower(trim($username));
if ($username === '') {
return ['success' => false, 'message' => 'Bitte einen Benutzernamen angeben.'];
}
$user = $this->ldap->getUserByUsername($username, ['mail', 'displayName', 'uid']);
if ($user === null) {
return ['success' => false, 'message' => 'Benutzer wurde im LDAP nicht gefunden.'];
}
$email = (string) ($user['mail'] ?? '');
if ($email === '') {
return ['success' => false, 'message' => 'Für diesen Benutzer ist keine E-Mail-Adresse im LDAP hinterlegt.'];
}
$token = bin2hex(random_bytes(24));
$expiresAt = gmdate('c', time() + ((int) ($this->config['reset_link_ttl_hours'] ?? 8) * 3600));
$this->store->add([
'token' => $token,
'username' => $username,
'email' => $email,
'status' => 'active',
'created_at' => gmdate('c'),
'expires_at' => $expiresAt,
]);
$link = rtrim($origin, '/') . '/auth/reset-password/?token=' . urlencode($token);
$body = "Hallo,\n\nfür dein Kusche.Berlin Desktop-Konto wurde ein Passwort-Reset angefordert.\n\nLink: {$link}\n\nDer Link ist bis {$expiresAt} gültig.\n\nWenn du das nicht warst, kannst du diese E-Mail ignorieren.\n";
$mail = $this->mailer->send($email, 'Passwort zurücksetzen', $body);
return $mail['success']
? ['success' => true, 'message' => 'Reset-Link wurde per E-Mail versendet.']
: ['success' => false, 'message' => $mail['message']];
}
/**
* @return array<string, mixed>|null
*/
public function validate(string $token): ?array
{
return $this->store->findActive($token);
}
/**
* @return array{success: bool, message: string}
*/
public function complete(string $token, string $password, string $passwordConfirm): array
{
$record = $this->validate($token);
if ($record === null) {
return ['success' => false, 'message' => 'Der Reset-Link ist ungültig oder abgelaufen.'];
}
if (strlen($password) < 12) {
return ['success' => false, 'message' => 'Das neue Passwort muss mindestens 12 Zeichen lang sein.'];
}
if ($password !== $passwordConfirm) {
return ['success' => false, 'message' => 'Die Passwörter stimmen nicht überein.'];
}
$result = $this->ldap->updateUserPassword((string) ($record['username'] ?? ''), $password);
if (!($result['success'] ?? false)) {
return ['success' => false, 'message' => (string) ($result['message'] ?? 'Passwortänderung fehlgeschlagen.')];
}
$this->store->deactivate($token, 'used');
return ['success' => true, 'message' => 'Das Passwort wurde erfolgreich zurückgesetzt.'];
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App;
final class PasswordResetTokenStore
{
private JsonStore $store;
public function __construct(string $storagePath)
{
$this->store = new JsonStore($storagePath);
}
/**
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
$items = $this->store->read();
return array_values(array_filter($items, 'is_array'));
}
/**
* @param array<string, mixed> $record
*/
public function add(array $record): void
{
$items = $this->all();
$items[] = $record;
$this->store->write($items);
}
/**
* @return array<string, mixed>|null
*/
public function findActive(string $token): ?array
{
foreach ($this->all() as $item) {
if ((string) ($item['token'] ?? '') !== $token) {
continue;
}
if ((string) ($item['status'] ?? 'active') !== 'active') {
return null;
}
if (strtotime((string) ($item['expires_at'] ?? '')) < time()) {
return null;
}
return $item;
}
return null;
}
public function deactivate(string $token, string $status): void
{
$items = $this->all();
foreach ($items as $index => $item) {
if ((string) ($item['token'] ?? '') !== $token) {
continue;
}
$items[$index]['status'] = $status;
$items[$index]['used_at'] = gmdate('c');
break;
}
$this->store->write($items);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App;
final class RegistrationIdentityAllocator
{
private JsonStore $store;
/**
* @param array<string, mixed> $config
*/
public function __construct(
string $storagePath,
private readonly array $config,
) {
$this->store = new JsonStore($storagePath);
}
/**
* @return array{uid_number: string, gid_number: string, samba_sid: string}
*/
public function allocate(): array
{
$payload = $this->store->read();
$nextUid = (int) ($payload['next_uid_number'] ?? ($this->config['ldap']['uid_number_start'] ?? 1000010));
$nextRid = (int) ($payload['next_samba_rid'] ?? ($this->config['ldap']['samba_rid_start'] ?? 1025));
$gidNumber = (int) ($this->config['ldap']['gid_number_default'] ?? 1000012);
$domainSid = trim((string) ($this->config['ldap']['samba_domain_sid'] ?? ''));
$payload['next_uid_number'] = $nextUid + 1;
$payload['next_samba_rid'] = $nextRid + 1;
$this->store->write($payload);
return [
'uid_number' => (string) $nextUid,
'gid_number' => (string) $gidNumber,
'samba_sid' => $domainSid !== '' ? $domainSid . '-' . $nextRid : '',
];
}
}

View File

@@ -10,6 +10,8 @@ final class RegistrationService
private RegistrationLdifBuilder $ldifBuilder;
private RegistrationSecretBox $secretBox;
private LdapProvisioner $ldapProvisioner;
private RegistrationIdentityAllocator $identityAllocator;
private PasswordResetService $passwordResetService;
/**
* @param array<string, mixed> $config
@@ -23,6 +25,9 @@ final class RegistrationService
$this->ldifBuilder = new RegistrationLdifBuilder($this->config);
$this->secretBox = new RegistrationSecretBox((string) ($this->config['password_crypto_key'] ?? ''));
$this->ldapProvisioner = new LdapProvisioner($this->config);
$counterPath = $this->resolveProjectPath((string) ($this->config['identity_counter_path'] ?? 'data/registration-identity-counter.json'));
$this->identityAllocator = new RegistrationIdentityAllocator($counterPath, $this->config);
$this->passwordResetService = new PasswordResetService($this->projectRoot, $this->config);
}
public function isEnabled(): bool
@@ -100,6 +105,10 @@ final class RegistrationService
$errors[] = 'Für diesen Benutzernamen oder diese E-Mail existiert bereits eine offene oder freigegebene Registrierung.';
}
if ($this->ldapProvisioner->usernameExists($username)) {
$errors[] = 'Dieser Benutzername ist bereits im LDAP vergeben.';
}
if ($errors !== []) {
return [
'success' => false,
@@ -107,9 +116,26 @@ final class RegistrationService
];
}
$identities = $this->identityAllocator->allocate();
$pendingGroup = trim((string) ($this->config['ldap']['pending_group_dn'] ?? ''));
$provisioning = [
'uid_number' => $identities['uid_number'],
'gid_number' => $identities['gid_number'],
'home_directory' => rtrim((string) ($this->config['ldap']['home_directory_prefix'] ?? '/home'), '/') . '/' . $username,
'login_shell' => trim((string) ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')),
'member_of_list' => $pendingGroup,
'samba_sid' => $identities['samba_sid'],
'apple_generateduid' => '',
'display_name' => $displayName,
'cn' => $displayName !== '' ? $displayName : $username,
'gecos' => $displayName,
'admin_note' => '',
'ldap_created_at' => gmdate('c'),
];
$request = [
'id' => bin2hex(random_bytes(8)),
'status' => 'pending',
'status' => 'pending_approval',
'created_at' => gmdate('c'),
'updated_at' => gmdate('c'),
'username' => $username,
@@ -125,8 +151,29 @@ final class RegistrationService
'host' => (string) ($_SERVER['HTTP_HOST'] ?? ''),
],
'review' => null,
'provisioning' => null,
'provisioning' => $provisioning,
];
$passwordSecret = $this->secretBox->encrypt($password);
try {
$ldapResult = $this->ldapProvisioner->createInactiveUser($request, $provisioning, $password);
$request['password_secret'] = null;
$request['provisioning']['ldap_dn'] = $ldapResult['dn'];
$request['provisioning']['group_updates'] = $ldapResult['group_updates'];
$request['provisioning']['status'] = 'ldap_created_inactive';
$request['provisioning']['ldif_path'] = $this->writeLdifDraft($request['id'], $username, $this->ldifBuilder->build($request, $provisioning));
} catch (\Throwable $exception) {
$request['password_secret'] = $passwordSecret;
$request['status'] = 'provisioning_failed';
$request['provisioning']['status'] = 'provisioning_failed';
$request['provisioning']['error'] = $exception->getMessage();
return [
'success' => false,
'errors' => ['LDAP-Provisionierung bei Registrierung fehlgeschlagen: ' . $exception->getMessage()],
'request' => $this->store->create($request),
];
}
return [
'success' => true,
@@ -146,49 +193,45 @@ final class RegistrationService
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'] ?? ''));
$memberOfList = trim((string) ($input['member_of_list'] ?? ''));
$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 ($memberOfList === '') {
$errors[] = 'Für die Freischaltung muss mindestens eine Zielgruppe ausgewählt werden.';
}
if ($errors !== []) {
return ['success' => false, 'errors' => $errors];
}
$activate = $this->ldapProvisioner->activateUser((string) ($request['username'] ?? ''), preg_split('/\r\n|\r|\n/', $memberOfList) ?: []);
if (!($activate['success'] ?? false)) {
return ['success' => false, 'errors' => [(string) ($activate['message'] ?? 'Aktivierung fehlgeschlagen.')]];
}
$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'))),
'uid_number' => (string) ($request['provisioning']['uid_number'] ?? ''),
'gid_number' => (string) ($request['provisioning']['gid_number'] ?? ''),
'home_directory' => (string) ($request['provisioning']['home_directory'] ?? ''),
'login_shell' => (string) ($request['provisioning']['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')),
'member_of_list' => $memberOfList,
'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'] ?? ''))),
'samba_sid' => (string) ($request['provisioning']['samba_sid'] ?? ''),
'apple_generateduid' => (string) ($request['provisioning']['apple_generateduid'] ?? ''),
'display_name' => (string) ($request['provisioning']['display_name'] ?? $request['display_name'] ?? ''),
'cn' => (string) ($request['provisioning']['cn'] ?? $request['display_name'] ?? $request['username'] ?? ''),
'gecos' => (string) ($request['provisioning']['gecos'] ?? $request['display_name'] ?? ''),
'admin_note' => $adminNote,
'ldif_generated_at' => gmdate('c'),
'ldap_activated_at' => gmdate('c'),
'ldap_dn' => (string) ($activate['dn'] ?? ($request['provisioning']['ldap_dn'] ?? '')),
'status' => 'approved_active',
];
$request = $this->store->update(
$id,
static function (array $item) use ($provisioning, $reviewer, $adminNote): array {
$item['status'] = 'approved_pending_provisioning';
$item['status'] = 'approved_active';
$item['updated_at'] = gmdate('c');
$item['review'] = [
'reviewed_at' => gmdate('c'),
@@ -206,54 +249,6 @@ final class RegistrationService
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);
try {
$passwordSecret = (string) ($request['password_secret'] ?? '');
if ($passwordSecret === '') {
throw new \RuntimeException('Für diese Registrierung ist kein Passwort mehr hinterlegt.');
}
$plainPassword = $this->secretBox->decrypt($passwordSecret);
$ldapResult = $this->ldapProvisioner->createInactiveUser($request, $provisioning, $plainPassword);
$request = $this->store->update(
$id,
static function (array $item) use ($ldifPath, $ldapResult): array {
$item['status'] = 'ldap_created_inactive';
$item['updated_at'] = gmdate('c');
$item['password_secret'] = null;
$item['provisioning']['ldif_path'] = $ldifPath;
$item['provisioning']['status'] = 'ldap_created_inactive';
$item['provisioning']['ldap_dn'] = $ldapResult['dn'];
$item['provisioning']['group_updates'] = $ldapResult['group_updates'];
return $item;
}
);
} catch (\Throwable $exception) {
$request = $this->store->update(
$id,
static function (array $item) use ($ldifPath, $exception): array {
$item['status'] = 'provisioning_failed';
$item['updated_at'] = gmdate('c');
$item['provisioning']['ldif_path'] = $ldifPath;
$item['provisioning']['status'] = 'provisioning_failed';
$item['provisioning']['error'] = $exception->getMessage();
return $item;
}
);
return [
'success' => false,
'errors' => ['LDAP-Provisionierung fehlgeschlagen: ' . $exception->getMessage()],
'request' => $request,
];
}
return [
'success' => true,
'request' => $request,
@@ -297,6 +292,66 @@ final class RegistrationService
];
}
/**
* @return array{success: bool, message: string}
*/
public function sendPasswordReset(string $username, string $origin): array
{
return $this->passwordResetService->request($username, $origin);
}
/**
* @return array{success: bool, message: string}
*/
public function deactivateUser(string $username): array
{
return $this->ldapProvisioner->deactivateUser($username);
}
/**
* @return array{success: bool, message: string}
*/
public function activateExistingUser(string $username, array $groupDns): array
{
return $this->ldapProvisioner->activateUser($username, $groupDns);
}
/**
* @return array{success: bool, message: string}
*/
public function setUserPassword(string $username, string $password): array
{
return $this->ldapProvisioner->updateUserPassword($username, $password);
}
/**
* @return array<int, array<string, mixed>>
*/
public function pendingApprovals(): array
{
return array_values(array_filter(
$this->all(),
static fn (array $item): bool => in_array((string) ($item['status'] ?? ''), ['pending_approval', 'provisioning_failed'], true)
));
}
/**
* @return array<int, array<string, string>>
*/
public function availableGroups(): array
{
return array_values(array_filter(
array_map(
static fn (array $group): array => [
'dn' => (string) ($group['dn'] ?? ''),
'label' => (string) ($group['label'] ?? ($group['dn'] ?? '')),
],
(array) ($this->config['ldap']['available_groups'] ?? [])
),
static fn (array $group): bool => $group['dn'] !== ''
));
}
private function writeLdifDraft(string $id, string $username, string $content): string
{
$directory = $this->resolveProjectPath((string) ($this->config['ldif_export_dir'] ?? 'data/registration-ldif'));