From cd5525ff2eee9b02c9192260dee9a6111ec3713b Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Wed, 10 Jun 2026 22:44:28 +0200 Subject: [PATCH] asdsad --- CopyToKeycloak/WAS_DU_TUN_MUSST.md | 44 +- .../theme/kusche-desktop/login/login.ftl | 187 +++---- .../login/messages/messages.properties | 16 + .../login/messages/messages_de.properties | 16 +- .../login/messages/messages_en.properties | 16 +- .../login/resources/css/desktop-kusche.css | 486 ++++++++++-------- .../resources/js/desktop-register-link.js | 22 + .../login/resources/js/login-clock.js | 26 + config/registration.php | 25 + partials/auth/login_screen.php | 5 + public/admin/registrations/index.php | 222 ++++++++ public/assets/auth/login.css | 188 +++++++ public/auth/login/index.php | 1 + public/auth/register/index.php | 153 ++++++ src/App/KeycloakAuth.php | 60 +++ src/App/RegistrationAccess.php | 37 ++ src/App/RegistrationLdifBuilder.php | 161 ++++++ src/App/RegistrationService.php | 274 ++++++++++ src/App/RegistrationStore.php | 150 ++++++ temp/MODERIERTE_REGISTRIERUNG_KONZEPT.md | 213 ++++++++ 20 files changed, 1951 insertions(+), 351 deletions(-) create mode 100644 CopyToKeycloak/theme/kusche-desktop/login/messages/messages.properties create mode 100644 CopyToKeycloak/theme/kusche-desktop/login/resources/js/desktop-register-link.js create mode 100644 CopyToKeycloak/theme/kusche-desktop/login/resources/js/login-clock.js create mode 100644 config/registration.php create mode 100644 public/admin/registrations/index.php create mode 100644 public/auth/register/index.php create mode 100644 src/App/RegistrationAccess.php create mode 100644 src/App/RegistrationLdifBuilder.php create mode 100644 src/App/RegistrationService.php create mode 100644 src/App/RegistrationStore.php create mode 100644 temp/MODERIERTE_REGISTRIERUNG_KONZEPT.md diff --git a/CopyToKeycloak/WAS_DU_TUN_MUSST.md b/CopyToKeycloak/WAS_DU_TUN_MUSST.md index 066d457e..abcd8bf3 100644 --- a/CopyToKeycloak/WAS_DU_TUN_MUSST.md +++ b/CopyToKeycloak/WAS_DU_TUN_MUSST.md @@ -26,27 +26,39 @@ Wenn du in Entwicklung arbeitest: Wenn du in Produktion Theme-Caches aktiv hast: 1. Keycloak stoppen oder neu starten -2. falls noetig Theme-Cache loeschen: +2. falls nötig Theme-Cache löschen: - `data/tmp/kc-gzip-cache` -## 3. Theme nur fuer den Desktop-Client setzen +## 3. Theme nur für den Desktop-Client setzen In Keycloak: -1. Realm `KuscheBerlin` oeffnen -2. Client `desktop-kusche-berlin` oeffnen -3. Die Login-Theme-Override fuer den Client auf `kusche-desktop` setzen +1. Realm `KuscheBerlin` öffnen +2. Client `desktop-kusche-berlin` öffnen +3. Die Login-Theme-Override für den Client auf `kusche-desktop` setzen Wichtig: -- Das Theme soll **nicht** pauschal als Realm-Login-Theme fuer alle Clients gesetzt werden, wenn andere Clients das Standard-Keycloak-Login behalten sollen. +- Das Theme soll **nicht** pauschal als Realm-Login-Theme für alle Clients gesetzt werden, wenn andere Clients das Standard-Keycloak-Login behalten sollen. +- Wenn die Texte automatisch nach Browser-Sprache wechseln sollen: + - im Realm unter `Realm settings` die Internationalisierung aktivieren + - `de` und `en` als erlaubte Sprachen hinterlegen + - optional `de` als Standard-Sprache setzen +- Wenn `Passwort vergessen?` sichtbar sein soll: + - im Realm unter `Login` die Option `Forgot password` aktivieren +- Wenn `Angemeldet bleiben` sichtbar sein soll: + - im Realm unter `Login` die Option `Remember Me` aktivieren +- Wenn ein Passwort-Reset auch wirklich ins angebundene LDAP zurückschreiben soll: + - in der LDAP-User-Federation muss Passwort-Änderung erlaubt sein + - das hängt von der Keycloak-LDAP-Konfiguration ab, nicht vom Theme selbst + - falls LDAP nur read-only angebunden ist, kann Keycloak zwar den Reset-Flow anzeigen, aber das Passwort nicht ins LDAP zurückschreiben Falls deine Keycloak-Version im Client-UI keine direkte Login-Theme-Auswahl anzeigt: -- pruefen, ob die Client-spezifische Login-Theme-Option in den Client-Einstellungen / Advanced Settings vorhanden ist +- prüfen, ob die Client-spezifische Login-Theme-Option in den Client-Einstellungen / Advanced Settings vorhanden ist - falls nicht vorhanden, bleibt als saubere Alternative nur: - - eigener Realm fuer Desktop - - oder spaeter ein Theme Selector SPI + - eigener Realm für Desktop + - oder später ein Theme Selector SPI ## 4. Testen @@ -57,12 +69,12 @@ Danach testen: Erwartung: -- fuer den Client `desktop-kusche-berlin` erscheint das neue Desktop-Login-Theme -- nach erfolgreichem Login geht es zur Desktop-Shell zurueck +- für den Client `desktop-kusche-berlin` erscheint das neue Desktop-Login-Theme +- nach erfolgreichem Login geht es zur Desktop-Shell zurück ## 5. Falls das Theme nicht erscheint -Dann diese Punkte pruefen: +Dann diese Punkte prüfen: - Theme-Ordner liegt wirklich im Keycloak-`themes/`-Verzeichnis - Verzeichnisname ist exakt `kusche-desktop` @@ -71,17 +83,17 @@ Dann diese Punkte pruefen: - Cache wurde geleert - wirklich der Client `desktop-kusche-berlin` wird verwendet -## 6. Welche Datei du spaeter bei Aenderungen ersetzt +## 6. Welche Datei du später bei Änderungen ersetzt -Wenn du nur Styling aendern willst, reicht meist: +Wenn du nur Styling ändern willst, reicht meist: - `theme/kusche-desktop/login/resources/css/desktop-kusche.css` -Wenn du die Struktur aendern willst: +Wenn du die Struktur ändern willst: - `theme/kusche-desktop/login/login.ftl` -## 7. Was ich bewusst nicht fuer dich serverseitig tue +## 7. Was ich bewusst nicht für dich serverseitig tue Diese Schritte musst du selbst auf dem Keycloak-System machen: diff --git a/CopyToKeycloak/theme/kusche-desktop/login/login.ftl b/CopyToKeycloak/theme/kusche-desktop/login/login.ftl index eab13feb..a1048852 100644 --- a/CopyToKeycloak/theme/kusche-desktop/login/login.ftl +++ b/CopyToKeycloak/theme/kusche-desktop/login/login.ftl @@ -1,146 +1,121 @@ <#import "template.ftl" as layout> <#import "passkeys.ftl" as passkeys> -<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=false; section> <#if section = "header"> - ${msg("loginAccountTitle")} + Desktop-Anmeldung <#elseif section = "form">
- - + -
<@passkeys.conditionalUIData /> + + <#elseif section = "info" > - <#if realm.password && realm.registrationAllowed && !registrationDisabled??> -
-
- ${msg("noAccount")} ${msg("doRegister")} -
-
- + <#-- Intentionally unused. Registration is rendered inside the form section. --> <#elseif section = "socialProviders" > - <#if realm.password && social?? && social.providers?has_content> -
-
-

${msg("identity-provider-login-label")}

- -
- + <#-- Social logins are intentionally hidden for this theme. --> diff --git a/CopyToKeycloak/theme/kusche-desktop/login/messages/messages.properties b/CopyToKeycloak/theme/kusche-desktop/login/messages/messages.properties new file mode 100644 index 00000000..039c2615 --- /dev/null +++ b/CopyToKeycloak/theme/kusche-desktop/login/messages/messages.properties @@ -0,0 +1,16 @@ +kbDesktopLoginHeadline=Desktop Login +kbDesktopLoginSubheadline=Melde dich mit deinem Kusche.Berlin Konto an, um auf Desktop, Apps und geschützte Inhalte zuzugreifen. +kbUsernameLabel=Benutzername +kbUsernameOrEmailLabel=Benutzername oder E-Mail +kbEmailLabel=E-Mail +kbPasswordLabel=Passwort +kbUsernamePlaceholder=Benutzername oder E-Mail eingeben +kbPasswordPlaceholder=Passwort eingeben +kbRememberMeLabel=Angemeldet bleiben +kbForgotPasswordLabel=Passwort vergessen? +kbLoginButtonLabel=Anmelden +kbRegisterButtonLabel=Registrieren +kbClockDateFallback=Montag, 18. November +kbClockTimeFallback=4:20 +kbInfoTitle=Willkommen im Familienbereich +kbInfoBody=Dieser geschützte Dashboardbereich ist für die Familie Kusche und enge Freunde gedacht. Hier entstehen persönliche Übersichten, gemeinsame Tools, interne Informationen und künftig weitere digitale Bereiche für Alltag, Organisation und Zusammenarbeit. diff --git a/CopyToKeycloak/theme/kusche-desktop/login/messages/messages_de.properties b/CopyToKeycloak/theme/kusche-desktop/login/messages/messages_de.properties index 91d70f03..039c2615 100644 --- a/CopyToKeycloak/theme/kusche-desktop/login/messages/messages_de.properties +++ b/CopyToKeycloak/theme/kusche-desktop/login/messages/messages_de.properties @@ -1,2 +1,16 @@ kbDesktopLoginHeadline=Desktop Login -kbDesktopLoginSubheadline=Geschuetzter Zugang zur Desktop-Shell und kuenftigen Modulen. +kbDesktopLoginSubheadline=Melde dich mit deinem Kusche.Berlin Konto an, um auf Desktop, Apps und geschützte Inhalte zuzugreifen. +kbUsernameLabel=Benutzername +kbUsernameOrEmailLabel=Benutzername oder E-Mail +kbEmailLabel=E-Mail +kbPasswordLabel=Passwort +kbUsernamePlaceholder=Benutzername oder E-Mail eingeben +kbPasswordPlaceholder=Passwort eingeben +kbRememberMeLabel=Angemeldet bleiben +kbForgotPasswordLabel=Passwort vergessen? +kbLoginButtonLabel=Anmelden +kbRegisterButtonLabel=Registrieren +kbClockDateFallback=Montag, 18. November +kbClockTimeFallback=4:20 +kbInfoTitle=Willkommen im Familienbereich +kbInfoBody=Dieser geschützte Dashboardbereich ist für die Familie Kusche und enge Freunde gedacht. Hier entstehen persönliche Übersichten, gemeinsame Tools, interne Informationen und künftig weitere digitale Bereiche für Alltag, Organisation und Zusammenarbeit. diff --git a/CopyToKeycloak/theme/kusche-desktop/login/messages/messages_en.properties b/CopyToKeycloak/theme/kusche-desktop/login/messages/messages_en.properties index bbe12f3f..34b66389 100644 --- a/CopyToKeycloak/theme/kusche-desktop/login/messages/messages_en.properties +++ b/CopyToKeycloak/theme/kusche-desktop/login/messages/messages_en.properties @@ -1,2 +1,16 @@ kbDesktopLoginHeadline=Desktop Login -kbDesktopLoginSubheadline=Protected access to the desktop shell and upcoming modules. +kbDesktopLoginSubheadline=Sign in with your Kusche.Berlin account to access the desktop, apps and protected content. +kbUsernameLabel=Username +kbUsernameOrEmailLabel=Username or email +kbEmailLabel=Email +kbPasswordLabel=Password +kbUsernamePlaceholder=Enter your username or email +kbPasswordPlaceholder=Enter your password +kbRememberMeLabel=Keep me signed in +kbForgotPasswordLabel=Forgot password? +kbLoginButtonLabel=Sign in +kbRegisterButtonLabel=Register +kbClockDateFallback=Monday, November 18 +kbClockTimeFallback=4:20 +kbInfoTitle=Welcome to the family area +kbInfoBody=This protected dashboard area is intended for the Kusche family and close friends. It will host personal overviews, shared tools, internal information and additional digital spaces for daily life, organization and collaboration. diff --git a/CopyToKeycloak/theme/kusche-desktop/login/resources/css/desktop-kusche.css b/CopyToKeycloak/theme/kusche-desktop/login/resources/css/desktop-kusche.css index 18aa61ab..de1d5873 100644 --- a/CopyToKeycloak/theme/kusche-desktop/login/resources/css/desktop-kusche.css +++ b/CopyToKeycloak/theme/kusche-desktop/login/resources/css/desktop-kusche.css @@ -1,307 +1,339 @@ html, body { min-height: 100%; + width: 100%; +} + +html { + margin: 0; + padding: 0; + background: transparent !important; } body { + margin: 0; overflow: hidden; background: - radial-gradient(circle at top, rgba(255, 221, 177, 0.30), transparent 26%), - radial-gradient(circle at 80% 18%, rgba(132, 216, 166, 0.18), transparent 20%), - linear-gradient(145deg, #101827 0%, #18253d 52%, #0f172a 100%) !important; + radial-gradient(circle at 18% 84%, rgba(142, 214, 119, 0.22), transparent 18%), + linear-gradient(0deg, rgba(14, 27, 19, 0.28), rgba(14, 27, 19, 0.28)), + linear-gradient(135deg, #26442b 0%, #355939 26%, #5f4a2f 58%, #22351f 100%) !important; color: #f8fafc; + font-family: "Roboto", "Helvetica Neue", Arial, sans-serif; +} + +body > div, +body > main { + margin: 0; } #kc-header-wrapper, #kc-header, .login-pf-header, -#kc-page-title { +#kc-page-title, +#kc-locale, +#kc-social-providers { display: none !important; } -#kc-content, #kc-content-wrapper, -#kc-locale { +#kc-content, +#kc-form, +#kc-form-wrapper, +.login-pf-page, +.card-pf, +.pf-v5-c-login, +.pf-v5-c-login__container, +.pf-v5-c-login__main, +.pf-v5-c-login__main-body, +.pf-v5-c-login__main-header, +.pf-v5-c-login__main-footer { + margin: 0 !important; + padding: 0 !important; + max-width: none !important; + width: 100% !important; + border: 0 !important; + background: transparent !important; + box-shadow: none !important; +} + +#kc-content-wrapper, +#kc-content, +.login-pf-page, +.pf-v5-c-login, +.pf-v5-c-login__container, +.pf-v5-c-login__main, +.pf-v5-c-login__main-body { + min-height: 100vh !important; +} + +#kc-content, +#kc-content-wrapper { position: relative; z-index: 2; } -#kc-content-wrapper { - padding-top: 48px; -} - -#kc-form-login .pf-v5-c-form__label-text, -#kc-form-login label, -#kc-form-options, -#kc-form-options a, -#kc-registration, -#kc-registration a, -#kc-social-providers h2, -#kc-social-providers a { - color: #e2e8f0 !important; +.pf-v5-c-alert, +.alert, +#kc-info-message, +#kc-error-message { + margin: 0 0 14px !important; + border: 0 !important; + border-radius: 12px !important; + background: rgba(127, 29, 29, 0.24) !important; + color: #fee2e2 !important; + box-shadow: none !important; } .kb-login-shell { position: relative; - isolation: isolate; - display: grid; - place-items: center; - min-height: calc(100vh - 80px); - padding: 24px; + min-height: 100vh; + padding: 24px 20px 30px; } -.kb-login-curtains { - position: absolute; - inset: 0; - z-index: 0; - pointer-events: none; -} - -.kb-login-curtain { - position: absolute; - left: 50%; - width: 180vmax; - height: 180vmax; - opacity: 0.72; - border-radius: 999px; - transform-origin: 0 50%; - animation-duration: 1.1s; - animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); - animation-fill-mode: both; -} - -.kb-login-curtain-top, -.kb-login-curtain-bottom { - top: 50%; - margin-top: -90vmax; -} - -.kb-login-curtain-top-a { - background: linear-gradient(135deg, rgba(236, 101, 105, 0.84), rgba(238, 175, 129, 0.76)); - animation-name: kbLoginCurtainTopA; -} - -.kb-login-curtain-top-b { - background: linear-gradient(135deg, rgba(255, 196, 104, 0.66), rgba(255, 138, 76, 0.6)); - animation-name: kbLoginCurtainTopB; - animation-delay: 0.08s; -} - -.kb-login-curtain-bottom-a { - background: linear-gradient(135deg, rgba(96, 184, 212, 0.7), rgba(55, 69, 181, 0.82)); - animation-name: kbLoginCurtainBottomA; - animation-delay: 0.12s; -} - -.kb-login-curtain-bottom-b { - background: linear-gradient(135deg, rgba(72, 125, 255, 0.5), rgba(36, 74, 179, 0.76)); - animation-name: kbLoginCurtainBottomB; - animation-delay: 0.18s; -} - -.kb-login-ambient { +.kb-login-backdrop { position: absolute; inset: 0; background: - radial-gradient(circle at 50% 10%, rgba(255, 255, 255, 0.12), transparent 30%), - url("../img/desktop-glow.svg") center/cover no-repeat; - opacity: 0.9; + linear-gradient(180deg, rgba(255, 255, 255, 0.10), transparent 16%), + radial-gradient(circle at center, transparent 24%, rgba(7, 13, 8, 0.18) 100%); pointer-events: none; } -.kb-login-panel { +.kb-login-stage { position: relative; z-index: 1; display: grid; - gap: 22px; - width: min(540px, 100%); - padding: 28px; - border-radius: 28px; - background: rgba(15, 23, 42, 0.62); - border: 1px solid rgba(255, 255, 255, 0.12); - box-shadow: 0 24px 80px rgba(2, 6, 23, 0.42); - backdrop-filter: blur(24px); - opacity: 0; - transform: translateY(28px) scale(0.98); - animation: kbLoginPanelReveal 0.72s cubic-bezier(0.22, 1, 0.36, 1) 0.35s forwards; - transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease; + justify-items: center; + align-content: start; + min-height: 100vh; } -.kb-login-panel:hover, -.kb-login-panel:focus-within { - transform: translateY(-2px); - box-shadow: 0 30px 90px rgba(2, 6, 23, 0.5); - border-color: rgba(255, 255, 255, 0.18); +.kb-login-clock { + margin-top: 22px; + text-align: center; + color: #f8fafc; + text-shadow: 0 10px 28px rgba(0, 0, 0, 0.22); } -.kb-login-copy { +.kb-login-date { + margin: 0 0 6px; + font-size: 1.15rem; + font-weight: 400; + letter-spacing: 0.01em; + opacity: 0.9; +} + +.kb-login-time { + margin: 0; + font-size: clamp(3.8rem, 8vw, 5.6rem); + font-weight: 300; + line-height: 1; +} + +.kb-login-card { + width: min(420px, 100%); + margin-top: 148px; display: grid; - gap: 12px; + gap: 14px; +} + +.kb-login-brand { + text-align: center; } .kb-login-kicker { margin: 0; - font-size: 12px; - letter-spacing: 0.18em; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.22em; text-transform: uppercase; - color: #cbd5e1; + color: rgba(248, 250, 252, 0.84); } -.kb-login-title { +.kb-login-form-shell, +#kc-form-wrapper, +#kc-form { margin: 0; - font-size: clamp(30px, 4vw, 40px); - line-height: 1.04; - color: #f8fafc; -} - -.kb-login-subtitle { - margin: 0; - color: #dbe5f1; - line-height: 1.55; -} - -.kb-login-form-shell { - margin: 0; -} - -#kc-form-wrapper { padding: 0 !important; } -#kc-form-login input[type="text"], -#kc-form-login input[type="password"] { +#kc-form-login { + display: grid; + gap: 12px; +} + +.kb-form-group { + display: grid; + gap: 6px; +} + +.kb-text-input { + width: 100%; min-height: 48px; - border-radius: 14px !important; - border: 1px solid rgba(255, 255, 255, 0.12) !important; - background: rgba(255, 255, 255, 0.08) !important; + padding: 0 18px; + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 999px; + background: rgba(213, 223, 193, 0.54) !important; color: #f8fafc !important; - box-shadow: none !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08) !important; + outline: none; + font-size: 1rem; + backdrop-filter: blur(9px); + transition: background-color 160ms ease, border-color 160ms ease; } -#kc-form-login input::placeholder { - color: rgba(226, 232, 240, 0.72); +.kb-text-input:focus { + border-color: rgba(255, 255, 255, 0.26); + background: rgba(213, 223, 193, 0.62) !important; } -#kc-form-login .pf-v5-c-button, -#kc-login { - min-height: 50px; - border: 0 !important; - border-radius: 14px !important; - background: linear-gradient(180deg, #a7f3d0, #6ee7b7) !important; - color: #082032 !important; +.kb-text-input::placeholder { + color: rgba(248, 250, 252, 0.82); +} + +.kb-password-row { + position: relative; +} + +.kb-text-input-password { + padding-right: 56px; +} + +.kb-password-toggle { + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; + border-radius: 999px; + background: transparent; + color: rgba(248, 250, 252, 0.88); + cursor: pointer; +} + +.kb-input-error { + padding: 0 10px; + font-size: 0.82rem; + color: #fee2e2; +} + +.kb-form-meta { + display: grid; + gap: 8px; + margin-top: 2px; +} + +.kb-remember { + display: inline-flex; + align-items: center; + gap: 8px; + color: rgba(248, 250, 252, 0.94); + font-size: 0.88rem; + cursor: pointer; +} + +.kb-remember input { + width: 16px; + height: 16px; + accent-color: #93c5fd; +} + +.kb-form-links a { + color: rgba(248, 250, 252, 0.94) !important; + text-decoration: none; + font-size: 0.88rem; +} + +.kb-form-links a:hover, +.kb-registration a:hover { + text-decoration: underline; +} + +.kb-form-submit { + margin-top: 4px; +} + +.kb-submit-button, +.kb-register-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 48px; + border: 0; + border-radius: 999px; + font-size: 0.98rem; font-weight: 700; - box-shadow: none !important; - transition: transform 160ms ease, filter 160ms ease, background-color 160ms ease, border-color 160ms ease; + letter-spacing: 0.01em; + text-decoration: none; + cursor: pointer; + transition: transform 160ms ease, filter 160ms ease; } -#kc-form-login .pf-v5-c-button:hover, -#kc-login:hover { +.kb-submit-button { + background: linear-gradient(180deg, #7dd3fc, #0ea5e9) !important; + color: #082f49 !important; + box-shadow: 0 14px 30px rgba(14, 165, 233, 0.22); +} + +.kb-register-button { + background: rgba(248, 250, 252, 0.18); + color: #f8fafc !important; + border: 1px solid rgba(248, 250, 252, 0.16); + backdrop-filter: blur(8px); +} + +.kb-submit-button:hover, +.kb-register-button:hover { transform: translateY(-1px); filter: brightness(1.03); } -#kc-form-options { - padding-top: 4px; +.kb-login-info { + padding: 16px 18px; + border-radius: 18px; + background: rgba(9, 18, 12, 0.34); + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(248, 250, 252, 0.94); + backdrop-filter: blur(10px); } -#kc-form-options .checkbox label { - display: inline-flex; - align-items: center; - gap: 8px; +.kb-login-info strong { + display: block; + margin-bottom: 6px; + font-size: 0.95rem; } -#kc-social-providers { - margin-top: 10px; +.kb-login-info p { + margin: 0; + font-size: 0.9rem; + line-height: 1.5; } -#kc-social-providers hr { - border-color: rgba(255, 255, 255, 0.12); -} - -#kc-registration { - margin-top: 18px; -} - -.pf-v5-c-alert { - border-radius: 14px !important; -} - -@keyframes kbLoginPanelReveal { - from { - opacity: 0; - transform: translateY(28px) scale(0.98); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes kbLoginCurtainTopA { - from { - transform: rotate(46deg) translateX(0); - } - - to { - transform: rotate(46deg) translateX(22vmax); - } -} - -@keyframes kbLoginCurtainTopB { - from { - transform: rotate(136deg) translateX(0); - } - - to { - transform: rotate(136deg) translateX(18vmax); - } -} - -@keyframes kbLoginCurtainBottomA { - from { - transform: rotate(-44deg) translateX(0); - } - - to { - transform: rotate(-44deg) translateX(22vmax); - } -} - -@keyframes kbLoginCurtainBottomB { - from { - transform: rotate(-134deg) translateX(0); - } - - to { - transform: rotate(-134deg) translateX(18vmax); - } -} - -@media (prefers-reduced-motion: reduce) { - .kb-login-curtain, - .kb-login-panel { - animation: none; - } - - .kb-login-panel { - opacity: 1; - transform: none; - } +.kb-registration { + width: min(420px, 100%); + margin: 14px auto 0; } @media (max-width: 640px) { - #kc-content-wrapper { - padding-top: 20px; - } - .kb-login-shell { - min-height: auto; - padding: 16px; + padding: 18px 14px 24px; } - .kb-login-panel { - padding: 22px 18px; - border-radius: 22px; + .kb-login-clock { + margin-top: 10px; + } + + .kb-login-time { + font-size: 3.7rem; + } + + .kb-login-card { + margin-top: 88px; } } diff --git a/CopyToKeycloak/theme/kusche-desktop/login/resources/js/desktop-register-link.js b/CopyToKeycloak/theme/kusche-desktop/login/resources/js/desktop-register-link.js new file mode 100644 index 00000000..cb660056 --- /dev/null +++ b/CopyToKeycloak/theme/kusche-desktop/login/resources/js/desktop-register-link.js @@ -0,0 +1,22 @@ +(function () { + const registerLink = document.getElementById('kb-register-button'); + + if (!registerLink) { + return; + } + + const params = new URLSearchParams(window.location.search); + const redirectUri = params.get('redirect_uri'); + + if (!redirectUri) { + return; + } + + try { + const target = new URL(redirectUri); + registerLink.href = target.origin + '/auth/register'; + registerLink.hidden = false; + } catch (error) { + console.warn('Registrierungslink konnte nicht vorbereitet werden.', error); + } +}()); diff --git a/CopyToKeycloak/theme/kusche-desktop/login/resources/js/login-clock.js b/CopyToKeycloak/theme/kusche-desktop/login/resources/js/login-clock.js new file mode 100644 index 00000000..a660680a --- /dev/null +++ b/CopyToKeycloak/theme/kusche-desktop/login/resources/js/login-clock.js @@ -0,0 +1,26 @@ +(function () { + const dateNode = document.getElementById('kb-login-date'); + const timeNode = document.getElementById('kb-login-time'); + + if (!dateNode || !timeNode) { + return; + } + + const renderClock = () => { + const now = new Date(); + + dateNode.textContent = new Intl.DateTimeFormat(document.documentElement.lang || 'de', { + weekday: 'long', + month: 'long', + day: 'numeric', + }).format(now); + + timeNode.textContent = new Intl.DateTimeFormat(document.documentElement.lang || 'de', { + hour: 'numeric', + minute: '2-digit', + }).format(now); + }; + + renderClock(); + window.setInterval(renderClock, 1000); +}()); diff --git a/config/registration.php b/config/registration.php new file mode 100644 index 00000000..00fe2247 --- /dev/null +++ b/config/registration.php @@ -0,0 +1,25 @@ + true, + 'storage_path' => 'data/registration-requests.json', + 'ldif_export_dir' => 'data/registration-ldif', + 'approver_groups' => ['administrators', 'appadmin'], + 'approver_usernames' => [], + 'ldap' => [ + 'users_dn' => 'cn=users,dc=nas,dc=kusche,dc=berlin', + 'default_groups' => [ + 'cn=generaluser,cn=groups,dc=nas,dc=kusche,dc=berlin', + ], + 'default_login_shell' => '/bin/sh', + 'home_directory_prefix' => '/home', + 'shadow_expire' => 1, + 'shadow_flag' => 0, + 'shadow_inactive' => 0, + 'shadow_max' => 99999, + 'shadow_min' => 0, + 'shadow_warning' => 7, + ], +]; diff --git a/partials/auth/login_screen.php b/partials/auth/login_screen.php index b10e4ac4..bebd5f45 100644 --- a/partials/auth/login_screen.php +++ b/partials/auth/login_screen.php @@ -6,6 +6,7 @@ $productName = (string) ($loginScreen['branding']['product_name'] ?? 'Kusche.Ber $headline = (string) ($loginScreen['branding']['headline'] ?? 'Desktop Login'); $subheadline = (string) ($loginScreen['branding']['subheadline'] ?? ''); $loginUrl = $loginScreen['login_url'] ?? null; +$registerUrl = $loginScreen['register_url'] ?? null; $previewUrl = $loginScreen['preview_url'] ?? null; $keycloakReady = (bool) ($loginScreen['keycloak_ready'] ?? false); $callbackUrl = (string) ($loginScreen['callback_url'] ?? ''); @@ -36,6 +37,10 @@ $callbackUrl = (string) ($loginScreen['callback_url'] ?? ''); Desktop-Vorschau + + + Registrierung anfragen +
diff --git a/public/admin/registrations/index.php b/public/admin/registrations/index.php new file mode 100644 index 00000000..64bf5d8c --- /dev/null +++ b/public/admin/registrations/index.php @@ -0,0 +1,222 @@ +canManage()) { + http_response_code(403); + echo 'Kein Zugriff auf Registrierungsfreigaben.'; + exit; +} + +$csrfToken = (string) ($_SESSION['registration_admin_token'] ?? ''); + +if ($csrfToken === '') { + $csrfToken = bin2hex(random_bytes(16)); + $_SESSION['registration_admin_token'] = $csrfToken; +} + +$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : []; +$reviewer = (string) ($currentUser['username'] ?? 'unbekannt'); +$selectedId = trim((string) ($_GET['id'] ?? $_POST['id'] ?? '')); +$errors = []; +$notice = null; + +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') { + $result = $registration->approve($selectedId, array_map('strval', $_POST), $reviewer); + + if (($result['success'] ?? false) !== true) { + $errors = array_values(array_map('strval', (array) ($result['errors'] ?? []))); + } else { + $notice = 'Registrierung freigegeben. Ein LDIF-Entwurf für den inaktiven Benutzer wurde erzeugt.'; + } + } elseif ($action === 'reject') { + $result = $registration->reject($selectedId, (string) ($_POST['reject_reason'] ?? ''), $reviewer); + + if (($result['success'] ?? false) !== true) { + $errors = array_values(array_map('strval', (array) ($result['errors'] ?? []))); + } else { + $notice = 'Registrierung abgelehnt.'; + } + } + } +} + +$requests = $registration->all(); +$selected = $selectedId !== '' ? $registration->find($selectedId) : ($requests[0] ?? null); +$provisioning = is_array($selected['provisioning'] ?? null) ? $selected['provisioning'] : []; +?> + + + + + Registrierungsfreigaben + + + +
+ +
+ + diff --git a/public/assets/auth/login.css b/public/assets/auth/login.css index 5462f5dc..ab2d83f2 100644 --- a/public/assets/auth/login.css +++ b/public/assets/auth/login.css @@ -33,6 +33,14 @@ body { backdrop-filter: blur(24px); } +.login-panel-wide { + width: min(760px, 100%); +} + +.login-panel-admin { + width: min(1180px, 100%); +} + .login-panel-top { margin-bottom: 24px; } @@ -72,6 +80,9 @@ body { border-radius: 14px; text-decoration: none; font-weight: 700; + border: 0; + cursor: pointer; + font: inherit; } .login-button-primary { @@ -91,12 +102,189 @@ body { border: 1px dashed rgba(255, 255, 255, 0.18); } +.login-button-danger { + color: #fff1f2; + background: linear-gradient(180deg, #fb7185, #e11d48); +} + +.login-form { + display: grid; + gap: 18px; +} + +.login-form-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.login-field { + display: grid; + gap: 8px; +} + +.login-field span { + font-size: 13px; + color: #cbd5e1; +} + +.login-field input, +.login-field textarea { + width: 100%; + min-height: 48px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(15, 23, 42, 0.52); + color: #f8fafc; + font: inherit; +} + +.login-field textarea { + min-height: 120px; + resize: vertical; +} + +.login-field-span-2 { + grid-column: span 2; +} + +.login-notice { + margin-bottom: 18px; + padding: 16px 18px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.login-notice strong { + display: block; + margin-bottom: 6px; +} + +.login-notice p { + margin: 0; + line-height: 1.5; +} + +.login-notice-success { + background: rgba(20, 83, 45, 0.45); +} + +.login-notice-error { + background: rgba(127, 29, 29, 0.4); +} + +.login-notice-info { + background: rgba(30, 41, 59, 0.5); +} + +.login-list { + margin: 0; + padding-left: 18px; +} + +.login-list li + li { + margin-top: 6px; +} + +.login-meta-inline { + margin-top: 8px !important; + font-size: 0.92rem; + color: #cbd5e1; +} + .login-meta { display: grid; gap: 14px; margin: 0; } +.admin-layout { + display: grid; + gap: 24px; + grid-template-columns: 320px minmax(0, 1fr); +} + +.admin-sidebar h2, +.admin-summary h2 { + margin-top: 0; +} + +.admin-request-list { + display: grid; + gap: 10px; + max-height: 70vh; + overflow: auto; +} + +.admin-request-card { + display: grid; + gap: 6px; + padding: 14px; + border-radius: 16px; + text-decoration: none; + color: inherit; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.admin-request-card.is-active { + border-color: rgba(167, 243, 208, 0.56); + background: rgba(16, 185, 129, 0.12); +} + +.admin-request-card span { + color: #cbd5e1; + font-size: 0.92rem; +} + +.admin-status { + display: inline-flex; + width: fit-content; + padding: 4px 10px; + border-radius: 999px; + color: #e2e8f0 !important; + background: rgba(255, 255, 255, 0.12); +} + +.admin-detail { + min-width: 0; +} + +.admin-summary { + margin-bottom: 20px; +} + +.admin-summary p { + margin: 0 0 10px; + line-height: 1.5; +} + +.login-pre { + margin: 12px 0 0; + padding: 14px; + border-radius: 14px; + overflow: auto; + background: rgba(2, 6, 23, 0.65); + color: #dbeafe; +} + +@media (max-width: 900px) { + .admin-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .login-form-grid { + grid-template-columns: 1fr; + } + + .login-field-span-2 { + grid-column: auto; + } +} + .login-meta div { display: grid; gap: 4px; diff --git a/public/auth/login/index.php b/public/auth/login/index.php index 0bb68dd4..c01177da 100644 --- a/public/auth/login/index.php +++ b/public/auth/login/index.php @@ -18,6 +18,7 @@ $previewUrl = $auth->allowDesktopPreview() $loginScreen = [ 'branding' => $auth->branding(), 'login_url' => $auth->isConfigured() ? '/auth/keycloak' : null, + 'register_url' => '/auth/register', 'preview_url' => $previewUrl, 'keycloak_ready' => $auth->isConfigured(), 'callback_url' => $auth->redirectUri(), diff --git a/public/auth/register/index.php b/public/auth/register/index.php new file mode 100644 index 00000000..7990cf54 --- /dev/null +++ b/public/auth/register/index.php @@ -0,0 +1,153 @@ +isEnabled()) { + http_response_code(404); + echo 'Registrierung ist deaktiviert.'; + exit; +} + +$csrfToken = (string) ($_SESSION['registration_form_token'] ?? ''); + +if ($csrfToken === '') { + $csrfToken = bin2hex(random_bytes(16)); + $_SESSION['registration_form_token'] = $csrfToken; +} + +$errors = []; +$submitted = false; +$createdRequest = null; +$form = [ + 'username' => '', + 'given_name' => '', + 'family_name' => '', + 'email' => '', + 'note' => '', +]; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $form = [ + 'username' => trim((string) ($_POST['username'] ?? '')), + 'given_name' => trim((string) ($_POST['given_name'] ?? '')), + 'family_name' => trim((string) ($_POST['family_name'] ?? '')), + 'email' => trim((string) ($_POST['email'] ?? '')), + 'note' => trim((string) ($_POST['note'] ?? '')), + ]; + + $postedToken = (string) ($_POST['csrf_token'] ?? ''); + + if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) { + $errors[] = 'Die Formularprüfung ist fehlgeschlagen. Bitte versuche es erneut.'; + } else { + $result = $registration->submit($form); + + if (($result['success'] ?? false) !== true) { + $errors = array_values(array_map('strval', (array) ($result['errors'] ?? []))); + } else { + $submitted = true; + $createdRequest = $result['request'] ?? null; + $csrfToken = bin2hex(random_bytes(16)); + $_SESSION['registration_form_token'] = $csrfToken; + $form = [ + 'username' => '', + 'given_name' => '', + 'family_name' => '', + 'email' => '', + 'note' => '', + ]; + } + } +} +?> + + + + + Registrierung anfragen + + + +
+ +
+ + diff --git a/src/App/KeycloakAuth.php b/src/App/KeycloakAuth.php index d181975d..ff0a0cff 100644 --- a/src/App/KeycloakAuth.php +++ b/src/App/KeycloakAuth.php @@ -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 $tokenPayload + * @param array $userInfo + * @return array + */ + 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 + */ + 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 $fields * @return array{success: bool, data?: array, error?: string} diff --git a/src/App/RegistrationAccess.php b/src/App/RegistrationAccess.php new file mode 100644 index 00000000..61b50b7e --- /dev/null +++ b/src/App/RegistrationAccess.php @@ -0,0 +1,37 @@ + $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) !== []; + } +} diff --git a/src/App/RegistrationLdifBuilder.php b/src/App/RegistrationLdifBuilder.php new file mode 100644 index 00000000..6e517405 --- /dev/null +++ b/src/App/RegistrationLdifBuilder.php @@ -0,0 +1,161 @@ + $config + */ + public function __construct( + private readonly array $config, + ) { + } + + /** + * @param array $request + * @param array $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; + } +} diff --git a/src/App/RegistrationService.php b/src/App/RegistrationService.php new file mode 100644 index 00000000..09d1dbea --- /dev/null +++ b/src/App/RegistrationService.php @@ -0,0 +1,274 @@ + $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> + */ + public function all(): array + { + return $this->store->all(); + } + + /** + * @return array|null + */ + public function find(string $id): ?array + { + return $this->store->find($id); + } + + /** + * @param array $input + * @return array{success: bool, errors?: array, request?: array} + */ + 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 $input + * @return array{success: bool, errors?: array, request?: array} + */ + 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, request?: array} + */ + 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, '/'); + } +} diff --git a/src/App/RegistrationStore.php b/src/App/RegistrationStore.php new file mode 100644 index 00000000..956a1375 --- /dev/null +++ b/src/App/RegistrationStore.php @@ -0,0 +1,150 @@ +> + */ + 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|null + */ + public function find(string $id): ?array + { + foreach ($this->read() as $item) { + if ((string) ($item['id'] ?? '') === $id) { + return $item; + } + } + + return null; + } + + /** + * @return array|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 $payload + * @return array + */ + public function create(array $payload): array + { + $items = $this->read(); + $items[] = $payload; + $this->write($items); + + return $payload; + } + + /** + * @param callable(array): array $mutator + * @return array|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> + */ + 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> $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 + ); + } +} diff --git a/temp/MODERIERTE_REGISTRIERUNG_KONZEPT.md b/temp/MODERIERTE_REGISTRIERUNG_KONZEPT.md new file mode 100644 index 00000000..fd301355 --- /dev/null +++ b/temp/MODERIERTE_REGISTRIERUNG_KONZEPT.md @@ -0,0 +1,213 @@ +# Moderierte Registrierung für desktop.kusche.berlin + +## Ziel + +Neue Nutzer sollen sich über die Login-Seite registrieren können, aber **nicht sofort** Zugriff auf den Desktop erhalten. + +Stattdessen soll der Ablauf so sein: + +1. Nutzer füllt Registrierungsformular aus. +2. Anfrage wird als `ausstehend` gespeichert. +3. Eine berechtigte Person prüft und genehmigt oder lehnt ab. +4. Erst bei Genehmigung wird der LDAP-/NAS-kompatible Benutzer vollständig angelegt. +5. Erst danach darf sich der Nutzer einloggen. + +## Warum der normale Keycloak-Registrierungsflow hier nicht reicht + +Der Standard-Registrierungsflow von Keycloak ist für euer Setup ungeeignet, weil: + +- Keycloak aktuell nur einen sehr schlanken LDAP-Eintrag erzeugt +- die NAS aber deutlich mehr Attribute erwartet +- Benutzer bei Standard-Registrierung sofort im Keycloak-Kontext existieren +- eine nachträgliche manuelle Freigabe damit unnötig kompliziert wird + +Für eure Umgebung ist es sauberer, die Registrierung **vor** Keycloak bzw. **neben** Keycloak zu behandeln. + +## Empfohlene Architektur + +### 1. Eigenes Registrierungsformular im Projekt + +Nicht Keycloak `Registrieren` verwenden, sondern: + +- eigene Seite, z. B. `/auth/register` +- eigenes Formular für: + - Benutzername + - Vorname + - Nachname + - E-Mail + - Passwort + - optional Begründung / Einladungscode / Referenz + +Diese Daten landen **nicht direkt im LDAP**, sondern zuerst in einer Warteschlange. + +### 2. Pending-Store für Registrierungsanfragen + +Die Registrierung wird lokal gespeichert, z. B.: + +- JSON-Datei in `data/registration-requests.json` +- oder besser SQLite / kleine Datenbank + +Status je Eintrag: + +- `pending` +- `approved` +- `rejected` +- `provisioned` +- `failed` + +Zusätzlich speichern: + +- Zeitstempel +- IP +- Browser +- gewünschter Benutzername +- Mail +- Vorname / Nachname +- gehashter Passwort-Entwurf oder temporärer verschlüsselter Wert + +## Wichtig zum Passwort + +Passwörter dürfen **nicht im Klartext** in so einer Warteschlange liegen. + +Saubere Varianten: + +1. Passwort erst nach Freigabe setzen lassen +2. oder verschlüsselt zwischenlagern +3. oder nach Freigabe Einladungslink / Set-Password-Link senden + +Empfehlung: + +- bei Registrierung noch **kein finales Passwort** speichern +- nach Freigabe bekommt der Nutzer einen Aktivierungslink +- Passwort wird erst dann final gesetzt + +Das ist deutlich sauberer und sicherer. + +### 3. Admin-/Freigabe-Tool + +Es braucht ein internes Tool, z. B.: + +- Desktop-App `Benutzerfreigaben` +- oder vorerst einfache Admin-Seite `/admin/registrations` + +Funktionen: + +- offene Registrierungen anzeigen +- Details prüfen +- genehmigen +- ablehnen +- Notiz hinterlegen + +Nur bestimmte Gruppen dürfen das: + +- `administrators` +- optional `appadmin` + +### 4. Provisioning-Tool für LDAP/NAS + +Bei Genehmigung startet **euer eigenes Provisioning-Tool**. + +Dieses Tool erzeugt dann den Benutzer korrekt für die NAS, also mit allen nötigen Feldern: + +- passende `objectClass` +- `uid` +- `cn` +- `sn` +- `mail` +- `uidNumber` +- `gidNumber` +- `homeDirectory` +- `loginShell` +- ggf. `displayName` +- ggf. `gecos` +- ggf. Apple-/Samba-/Shadow-Felder +- passende Gruppenmitgliedschaften + +Genau dieser Teil sollte **nicht** Keycloak überlassen werden. + +### 5. Aktivierung für Keycloak-Login + +Nach erfolgreicher LDAP-Anlage: + +- Nutzerstatus im Pending-Store auf `provisioned` +- Login ab dann erlaubt + +Optional zusätzlich: + +- Nutzer in bestimmte Startgruppen setzen +- Mail zur Freischaltung versenden +- Passwort-Setzen-Link generieren + +## Technische Varianten + +### Variante A: Registrierung komplett außerhalb von Keycloak + +Flow: + +1. Login-Seite hat Button `Registrieren` +2. Button führt auf eure eigene Registrierungsseite +3. Anfrage landet lokal im Pending-Store +4. Admin genehmigt +5. euer Tool legt LDAP-User korrekt an +6. danach Login über Keycloak gegen LDAP + +Das ist die empfohlene Variante. + +### Variante B: Keycloak-Registrierung bleibt aktiv + +Flow: + +1. Nutzer registriert sich in Keycloak +2. Benutzer wird dort oder im LDAP teilweise angelegt +3. zusätzliche Freigabelogik blockiert Login + +Nachteile: + +- unsauberer Zustand +- doppelte Logik +- schwerer kontrollierbar +- LDAP-/NAS-Kompatibilität bleibt problematisch + +Diese Variante würde ich nicht empfehlen. + +## Empfohlene Umsetzung für dieses Projekt + +### Phase 1 + +- Keycloak-Registrierung im Login-Theme wieder entfernen oder deaktivieren +- eigene Route `/auth/register` +- einfache Ablage als JSON oder SQLite +- Admin-Ansicht für Freigaben + +### Phase 2 + +- eigenes LDAP-Provisioning-Skript / Service +- Vergabe der fehlenden NAS-relevanten Attribute +- Gruppen-Standardzuweisung + +### Phase 3 + +- Aktivierungs-Mail oder Passwort-Setz-Link +- Audit-Log +- Benachrichtigungen für Administratoren + +## Konkrete Entscheidungsempfehlung + +Ja, wir können dafür ein Tool bauen. + +Die saubere Lösung ist: + +- **nicht** die Keycloak-Registrierung zu verbiegen +- sondern eine **eigene moderierte Registrierung** im Projekt zu bauen +- und erst nach Freigabe den LDAP-/NAS-Benutzer korrekt zu provisionieren + +## Nächster sinnvoller Schritt + +Als nächstes sollten wir bauen: + +1. `Registrieren` im Login nicht mehr auf Keycloak zeigen lassen +2. neue Projektseite `/auth/register` +3. lokaler `pending`-Store +4. interne Admin-Seite für Freigaben + +Danach kommt das LDAP-Provisioning.