feat(auth): harden login flows and finalize quality-check coverage

This commit is contained in:
2026-03-07 22:54:33 +01:00
parent aefe80457c
commit 7153fec05c
20 changed files with 1769 additions and 28 deletions

View File

@@ -0,0 +1,297 @@
{
"task_id": "LOGIN-AUTH-QUALITY-CHECK-001",
"plan_ref": "agent-system/runs/LOGIN-AUTH-QUALITY-CHECK-001/plan.json",
"status": "done",
"changed_files": [
{
"path": "agent-system/runs/LOGIN-AUTH-QUALITY-CHECK-001/guard-matrix.json",
"summary": "Explizite Guard-Matrix fuer alle In-Scope-Flows angelegt (Flow->Datei-Zuordnung, Guard-Refs, priorisierte Findings, priority_summary mit critical/high/medium open=0)."
},
{
"path": "pages/auth/register().php",
"summary": "Register-POST auf klares POST-Gate umgestellt und CSRF-Pruefung vor Request-Verarbeitung erzwungen; CSRF-Fehlerpfad konsistent umgesetzt."
},
{
"path": "templates/login.phtml",
"summary": "Lokalisierte Passwort-Toggle-Labels als Datenattribute fuer JS-Consumer bereitgestellt."
},
{
"path": "web/js/components/app-password-toggle.js",
"summary": "Login-Passwort-Toggle konsolidiert: duplicate-init-Guard eingefuehrt (passwordToggleBound), erneute Wrapper/Listener-Bindungen verhindert."
},
{
"path": "web/js/components/app-password-hints.js",
"summary": "Login-Passworthinweise konsolidiert: idempotente Container-Bindung eingefuehrt (passwordHintsBound), Mehrfach-Listener bei Re-Init verhindert."
},
{
"path": "web/css/pages/app-login.css",
"summary": "Ungenutzten Legacy-Selektor .back_to_login entfernt (konkrete CSS-Cleanup-Massnahme im Login-Scope)."
},
{
"path": "i18n/default_de.json",
"summary": "Neue i18n-Keys fuer Passwort-Toggle (Show/Hide password) in Deutsch ergaenzt."
},
{
"path": "i18n/default_en.json",
"summary": "Neue i18n-Keys fuer Passwort-Toggle (Show/Hide password) in Englisch ergaenzt."
},
{
"path": "tests/Service/Auth/PasswordResetServiceTest.php",
"summary": "Neue Unit-Tests fuer PasswordResetService (requestReset/verifyCode/resetPassword inkl. expiry/attempts/used/success)."
},
{
"path": "tests/Service/Auth/EmailVerificationServiceTest.php",
"summary": "Neue Unit-Tests fuer EmailVerificationService (sendVerification/verifyCode/resendVerification inkl. already_verified/attempt-limit)."
},
{
"path": "tests/Service/Auth/MicrosoftOidcServiceTest.php",
"summary": "Automatisierten SSO-Regressionstest fuer Callback-Domain-Policy ergänzt (email_domain_not_allowed)."
},
{
"path": "tests/Architecture/AuthLoginFrontendCleanupContractTest.php",
"summary": "Neuer Architektur-Contract fuer Frontend-Cleanup-Invarianten (kein .back_to_login, duplicate-binding guards in Login-JS)."
},
{
"path": "tests/Architecture/AuthLoginAccessibilityContractTest.php",
"summary": "A11y-Contract erweitert: Toggle muss lokalisierte Labels nutzen und darf kein tabIndex=-1 verwenden."
},
{
"path": "tests/Architecture/AuthRegisterSecurityContractTest.php",
"summary": "Neuer Security-Contract: Register-POST muss CSRF vor Payload-Verarbeitung pruefen."
}
],
"guard_evidence": [
{
"guard_id": "GR-CORE-001",
"status": "pass",
"evidence": "Guard-Matrix dokumentiert pro Auth-Flow die Layer-Zuordnung; Aenderungen bleiben in bestehender Schichtung (Pages orchestration, Services business logic)."
},
{
"guard_id": "GR-CORE-002",
"status": "pass",
"evidence": "QG-004 structural checks weiterhin 0; keine direkte Service/Gateway/Repository-Instanziierung in pages eingefuehrt."
},
{
"guard_id": "GR-CORE-003",
"status": "pass",
"evidence": "Auth pages nutzen weiterhin requestInput()-Vertrag; register nutzt now isMethod('POST') + requestInput body access ohne superglobal drift."
},
{
"guard_id": "GR-CORE-004",
"status": "pass",
"evidence": "QG-004 ApiResponse::readJsonBody check in pages/api/v1 bleibt 0."
},
{
"guard_id": "GR-CORE-005",
"status": "pass",
"evidence": "Keine Abschwaechung serverseitiger AuthZ-Pruefungen; Guard-Matrix zeigt keine offenen critical/high findings in AuthZ-relevanten Flows."
},
{
"guard_id": "GR-CORE-006",
"status": "pass",
"evidence": "Tenant-Scope-Invarianten unveraendert; SSO-Callback-Regression nun automatisiert mit Domain-Policy-Fehlerpfad abgesichert."
},
{
"guard_id": "GR-CORE-008",
"status": "pass",
"evidence": "Keine neuen direkten Superglobal-Zugriffe in pages/auth oder api pages eingefuehrt."
},
{
"guard_id": "GR-CORE-012",
"status": "pass",
"evidence": "Mutierende Erfolgsfaelle bleiben PRG-konform (register success -> redirect verify-email etc.)."
},
{
"guard_id": "GR-SEC-001",
"status": "pass",
"evidence": "Register-POST enforce't CSRF vor Payload-Verarbeitung; AuthRegisterSecurityContractTest bestaetigt die Reihenfolge."
},
{
"guard_id": "GR-SEC-002",
"status": "pass",
"evidence": "Keine neuen unredacted PII/secret logging paths eingefuehrt; neue Tests arbeiten mock-basiert ohne Log-Ausleitung."
},
{
"guard_id": "GR-SEC-003",
"status": "pass",
"evidence": "Keine SQL-String-Building-Aenderungen; QG-004 structural checks weiterhin 0 und neue Tests nutzen Repository-Interfaces."
},
{
"guard_id": "GR-SEC-004",
"status": "pass",
"evidence": "Keine externen CDN/remote assets in touched login templates/js/css eingefuehrt."
},
{
"guard_id": "GR-SEC-005",
"status": "pass",
"evidence": "Keine Kryptografie-Aenderungen in diesem Run; bestehende Crypto-Pfade unveraendert."
},
{
"guard_id": "GR-UI-009",
"status": "pass",
"evidence": "A11y bleibt intakt: AuthLoginAccessibilityContractTest gruen, Toggle keyboard-fokusfaehig und semantisch button-basiert."
},
{
"guard_id": "GR-UI-010",
"status": "pass",
"evidence": "Neue Toggle-Texte werden ueber t()-Keys bereitgestellt und in de/en gepflegt."
},
{
"guard_id": "GR-UI-011",
"status": "pass",
"evidence": "Login-JS konsolidiert: app-password-toggle und app-password-hints besitzen duplicate-binding guards (idempotente Initialisierung)."
},
{
"guard_id": "GR-UI-012",
"status": "pass",
"evidence": "Konkrete Login-CSS-Cleanup-Massnahme umgesetzt: ungenutzter Selektor .back_to_login entfernt; Contract testet Abwesenheit."
},
{
"guard_id": "GR-UI-013",
"status": "pass",
"evidence": "Interaktive Controls bleiben semantisch/keyboard-operabel; Toggle aktualisiert aria-labels korrekt (show/hide)."
},
{
"guard_id": "GR-UI-015",
"status": "pass",
"evidence": "i18n key completeness erfuellt: Show/Hide password in default_de.json und default_en.json vorhanden."
},
{
"guard_id": "GR-TEST-001",
"status": "pass",
"evidence": "Testabdeckung erweitert fuer Reset/Verify plus explizite SSO-Regression (MicrosoftOidcServiceTest callback domain policy)."
},
{
"guard_id": "GR-TEST-002",
"status": "pass",
"evidence": "Neue Tests bleiben DI/mock-freundlich ohne untestbare neue global side-effects."
},
{
"guard_id": "GR-LANG-002",
"status": "pass",
"evidence": "composer cs:check pass (0/518 files fixable)."
}
],
"commands": [
{
"cmd": "docker compose exec php vendor/bin/phpunit tests/Service/Auth/MicrosoftOidcServiceTest.php --no-progress",
"result": "pass"
},
{
"cmd": "docker compose exec php vendor/bin/phpunit tests/Architecture/AuthLoginFrontendCleanupContractTest.php --no-progress",
"result": "pass"
},
{
"cmd": "docker compose exec php vendor/bin/phpunit tests/Service/Auth/PasswordResetServiceTest.php tests/Service/Auth/EmailVerificationServiceTest.php --no-progress",
"result": "pass"
},
{
"cmd": "docker compose exec php vendor/bin/phpunit tests/Architecture/AuthLoginAccessibilityContractTest.php tests/Architecture/AuthRegisterSecurityContractTest.php --no-progress",
"result": "pass"
},
{
"cmd": "docker compose exec php vendor/bin/phpunit --no-progress",
"result": "pass"
},
{
"cmd": "docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress",
"result": "pass"
},
{
"cmd": "docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php --no-progress",
"result": "pass"
},
{
"cmd": "(rg 'new\\s+[^\\s(]+Factory\\(' lib/Service || true) | wc -l && (rg --glob '!**/*Factory.php' 'new\\s+[^\\s(]+(Service|Gateway|Repository)\\(' lib/Service || true) | wc -l && (rg \"\\b(accessServicesFactory|directoryServicesFactory|userServicesFactory|authServicesFactory|tenantServicesFactory|settingServicesFactory|userRepositoryFactory|authRepositoryFactory|authGatewayFactory)\\(\" pages lib templates web || true) | wc -l && (rg -n 'new\\s+[^\\s(]+(Service|Gateway|Repository)\\(' pages -g '*.php' || true) | wc -l && (rg -n '\\bDB::' pages -g '*.php' || true) | wc -l && (rg -n 'app\\([^\\n]*Factory::class\\)->create' pages/admin -g '*.php' || true) | wc -l && (rg -n 'Factory::class' pages/admin -g '*.php' || true) | wc -l && (rg -n 'ApiResponse::readJsonBody\\(' pages/api/v1 -g '*.php' || true) | wc -l",
"result": "pass"
},
{
"cmd": "docker compose exec php composer cs:check",
"result": "pass"
},
{
"cmd": "Manual browser smoke (QG-005) for /login, /password/forgot->verify->reset, /verify-email, /auth/microsoft/start+callback, devtools console",
"result": "pass"
}
],
"quality_gate_results": [
{
"gate_id": "QG-001",
"result": "pass",
"notes": "Exit code 0. PHPUnit full: 487 tests, 11030 assertions, Warnings 1, Deprecations 1 (non-blocking)."
},
{
"gate_id": "QG-002",
"result": "pass",
"notes": "Exit code 0. PHPStan: [OK] No errors."
},
{
"gate_id": "QG-003",
"result": "pass",
"notes": "Exit code 0. CoreStarterkitContractTest: 11 tests, 6167 assertions."
},
{
"gate_id": "QG-004",
"result": "pass",
"notes": "Alle strukturellen rg-check Zaehler sind 0 (8/8 checks gruen)."
},
{
"gate_id": "QG-005",
"result": "pass",
"notes": "Manueller Browser-Smoke zuvor erfolgreich bestaetigt (lokal+SSO+Recovery+Verify+Console)."
},
{
"gate_id": "QG-006",
"result": "pass",
"notes": "Exit code 0. composer cs:check: 0 von 518 Dateien fixbar."
}
],
"test_results": [
{
"name": "MicrosoftOidcServiceTest",
"result": "pass",
"notes": "15 tests, 61 assertions; inkl. neuer callback regression email_domain_not_allowed."
},
{
"name": "AuthLoginFrontendCleanupContractTest",
"result": "pass",
"notes": "3 tests, 15 assertions; prueft CSS cleanup + JS duplicate-binding guards."
},
{
"name": "PasswordResetServiceTest + EmailVerificationServiceTest",
"result": "pass",
"notes": "27 tests, 81 assertions."
},
{
"name": "AuthLoginAccessibilityContractTest + AuthRegisterSecurityContractTest",
"result": "pass",
"notes": "6 tests, 105 assertions."
},
{
"name": "CoreStarterkitContractTest",
"result": "pass",
"notes": "11 tests, 6167 assertions."
},
{
"name": "PHPUnit Full",
"result": "pass",
"notes": "487 tests, 11030 assertions, warnings/deprecations vorhanden aber kein failure."
},
{
"name": "PHPStan",
"result": "pass",
"notes": "No errors."
},
{
"name": "PHP CS Check",
"result": "pass",
"notes": "0/518 files fixable."
},
{
"name": "Manual Browser Smoke (QG-005)",
"result": "pass",
"notes": "Manueller Smoke erfolgreich bestaetigt."
}
],
"open_items": []
}

View File

@@ -0,0 +1,10 @@
{
"task_id": "LOGIN-AUTH-QUALITY-CHECK-001",
"ready_to_finalize": true,
"guard_review": "pass",
"acceptance_review": "pass",
"ci_status": "pass",
"final_action": "commit",
"commit_message": "feat(auth): finalize login auth quality check hardening and coverage",
"notes": "review-guards, review-acceptance und execution-report sind jeweils PASS; QG-001 bis QG-006 sind PASS."
}

View File

@@ -0,0 +1,231 @@
{
"task_id": "LOGIN-AUTH-QUALITY-CHECK-001",
"scope_ref": "agent-system/runs/LOGIN-AUTH-QUALITY-CHECK-001/plan.json",
"flows": [
{
"flow": "local login",
"files": [
"pages/auth/login().php",
"pages/auth/login(login).phtml",
"web/js/app-login-init.js",
"web/js/components/app-password-toggle.js",
"web/js/components/app-password-hints.js",
"web/css/pages/app-login.css"
],
"guard_ids": [
"GR-CORE-003",
"GR-CORE-005",
"GR-CORE-006",
"GR-SEC-001",
"GR-UI-009",
"GR-UI-010",
"GR-UI-011",
"GR-UI-012",
"GR-UI-013",
"GR-UI-015"
],
"findings": [
{
"id": "F-LOGIN-001",
"severity": "medium",
"status": "resolved",
"summary": "Password toggle lacked duplicate-init guard and keyboard focus resilience evidence.",
"evidence": "web/js/components/app-password-toggle.js now has passwordToggleBound guard; tests/Architecture/AuthLoginFrontendCleanupContractTest.php validates guard presence."
},
{
"id": "F-LOGIN-002",
"severity": "low",
"status": "resolved",
"summary": "Unused legacy login CSS selector remained in app-login.css.",
"evidence": "Selector .back_to_login removed; tests/Architecture/AuthLoginFrontendCleanupContractTest.php asserts absence."
}
]
},
{
"flow": "register",
"files": [
"pages/auth/register().php",
"pages/auth/register(login).phtml"
],
"guard_ids": [
"GR-CORE-003",
"GR-CORE-012",
"GR-SEC-001",
"GR-UI-009",
"GR-UI-010"
],
"findings": [
{
"id": "F-REGISTER-001",
"severity": "high",
"status": "resolved",
"summary": "Register POST accepted payload without upfront CSRF verification.",
"evidence": "pages/auth/register().php now enforces Session::checkCsrfToken() before payload handling; tests/Architecture/AuthRegisterSecurityContractTest.php green."
}
]
},
{
"flow": "forgot/verify/reset",
"files": [
"pages/auth/forgot().php",
"pages/auth/verify().php",
"pages/auth/reset().php",
"lib/Service/Auth/PasswordResetService.php",
"pages/auth/forgot(login).phtml",
"pages/auth/verify(login).phtml",
"pages/auth/reset(login).phtml"
],
"guard_ids": [
"GR-SEC-001",
"GR-SEC-002",
"GR-TEST-001",
"GR-TEST-002",
"GR-UI-009"
],
"findings": [
{
"id": "F-RESET-001",
"severity": "medium",
"status": "resolved",
"summary": "Edge-case unit regression coverage for reset lifecycle was incomplete.",
"evidence": "tests/Service/Auth/PasswordResetServiceTest.php adds requestReset/verifyCode/resetPassword edge and failure paths."
}
]
},
{
"flow": "verify-email",
"files": [
"pages/auth/verify-email().php",
"pages/auth/verify-email(login).phtml",
"lib/Service/Auth/EmailVerificationService.php"
],
"guard_ids": [
"GR-SEC-001",
"GR-SEC-002",
"GR-TEST-001",
"GR-TEST-002",
"GR-UI-009"
],
"findings": [
{
"id": "F-VERIFY-001",
"severity": "medium",
"status": "resolved",
"summary": "Automated coverage for verify-email resend/attempt-limit branches was incomplete.",
"evidence": "tests/Service/Auth/EmailVerificationServiceTest.php covers send/verify/resend including already_verified and attempt limit."
}
]
},
{
"flow": "microsoft start",
"files": [
"pages/auth/microsoft/start().php",
"lib/Service/Auth/TenantSsoService.php",
"lib/Service/Auth/MicrosoftOidcService.php"
],
"guard_ids": [
"GR-CORE-005",
"GR-CORE-006",
"GR-SEC-002",
"GR-TEST-001",
"GR-TEST-002"
],
"findings": [
{
"id": "F-MSSO-START-001",
"severity": "none",
"status": "none",
"summary": "No open finding.",
"evidence": "tests/Service/Auth/MicrosoftOidcServiceTest.php already covered startAuthorization negative and PKCE happy paths."
}
]
},
{
"flow": "microsoft callback",
"files": [
"pages/auth/microsoft/callback().php",
"lib/Service/Auth/MicrosoftOidcService.php",
"lib/Service/Auth/SsoUserLinkService.php",
"lib/Service/Auth/TenantSsoService.php"
],
"guard_ids": [
"GR-CORE-005",
"GR-CORE-006",
"GR-SEC-002",
"GR-TEST-001",
"GR-TEST-002"
],
"findings": [
{
"id": "F-MSSO-CALLBACK-001",
"severity": "medium",
"status": "resolved",
"summary": "Missing explicit automated regression case for domain-policy rejection in callback.",
"evidence": "tests/Service/Auth/MicrosoftOidcServiceTest.php now includes testHandleCallbackReturnsEmailDomainNotAllowedWhenTenantDomainPolicyRejectsUser."
}
]
},
{
"flow": "api auth login",
"files": [
"pages/api/v1/auth/login().php"
],
"guard_ids": [
"GR-CORE-004",
"GR-CORE-005",
"GR-SEC-002",
"GR-SEC-003",
"GR-CORE-006"
],
"findings": [
{
"id": "F-API-LOGIN-001",
"severity": "none",
"status": "none",
"summary": "No open finding.",
"evidence": "QG-004 structural checks remain 0 violations; no direct readJsonBody/superglobal drift introduced."
}
]
},
{
"flow": "api me password",
"files": [
"pages/api/v1/me/password().php"
],
"guard_ids": [
"GR-CORE-005",
"GR-SEC-002",
"GR-SEC-003",
"GR-TEST-001"
],
"findings": [
{
"id": "F-API-ME-PASSWORD-001",
"severity": "none",
"status": "none",
"summary": "No open finding.",
"evidence": "No API behavior change in this run; quality gates and architecture contracts stay green."
}
]
}
],
"priority_summary": {
"critical": {
"open": 0,
"resolved": 0
},
"high": {
"open": 0,
"resolved": 1
},
"medium": {
"open": 0,
"resolved": 4
},
"low": {
"open": 0,
"resolved": 1
}
},
"notes": "Alle kritischen/hohen Findings sind geschlossen; keine offenen critical/high/medium Befunde im In-Scope-Auth-Bereich."
}

View File

@@ -0,0 +1,249 @@
{
"task_id": "LOGIN-AUTH-QUALITY-CHECK-001",
"summary": "Ganzheitlicher Quality-Check fuer Auth/Login/Password (lokal + Microsoft SSO) mit Guard- und Best-Practice-Review ueber PHP und Frontend, gezielten Korrekturen bei Verstoessen (inkl. unnoetigem CSS/JS), sowie Ergaenzung und Absicherung durch reproduzierbare Tests und Quality Gates.",
"assumptions": [
"Scope umfasst lokalen Login/Password-Flow und Microsoft-SSO-Start/Callback inklusive tenantabhaengiger Login-Methoden.",
"Umsetzungstiefe ist 'gezielte Fixes', kein breiter Architektur-Refactor.",
"Wenn API-Verhalten nicht geaendert wird, ist keine OpenAPI-Aenderung noetig.",
"Alle neu eingefuehrten sichtbaren Texte werden im selben Merge in de+en gepflegt."
],
"scope": {
"in": [
"pages/auth/login().php, register().php, forgot().php, verify().php, reset().php, verify-email().php",
"pages/auth/login(login).phtml, register(login).phtml, forgot(login).phtml, verify(login).phtml, reset(login).phtml, verify-email(login).phtml",
"pages/auth/microsoft/start().php und pages/auth/microsoft/callback().php",
"pages/api/v1/auth/login().php und pages/api/v1/me/password().php",
"lib/Service/Auth/AuthService.php, RememberMeService.php, PasswordResetService.php, EmailVerificationService.php, MicrosoftOidcService.php, TenantSsoService.php, SsoUserLinkService.php",
"templates/login.phtml und templates/partials/auth-help-links.phtml (plus ggf. auth-bezogene Partials)",
"web/js/app-login-init.js, web/js/components/app-password-toggle.js, web/js/components/app-password-hints.js",
"web/css/pages/app-login.css",
"tests/Service/Auth/*, tests/Http/ApiAuthTest.php, tests/Architecture/AuthLoginAccessibilityContractTest.php, tests/Architecture/AuthHelpLinksContractTest.php",
"i18n/default_de.json und i18n/default_en.json fuer neue Keys"
],
"out": [
"Neues Auth-Feature-Design oder Produktneuentwicklung",
"Breite Refactors ausserhalb Auth/Login/Password/SSO",
"DB-Schema-Migrationen",
"Nicht-authentifizierungsbezogene Admin-Module"
]
},
"guardrails": {
"required_guard_ids": [
"GR-CORE-001",
"GR-CORE-002",
"GR-CORE-003",
"GR-CORE-004",
"GR-CORE-005",
"GR-CORE-006",
"GR-CORE-008",
"GR-CORE-012",
"GR-SEC-001",
"GR-SEC-002",
"GR-SEC-003",
"GR-SEC-004",
"GR-SEC-005",
"GR-UI-009",
"GR-UI-010",
"GR-UI-011",
"GR-UI-012",
"GR-UI-013",
"GR-UI-015",
"GR-TEST-001",
"GR-TEST-002",
"GR-LANG-002"
],
"required_quality_gate_ids": [
"QG-001",
"QG-002",
"QG-003",
"QG-004",
"QG-005",
"QG-006"
]
},
"success_criteria": [
{
"id": "SC-001",
"criterion": "Eine vollstaendige Guard-Matrix fuer den Auth/Login/Password-Scope liegt vor, mit Datei-/Flow-Bezug und priorisierten Findings (kritisch/hoch/mittel)."
},
{
"id": "SC-002",
"criterion": "Alle kritischen und hohen Guard-Verstoesse im Scope sind durch gezielte Code-Anpassungen behoben oder mit begruendeter, nachvollziehbarer Ausnahme dokumentiert."
},
{
"id": "SC-003",
"criterion": "Frontend-Review entfernt oder konsolidiert unnoetige/duplizierte Login-CSS- und Login-JS-Logik, ohne A11y- oder UX-Regression."
},
{
"id": "SC-004",
"criterion": "Serverseitige Sicherheitsinvarianten bleiben fuer alle betroffenen Auth-Endpunkte intakt (AuthZ, Tenant-Scope, CSRF auf POST, keine sensiblen Leaks)."
},
{
"id": "SC-005",
"criterion": "Automatisierte Tests sind erweitert, sodass Login/Password-Edge-Cases und Regressionen (inkl. Reset/Verify sowie SSO-Pfade) reproduzierbar abgedeckt sind."
},
{
"id": "SC-006",
"criterion": "Alle Pflicht-Gates QG-001 bis QG-006 laufen mit PASS und nachvollziehbarer Evidenz."
},
{
"id": "SC-007",
"criterion": "Manuelle End-to-End-Smokes fuer lokale Login- und SSO-Kernfluesse sind erfolgreich, inklusive Browser-Console ohne neue JS-Fehler."
}
],
"implementation_steps": [
{
"id": "S1",
"title": "Guard-Matrix und Baseline erstellen",
"description": "Dokumentiere pro Auth/Login/Password-Flow die relevanten Guards aus Katalog und ordne sie konkreten Dateien/Codepfaden zu; erfasse bestehende Testabdeckung und initiale Gap-Liste.",
"guard_refs": [
"GR-CORE-001",
"GR-TEST-001"
]
},
{
"id": "S2",
"title": "PHP Page/API Boundary-Review durchfuehren",
"description": "Pruefe pages/auth/* und pages/api/v1/auth|me auf requestInput-Vertrag, Superglobal-Vermeidung, CSRF-Reihenfolge, API-Fehlerkonsistenz, PRG-Verhalten und serverseitige Sicherheitspruefungen.",
"guard_refs": [
"GR-CORE-003",
"GR-CORE-004",
"GR-CORE-008",
"GR-CORE-012",
"GR-SEC-001",
"GR-CORE-005",
"GR-CORE-006"
]
},
{
"id": "S3",
"title": "Service-Layer Security- und Testability-Review",
"description": "Pruefe AuthService/RememberMe/PasswordReset/EmailVerification/MicrosoftOIDC/TenantSSO auf Auth- und Tenant-Invarianten, PII-Redaktion, SQL-/Crypto-Nutzung sowie unit-testbare Konstruktion via Dependency Injection.",
"guard_refs": [
"GR-CORE-001",
"GR-SEC-002",
"GR-SEC-003",
"GR-SEC-005",
"GR-TEST-002"
]
},
{
"id": "S4",
"title": "Frontend/UI-UX Quality-Review mit CSS/JS-Cleanup",
"description": "Pruefe Login-Templates, Login-CSS und Login-JS auf A11y-Basics, i18n-Konformitaet, Semantik, UI-State-Vollstaendigkeit sowie JS/CSS-Reuse-first; entferne unnoetige/duplizierte Regeln oder Logik gezielt.",
"guard_refs": [
"GR-UI-009",
"GR-UI-010",
"GR-UI-011",
"GR-UI-012",
"GR-UI-013",
"GR-UI-015",
"GR-SEC-004"
]
},
{
"id": "S5",
"title": "Gezielte Korrekturen umsetzen",
"description": "Setze nur die priorisierten High-Impact-Fixes um (PHP + Frontend), ohne Scope-Ausweitung; bei UI-Textaenderungen i18n-Keys in de/en im selben Schritt nachziehen.",
"guard_refs": [
"GR-TEST-002",
"GR-UI-010",
"GR-UI-015",
"GR-LANG-002"
]
},
{
"id": "S6",
"title": "Testabdeckung gezielt erweitern",
"description": "Ergaenze/erweitere PHPUnit-Tests fuer unterabgedeckte Auth/Login/Password-Pfade (v. a. PasswordResetService, EmailVerificationService, Login/SSO-Edge-Cases) sowie passende Architektur-Contracts fuer Markup-/A11y-Invarianten.",
"guard_refs": [
"GR-TEST-001",
"GR-TEST-002",
"GR-UI-009",
"GR-SEC-001"
]
},
{
"id": "S7",
"title": "Quality Gates und Smokes ausfuehren",
"description": "Fuehre QG-001 bis QG-006 aus, inklusive struktureller rg-Checks und JS-Smoke; dokumentiere pro Gate PASS/FAIL/Blocker mit kurzer Evidenz.",
"guard_refs": [
"GR-LANG-002",
"GR-TEST-001",
"GR-UI-011",
"GR-UI-012"
]
},
{
"id": "S8",
"title": "Acceptance gegen SC-IDs abschliessen",
"description": "Mappe jede Success-Criterion-ID auf konkrete Evidenz (Dateien, Tests, Gate-Resultate, Smoke-Ergebnisse) und markiere verbleibende Restrisiken transparent.",
"guard_refs": [
"GR-TEST-001",
"GR-CORE-005"
]
}
],
"tests": [
"Bestehende Tests: tests/Service/Auth/AuthServiceTest.php und tests/Service/Auth/RememberMeServiceTest.php gezielt erweitern (fehlende Login-/Tenant-/SSO-Edge-Cases).",
"Neue Tests: tests/Service/Auth/PasswordResetServiceTest.php (requestReset, verifyCode, resetPassword inkl. expiry/attempts/used).",
"Neue Tests: tests/Service/Auth/EmailVerificationServiceTest.php (sendVerification, verifyCode, resendVerification inkl. already_verified/attempt limits).",
"Bestehende Architekturtests: tests/Architecture/AuthLoginAccessibilityContractTest.php und tests/Architecture/AuthHelpLinksContractTest.php bei Bedarf erweitern.",
"API-bezogene Regressionen: gezielte Ergaenzung in tests/Http/ApiAuthTest.php oder neuer fokussierter Auth-API-Test fuer Login-/Password-Randfaelle.",
"QG-001: docker compose exec php vendor/bin/phpunit",
"QG-002: docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress",
"QG-003: docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php",
"QG-004: strukturelle rg-Checks gemaess agent-system/checks/quality-gates.json (Erwartung: 0 Verstosse)",
"QG-005: Browser-Smoke fuer Login, Forgot/Verify/Reset, Microsoft Start/Callback; DevTools Console ohne neue Errors",
"QG-006: docker compose exec php composer cs:check"
],
"acceptance_checks": [
"SC-001: Guard-Matrix enthaelt jeden In-Scope-Flow und referenziert konkrete Dateien plus Priorisierung der Findings.",
"SC-002: Kein offener kritischer/hoher Guard-Verstoss im Scope ohne dokumentierte und akzeptierte Ausnahme.",
"SC-003: Login-Frontend hat keine neu eingefuehrte A11y-Regressionsspur und keine unnoetige duplizierte CSS/JS-Logik in den geprueften Bereichen.",
"SC-004: AuthZ-, Tenant- und CSRF-Invarianten sind fuer alle betroffenen POST- und Login-Endpunkte nachweisbar intakt.",
"SC-005: Neue/erweiterte Tests schlagen bei kontrollierter Regelverletzung fehl und laufen im Zielzustand stabil gruen.",
"SC-006: QG-001 bis QG-006 sind mit PASS protokolliert.",
"SC-007: Manueller Smoke bestaetigt erfolgreiche Kernfluesse (lokal + SSO + Logout) ohne neue Browser-Console-Fehler."
],
"ux_notes": {
"affected_patterns": [
"Auth login card/layout (templates/login.phtml + pages/auth/*(login).phtml)",
"Tenant selection pattern (login-tenant-fieldset, login-tenant-choice-grid)",
"Auth help links partial (templates/partials/auth-help-links.phtml)",
"Password hint/toggle behavior (web/js/components/app-password-hints.js, app-password-toggle.js)",
"Login page styling (web/css/pages/app-login.css)"
],
"ui_states_required": [
"loading",
"empty",
"error",
"success"
],
"a11y_touchpoints": [
"Email/password/code inputs inklusive aria-invalid und aria-describedby Verknuepfungen",
"Fehler-Notices als assertive live regions mit fokussierbarer Rueckmeldung",
"Tenant-Radioauswahl und Tastatur-Fokuspfad",
"Microsoft Login CTA und lokale Login-Buttons mit semantischer Bedienbarkeit",
"Password toggle control (button semantics, focus-visible, aria-label updates)"
]
},
"risks": [
{
"risk": "Gezielte CSS-Bereinigung kann visuelle Login-Regressionen erzeugen, obwohl funktional korrekt.",
"mitigation": "Nur additive/minimale CSS-Deltas, visuelle Smokes pro Auth-Stage und QG-005 verpflichtend."
},
{
"risk": "Aenderungen an tenantabhaengiger Login-Methodik koennen lokale oder Microsoft-Login-Pfade unbeabsichtigt blockieren.",
"mitigation": "SSO- und Local-Branches jeweils mit positiven und negativen Tests absichern; End-to-End-Smoke fuer Start/Callback/Login ausfuehren."
},
{
"risk": "Security-Fixes koennen durch Nebenwirkungen API- oder Session-Verhalten brechen.",
"mitigation": "Invarianten zuerst testen (CSRF/AuthZ/Tenant), danach nur kleine isolierte Codeaenderungen mit Ruecktests."
},
{
"risk": "Neue Tests werden flakey wegen Session/Global-State-Kopplung.",
"mitigation": "Konsequent Mocking/DI nutzen, globale Abhaengigkeiten kapseln und deterministische Assertions ohne Zeitrennen definieren."
}
]
}

View File

@@ -0,0 +1,57 @@
{
"task_id": "LOGIN-AUTH-QUALITY-CHECK-001",
"verdict": "pass",
"checked_criterion_ids": [
"SC-001",
"SC-002",
"SC-003",
"SC-004",
"SC-005",
"SC-006",
"SC-007"
],
"checks": [
{
"criterion_id": "SC-001",
"criterion": "Eine vollstaendige Guard-Matrix fuer den Auth/Login/Password-Scope liegt vor, mit Datei-/Flow-Bezug und priorisierten Findings (kritisch/hoch/mittel).",
"result": "pass",
"evidence": "agent-system/runs/LOGIN-AUTH-QUALITY-CHECK-001/guard-matrix.json dokumentiert alle In-Scope-Flows mit Datei-/Guard-Zuordnung; priority_summary weist open=0 fuer critical/high/medium aus (high resolved=1, medium resolved=4)."
},
{
"criterion_id": "SC-002",
"criterion": "Alle kritischen und hohen Guard-Verstoesse im Scope sind durch gezielte Code-Anpassungen behoben oder mit begruendeter, nachvollziehbarer Ausnahme dokumentiert.",
"result": "pass",
"evidence": "guard-matrix.json zeigt keine offenen critical/high Findings; der High-Fund F-REGISTER-001 ist als resolved dokumentiert und in pages/auth/register().php umgesetzt (CSRF vor Payload), abgesichert durch tests/Architecture/AuthRegisterSecurityContractTest.php (lokal PASS)."
},
{
"criterion_id": "SC-003",
"criterion": "Frontend-Review entfernt oder konsolidiert unnoetige/duplizierte Login-CSS- und Login-JS-Logik, ohne A11y- oder UX-Regression.",
"result": "pass",
"evidence": "Konkrete Cleanup-Evidence vorhanden: web/css/pages/app-login.css ohne .back_to_login sowie idempotente Duplicate-Binding-Guards in web/js/components/app-password-toggle.js und web/js/components/app-password-hints.js; belegt durch tests/Architecture/AuthLoginFrontendCleanupContractTest.php (lokal PASS, 3 Tests/15 Assertions) und A11y-Contract tests/Architecture/AuthLoginAccessibilityContractTest.php (lokal PASS)."
},
{
"criterion_id": "SC-004",
"criterion": "Serverseitige Sicherheitsinvarianten bleiben fuer alle betroffenen Auth-Endpunkte intakt (AuthZ, Tenant-Scope, CSRF auf POST, keine sensiblen Leaks).",
"result": "pass",
"evidence": "CSRF-Invariante fuer Register ist codeseitig durchgesetzt und per Contract-Test bestaetigt; QG-004 Structural Checks lokal erneut 8x0 (u.a. keine verbotenen direkten Instanziierungen, keine ApiResponse::readJsonBody-Nutzung in pages/api/v1). execution-report.json guard_evidence markiert GR-CORE-005/006 und GR-SEC-002 als pass ohne offene Punkte."
},
{
"criterion_id": "SC-005",
"criterion": "Automatisierte Tests sind erweitert, sodass Login/Password-Edge-Cases und Regressionen (inkl. Reset/Verify sowie SSO-Pfade) reproduzierbar abgedeckt sind.",
"result": "pass",
"evidence": "Reset/Verify-Abdeckung: tests/Service/Auth/PasswordResetServiceTest.php und tests/Service/Auth/EmailVerificationServiceTest.php (lokal PASS, zusammen 27 Tests/81 Assertions). SSO-Abdeckung: tests/Service/Auth/MicrosoftOidcServiceTest.php enthaelt Callback-Domain-Policy-Regression (lokal PASS, 15 Tests/61 Assertions)."
},
{
"criterion_id": "SC-006",
"criterion": "Alle Pflicht-Gates QG-001 bis QG-006 laufen mit PASS und nachvollziehbarer Evidenz.",
"result": "pass",
"evidence": "execution-report.json weist QG-001..QG-006 als pass aus; lokal verifiziert wurden QG-004 (8 strukturelle Checks alle 0) und QG-006 (composer cs:check PASS, 0/518 fixable)."
},
{
"criterion_id": "SC-007",
"criterion": "Manuelle End-to-End-Smokes fuer lokale Login- und SSO-Kernfluesse sind erfolgreich, inklusive Browser-Console ohne neue JS-Fehler.",
"result": "pass",
"evidence": "execution-report.json dokumentiert QG-005 als pass fuer lokale Login- und SSO-Kernpfade inkl. Console-Check; keine widersprechenden Open Items vorhanden."
}
]
}

View File

@@ -0,0 +1,37 @@
{
"task_id": "LOGIN-AUTH-QUALITY-CHECK-001",
"verdict": "pass",
"checked_guard_ids": [
"GR-CORE-001",
"GR-CORE-002",
"GR-CORE-003",
"GR-CORE-004",
"GR-CORE-005",
"GR-CORE-006",
"GR-CORE-008",
"GR-CORE-012",
"GR-SEC-001",
"GR-SEC-002",
"GR-SEC-003",
"GR-SEC-004",
"GR-SEC-005",
"GR-UI-009",
"GR-UI-010",
"GR-UI-011",
"GR-UI-012",
"GR-UI-013",
"GR-UI-015",
"GR-TEST-001",
"GR-TEST-002",
"GR-LANG-002"
],
"checked_quality_gate_ids": [
"QG-001",
"QG-002",
"QG-003",
"QG-004",
"QG-005",
"QG-006"
],
"findings": []
}

View File

@@ -52,6 +52,8 @@
"Select your tenant to continue": "Mandant auswählen und weiter",
"Choose how you want to sign in": "Anmeldeart auswählen",
"Continue": "Weiter",
"Show password": "Passwort anzeigen",
"Hide password": "Passwort verbergen",
"Use different email": "Andere E-Mail verwenden",
"Back to login": "Zurück zum Login",
"Encrypted connection": "Verschlüsselt",

View File

@@ -52,6 +52,8 @@
"Select your tenant to continue": "Select your tenant to continue",
"Choose how you want to sign in": "Choose how you want to sign in",
"Continue": "Continue",
"Show password": "Show password",
"Hide password": "Hide password",
"Use different email": "Use different email",
"Back to login": "Back to login",
"Encrypted connection": "Encrypted",

View File

@@ -4,6 +4,7 @@ use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
$sessionStore = app(SessionStoreInterface::class);
@@ -15,7 +16,10 @@ if (!allowRegistration()) {
Router::redirect('login');
}
if ($request->hasBody('email')) {
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
$error = t('Form expired, please try again');
} elseif ($request->hasBody('email')) {
$authService = (app(AuthServicesFactory::class))->createAuthService();
$firstName = $request->bodyString('first_name');
$lastName = $request->bodyString('last_name');
@@ -39,6 +43,9 @@ if ($request->hasBody('email')) {
Flash::success(t('Registration successful! Please check your email for the verification code.'), 'verify-email', 'registration_success');
Router::redirect('verify-email');
}
} else {
$error = t('User can not be registered');
}
}
$userPasswordPolicyService = (app(UserServicesFactory::class))->createUserPasswordPolicyService();

View File

@@ -15,7 +15,11 @@ if ($bufferTitle !== '') {
?>
<!DOCTYPE html>
<html data-theme="<?php e($theme); ?>" <?php if ($primaryVars !== ''): ?>style="<?php e($primaryVars); ?>" <?php endif; ?>>
<html
data-theme="<?php e($theme); ?>"
data-password-toggle-show-label="<?php e(t('Show password')); ?>"
data-password-toggle-hide-label="<?php e(t('Hide password')); ?>"
<?php if ($primaryVars !== ''): ?>style="<?php e($primaryVars); ?>" <?php endif; ?>>
<head>
<base href="<?php e(localeBase()); ?>">

View File

@@ -59,4 +59,19 @@ class AuthLoginAccessibilityContractTest extends TestCase
$this->assertStringContainsString('aria-invalid="true"', $content, $file);
}
}
public function testPasswordToggleUsesLocalizedLabelsAndKeyboardFocusableControl(): void
{
$layoutContent = $this->readProjectFile('templates/login.phtml');
$scriptContent = $this->readProjectFile('web/js/components/app-password-toggle.js');
$this->assertStringContainsString('data-password-toggle-show-label', $layoutContent);
$this->assertStringContainsString('data-password-toggle-hide-label', $layoutContent);
$this->assertStringContainsString('passwordToggleShowLabel', $scriptContent);
$this->assertStringContainsString('passwordToggleHideLabel', $scriptContent);
$this->assertStringNotContainsString('toggle.tabIndex = -1;', $scriptContent);
$this->assertStringNotContainsString("'Show password'", $scriptContent);
$this->assertStringNotContainsString("'Hide password'", $scriptContent);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class AuthLoginFrontendCleanupContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testLoginCssDoesNotContainLegacyBackToLoginSelector(): void
{
$content = $this->readProjectFile('web/css/pages/app-login.css');
$this->assertStringNotContainsString('.back_to_login', $content);
}
public function testLoginPasswordToggleUsesDuplicateBindingGuard(): void
{
$content = $this->readProjectFile('web/js/components/app-password-toggle.js');
$this->assertStringContainsString("input.dataset.passwordToggleBound === '1'", $content);
$this->assertStringContainsString("input.dataset.passwordToggleBound = '1';", $content);
$this->assertStringContainsString("toggle.dataset.passwordToggleBound = '1';", $content);
}
public function testLoginPasswordHintsUsesDuplicateBindingGuard(): void
{
$content = $this->readProjectFile('web/js/components/app-password-hints.js');
$this->assertStringContainsString("container.dataset.passwordHintsBound === '1'", $content);
$this->assertStringContainsString("container.dataset.passwordHintsBound = '1';", $content);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class AuthRegisterSecurityContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testRegisterPageVerifiesCsrfBeforeHandlingPostPayload(): void
{
$content = $this->readProjectFile('pages/auth/register().php');
$this->assertStringContainsString("if (\$request->isMethod('POST')) {", $content);
$this->assertStringContainsString("if (!Session::checkCsrfToken()) {", $content);
$this->assertStringContainsString("t('Form expired, please try again')", $content);
$this->assertMatchesRegularExpression(
'/if \(\$request->isMethod\(\'POST\'\)\) \{\s*if \(!Session::checkCsrfToken\(\)\) \{/s',
$content
);
}
}

View File

@@ -0,0 +1,315 @@
<?php
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Repository\Auth\EmailVerificationRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Auth\EmailVerificationService;
use MintyPHP\Service\Mail\MailService;
use PHPUnit\Framework\TestCase;
class EmailVerificationServiceTest extends TestCase
{
public function testSendVerificationReturnsUserNotFoundWhenUserDoesNotExist(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->expects($this->once())->method('find')->with(9)->willReturn(null);
$service = $this->newService(userReadRepository: $userReadRepository);
$result = $service->sendVerification(9);
$this->assertFalse($result['ok']);
$this->assertSame('user_not_found', $result['error']);
}
public function testSendVerificationReturnsEmailRequiredWhenEmailIsMissing(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->expects($this->once())->method('find')->with(10)->willReturn([
'id' => 10,
'email' => '',
]);
$service = $this->newService(userReadRepository: $userReadRepository);
$result = $service->sendVerification(10);
$this->assertFalse($result['ok']);
$this->assertSame('email_required', $result['error']);
}
public function testSendVerificationReturnsCreateFailedWhenRepositoryCreateFails(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->expects($this->once())->method('find')->with(11)->willReturn([
'id' => 11,
'email' => 'user@example.com',
'locale' => 'en',
'first_name' => 'Ada',
'last_name' => 'Lovelace',
]);
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
$verificationRepository->expects($this->once())->method('invalidateForUser')->with(11);
$verificationRepository->expects($this->once())
->method('create')
->with(
11,
$this->callback(static fn (string $hash): bool => (password_get_info($hash)['algo'] ?? null) !== null),
$this->callback(static fn (string $expiresAt): bool => strtotime($expiresAt . ' UTC') !== false)
)
->willReturn(null);
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->never())->method('sendTemplate');
$service = $this->newService(
userReadRepository: $userReadRepository,
emailVerificationRepository: $verificationRepository,
mailService: $mailService
);
$result = $service->sendVerification(11, 'en');
$this->assertFalse($result['ok']);
$this->assertSame('create_failed', $result['error']);
}
public function testSendVerificationCreatesCodeAndSendsMail(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->expects($this->once())->method('find')->with(12)->willReturn([
'id' => 12,
'email' => 'user@example.com',
'locale' => 'en',
'first_name' => 'Ada',
'last_name' => 'Lovelace',
]);
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
$verificationRepository->expects($this->once())->method('invalidateForUser')->with(12);
$verificationRepository->expects($this->once())->method('create')->willReturn(120);
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->once())
->method('sendTemplate')
->with(
'email_verification',
$this->callback(static function (array $vars): bool {
return preg_match('/^\d{6}$/', (string) ($vars['code'] ?? '')) === 1
&& (int) ($vars['expires_minutes'] ?? 0) === 30
&& str_contains((string) ($vars['verify_url'] ?? ''), 'verify-email');
}),
'user@example.com',
$this->isString(),
'en'
)
->willReturn(['ok' => true]);
$service = $this->newService(
userReadRepository: $userReadRepository,
emailVerificationRepository: $verificationRepository,
mailService: $mailService
);
$result = $service->sendVerification(12, 'en');
$this->assertTrue($result['ok']);
}
public function testVerifyCodeRejectsEmptyInput(): void
{
$service = $this->newService();
$result = $service->verifyCode('', '');
$this->assertFalse($result['ok']);
$this->assertSame('invalid', $result['error']);
}
public function testVerifyCodeReturnsAlreadyVerifiedWhenUserIsAlreadyVerified(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->method('findByEmail')->willReturn([
'id' => 13,
'email_verified_at' => gmdate('Y-m-d H:i:s'),
]);
$service = $this->newService(userReadRepository: $userReadRepository);
$result = $service->verifyCode('user@example.com', '123456');
$this->assertFalse($result['ok']);
$this->assertSame('already_verified', $result['error']);
}
public function testVerifyCodeReturnsTooManyAttemptsWhenLimitReached(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->method('findByEmail')->willReturn([
'id' => 14,
'email_verified_at' => null,
]);
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
$verificationRepository->method('findActiveByUserId')->willReturn([
'id' => 140,
'attempts' => 5,
'code_hash' => password_hash('123456', PASSWORD_DEFAULT),
]);
$verificationRepository->expects($this->never())->method('incrementAttempts');
$service = $this->newService(
userReadRepository: $userReadRepository,
emailVerificationRepository: $verificationRepository
);
$result = $service->verifyCode('user@example.com', '123456');
$this->assertFalse($result['ok']);
$this->assertSame('too_many_attempts', $result['error']);
}
public function testVerifyCodeIncrementsAttemptsOnWrongCode(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->method('findByEmail')->willReturn([
'id' => 15,
'email_verified_at' => null,
]);
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
$verificationRepository->method('findActiveByUserId')->willReturn([
'id' => 150,
'attempts' => 0,
'code_hash' => password_hash('654321', PASSWORD_DEFAULT),
]);
$verificationRepository->expects($this->once())->method('incrementAttempts')->with(150)->willReturn(true);
$service = $this->newService(
userReadRepository: $userReadRepository,
emailVerificationRepository: $verificationRepository
);
$result = $service->verifyCode('user@example.com', '123456');
$this->assertFalse($result['ok']);
$this->assertSame('invalid', $result['error']);
}
public function testVerifyCodeMarksRequestUsedAndUserAsVerifiedOnSuccess(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->method('findByEmail')->willReturn([
'id' => 16,
'email_verified_at' => null,
]);
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
$verificationRepository->method('findActiveByUserId')->willReturn([
'id' => 160,
'attempts' => 1,
'code_hash' => password_hash('123456', PASSWORD_DEFAULT),
]);
$verificationRepository->expects($this->once())->method('markUsed')->with(160)->willReturn(true);
$userWriteRepository = $this->createMock(UserWriteRepositoryInterface::class);
$userWriteRepository->expects($this->once())->method('setEmailVerified')->with(16)->willReturn(true);
$service = $this->newService(
userReadRepository: $userReadRepository,
userWriteRepository: $userWriteRepository,
emailVerificationRepository: $verificationRepository
);
$result = $service->verifyCode('user@example.com', '123456');
$this->assertTrue($result['ok']);
$this->assertSame(16, (int) ($result['user_id'] ?? 0));
}
public function testResendVerificationRejectsEmptyEmail(): void
{
$service = $this->newService();
$result = $service->resendVerification(' ');
$this->assertFalse($result['ok']);
$this->assertSame('email_required', $result['error']);
}
public function testResendVerificationReturnsOkForUnknownEmailWithoutLeakingIdentity(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->expects($this->once())->method('findByEmail')->with('missing@example.com')->willReturn(null);
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
$verificationRepository->expects($this->never())->method('create');
$service = $this->newService(
userReadRepository: $userReadRepository,
emailVerificationRepository: $verificationRepository
);
$result = $service->resendVerification('missing@example.com');
$this->assertTrue($result['ok']);
}
public function testResendVerificationReturnsAlreadyVerifiedWhenUserIsAlreadyVerified(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->method('findByEmail')->willReturn([
'id' => 17,
'email_verified_at' => gmdate('Y-m-d H:i:s'),
]);
$service = $this->newService(userReadRepository: $userReadRepository);
$result = $service->resendVerification('user@example.com');
$this->assertFalse($result['ok']);
$this->assertSame('already_verified', $result['error']);
}
public function testResendVerificationDelegatesToSendVerificationForUnverifiedUsers(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->expects($this->once())->method('findByEmail')->with('user@example.com')->willReturn([
'id' => 18,
'email_verified_at' => null,
]);
$userReadRepository->expects($this->once())->method('find')->with(18)->willReturn([
'id' => 18,
'email' => 'user@example.com',
'locale' => 'en',
]);
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
$verificationRepository->expects($this->once())->method('invalidateForUser')->with(18);
$verificationRepository->expects($this->once())->method('create')->willReturn(180);
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->once())->method('sendTemplate')->willReturn(['ok' => true]);
$service = $this->newService(
userReadRepository: $userReadRepository,
emailVerificationRepository: $verificationRepository,
mailService: $mailService
);
$result = $service->resendVerification('user@example.com', 'en');
$this->assertTrue($result['ok']);
}
private function newService(
?UserReadRepositoryInterface $userReadRepository = null,
?UserWriteRepositoryInterface $userWriteRepository = null,
?EmailVerificationRepositoryInterface $emailVerificationRepository = null,
?MailService $mailService = null
): EmailVerificationService {
$userReadRepository ??= $this->createMock(UserReadRepositoryInterface::class);
$userWriteRepository ??= $this->createMock(UserWriteRepositoryInterface::class);
$emailVerificationRepository ??= $this->createMock(EmailVerificationRepositoryInterface::class);
$mailService ??= $this->createMock(MailService::class);
return new EmailVerificationService(
$userReadRepository,
$userWriteRepository,
$emailVerificationRepository,
$mailService
);
}
}

View File

@@ -397,6 +397,83 @@ class MicrosoftOidcServiceTest extends TestCase
$this->assertSame('tenant-id-123', (string) (($result['claims'] ?? [])['tid'] ?? ''));
}
public function testHandleCallbackReturnsEmailDomainNotAllowedWhenTenantDomainPolicyRejectsUser(): void
{
[$idToken, $jwk] = $this->buildValidIdToken('client-id', 'tenant-id-123', 'nonce-123');
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
$stateStore->method('consume')->willReturn([
'ok' => true,
'entry' => [
'tenant_id' => 12,
'nonce' => 'nonce-123',
'code_verifier' => 'verifier-123',
'redirect_uri' => 'https://app.example/auth/microsoft/callback',
'locale' => 'de',
],
]);
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->expects($this->once())
->method('findById')
->with(12)
->willReturn([
'id' => 12,
'status' => 'active',
'uuid' => 'tenant-uuid',
]);
$tenantSsoService = $this->createMock(TenantSsoService::class);
$tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
'ok' => true,
'config' => [
'authority' => 'https://login.microsoftonline.com/tenant-id-123/v2.0',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'tenant_id' => 'tenant-id-123',
'allowed_domains' => 'allowed.example.com',
'sync_profile_on_login' => 0,
'sync_profile_fields' => [],
],
]);
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
$tenantSsoService->expects($this->once())
->method('isEmailDomainAllowed')
->with('user@example.com', ['allowed.example.com'])
->willReturn(false);
$oidcGateway = $this->createMock(OidcHttpGateway::class);
$oidcGateway->expects($this->exactly(3))
->method('call')
->willReturnOnConsecutiveCalls(
[
'status' => 200,
'data' => json_encode([
'token_endpoint' => 'https://login.microsoftonline.com/tenant-id-123/oauth2/v2.0/token',
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
'jwks_uri' => 'https://login.microsoftonline.com/tenant-id-123/discovery/v2.0/keys',
]) ?: '{}',
],
[
'status' => 200,
'data' => json_encode([
'id_token' => $idToken,
'access_token' => '',
]) ?: '{}',
],
[
'status' => 200,
'data' => json_encode(['keys' => [$jwk]]) ?: '{}',
],
);
$service = $this->newService($tenantSsoService, $tenantGateway, $oidcGateway, $stateStore);
$result = $service->handleCallback('state-ok', 'code-ok');
$this->assertFalse($result['ok']);
$this->assertSame('email_domain_not_allowed', $result['error']);
}
private function newServiceForTokenFlow(array $discoveryResponse, ?array $tokenResponse = null): MicrosoftOidcService
{
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);

View File

@@ -0,0 +1,362 @@
<?php
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Repository\Auth\PasswordResetRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Auth\PasswordResetService;
use MintyPHP\Service\Auth\RememberMeService;
use MintyPHP\Service\Mail\MailService;
use MintyPHP\Service\User\UserPasswordService;
use PHPUnit\Framework\TestCase;
class PasswordResetServiceTest extends TestCase
{
public function testRequestResetRejectsEmptyEmail(): void
{
$service = $this->newService();
$result = $service->requestReset(' ');
$this->assertFalse($result['ok']);
$this->assertSame('email_required', $result['error']);
}
public function testRequestResetReturnsOkForUnknownEmailWithoutCreatingReset(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->expects($this->once())
->method('findByEmail')
->with('missing@example.com')
->willReturn(null);
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->expects($this->never())->method('create');
$passwordResetRepository->expects($this->never())->method('invalidateForUser');
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->never())->method('sendTemplate');
$service = $this->newService(
userReadRepository: $userReadRepository,
passwordResetRepository: $passwordResetRepository,
mailService: $mailService
);
$result = $service->requestReset('missing@example.com');
$this->assertTrue($result['ok']);
}
public function testRequestResetReturnsCreateFailedWhenRepositoryCreateFails(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->method('findByEmail')->willReturn([
'id' => 7,
'email' => 'user@example.com',
'locale' => 'en',
'first_name' => 'Ada',
'last_name' => 'Lovelace',
]);
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->expects($this->once())->method('invalidateForUser')->with(7);
$passwordResetRepository->expects($this->once())
->method('create')
->with(
7,
$this->callback(static fn (string $hash): bool => (password_get_info($hash)['algo'] ?? null) !== null),
$this->callback(static fn (string $expiresAt): bool => strtotime($expiresAt . ' UTC') !== false)
)
->willReturn(null);
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->never())->method('sendTemplate');
$service = $this->newService(
userReadRepository: $userReadRepository,
passwordResetRepository: $passwordResetRepository,
mailService: $mailService
);
$result = $service->requestReset('user@example.com', 'en');
$this->assertFalse($result['ok']);
$this->assertSame('create_failed', $result['error']);
}
public function testRequestResetCreatesResetAndSendsMailWhenUserExists(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->method('findByEmail')->willReturn([
'id' => 8,
'email' => 'user@example.com',
'locale' => 'en',
'first_name' => 'Ada',
'last_name' => 'Lovelace',
]);
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->expects($this->once())->method('invalidateForUser')->with(8);
$passwordResetRepository->expects($this->once())->method('create')->willReturn(42);
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->once())
->method('sendTemplate')
->with(
'reset_code',
$this->callback(static function (array $vars): bool {
return preg_match('/^\d{6}$/', (string) ($vars['code'] ?? '')) === 1
&& (int) ($vars['expires_minutes'] ?? 0) === 15
&& str_contains((string) ($vars['verify_url'] ?? ''), 'password/verify');
}),
'user@example.com',
$this->isString(),
'en'
)
->willReturn(['ok' => true]);
$service = $this->newService(
userReadRepository: $userReadRepository,
passwordResetRepository: $passwordResetRepository,
mailService: $mailService
);
$result = $service->requestReset('user@example.com', 'en');
$this->assertTrue($result['ok']);
}
public function testVerifyCodeRejectsEmptyInput(): void
{
$service = $this->newService();
$result = $service->verifyCode('', '');
$this->assertFalse($result['ok']);
$this->assertSame('invalid', $result['error']);
}
public function testVerifyCodeReturnsTooManyAttemptsWhenLimitReached(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->method('findByEmail')->willReturn(['id' => 9]);
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->method('findActiveByUserId')->willReturn([
'id' => 91,
'attempts' => 5,
'code_hash' => password_hash('123456', PASSWORD_DEFAULT),
]);
$passwordResetRepository->expects($this->never())->method('incrementAttempts');
$service = $this->newService(
userReadRepository: $userReadRepository,
passwordResetRepository: $passwordResetRepository
);
$result = $service->verifyCode('user@example.com', '123456');
$this->assertFalse($result['ok']);
$this->assertSame('too_many_attempts', $result['error']);
}
public function testVerifyCodeIncrementsAttemptsOnWrongCode(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->method('findByEmail')->willReturn(['id' => 11]);
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->method('findActiveByUserId')->willReturn([
'id' => 111,
'attempts' => 1,
'code_hash' => password_hash('654321', PASSWORD_DEFAULT),
]);
$passwordResetRepository->expects($this->once())->method('incrementAttempts')->with(111)->willReturn(true);
$service = $this->newService(
userReadRepository: $userReadRepository,
passwordResetRepository: $passwordResetRepository
);
$result = $service->verifyCode('user@example.com', '123456');
$this->assertFalse($result['ok']);
$this->assertSame('invalid', $result['error']);
}
public function testVerifyCodeReturnsOkForValidCode(): void
{
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepository->method('findByEmail')->willReturn(['id' => 12]);
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->method('findActiveByUserId')->willReturn([
'id' => 121,
'attempts' => 0,
'code_hash' => password_hash('123456', PASSWORD_DEFAULT),
]);
$passwordResetRepository->expects($this->never())->method('incrementAttempts');
$service = $this->newService(
userReadRepository: $userReadRepository,
passwordResetRepository: $passwordResetRepository
);
$result = $service->verifyCode('user@example.com', '123456');
$this->assertTrue($result['ok']);
$this->assertSame(121, (int) ($result['reset_id'] ?? 0));
$this->assertSame(12, (int) ($result['user_id'] ?? 0));
}
public function testResetPasswordReturnsErrorWhenResetRequestMissing(): void
{
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->method('findById')->willReturn(null);
$service = $this->newService(passwordResetRepository: $passwordResetRepository);
$result = $service->resetPassword(77, 'StrongPass1!', 'StrongPass1!');
$this->assertFalse($result['ok']);
$this->assertSame([t('Reset request not found')], $result['errors']);
}
public function testResetPasswordReturnsErrorWhenResetRequestAlreadyUsed(): void
{
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->method('findById')->willReturn([
'id' => 80,
'user_id' => 5,
'used_at' => gmdate('Y-m-d H:i:s'),
]);
$service = $this->newService(passwordResetRepository: $passwordResetRepository);
$result = $service->resetPassword(80, 'StrongPass1!', 'StrongPass1!');
$this->assertFalse($result['ok']);
$this->assertSame([t('Reset request already used')], $result['errors']);
}
public function testResetPasswordReturnsErrorWhenResetRequestExpired(): void
{
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->method('findById')->willReturn([
'id' => 81,
'user_id' => 5,
'used_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() - 60),
]);
$service = $this->newService(passwordResetRepository: $passwordResetRepository);
$result = $service->resetPassword(81, 'StrongPass1!', 'StrongPass1!');
$this->assertFalse($result['ok']);
$this->assertSame([t('Reset request expired')], $result['errors']);
}
public function testResetPasswordReturnsErrorWhenResetUserIdIsInvalid(): void
{
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->method('findById')->willReturn([
'id' => 82,
'user_id' => 0,
'used_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 300),
]);
$service = $this->newService(passwordResetRepository: $passwordResetRepository);
$result = $service->resetPassword(82, 'StrongPass1!', 'StrongPass1!');
$this->assertFalse($result['ok']);
$this->assertSame([t('Reset request not found')], $result['errors']);
}
public function testResetPasswordReturnsValidationErrorsFromUserPasswordService(): void
{
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->method('findById')->willReturn([
'id' => 83,
'user_id' => 17,
'used_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 300),
]);
$passwordResetRepository->expects($this->never())->method('markUsed');
$userPasswordService = $this->createMock(UserPasswordService::class);
$userPasswordService->expects($this->once())
->method('resetPassword')
->with(17, 'weak', 'weak')
->willReturn(['ok' => false, 'errors' => ['weak']]);
$rememberMeService = $this->createMock(RememberMeService::class);
$rememberMeService->expects($this->never())->method('forgetAllForUser');
$service = $this->newService(
passwordResetRepository: $passwordResetRepository,
userPasswordService: $userPasswordService,
rememberMeService: $rememberMeService
);
$result = $service->resetPassword(83, 'weak', 'weak');
$this->assertSame(['ok' => false, 'errors' => ['weak']], $result);
}
public function testResetPasswordMarksRequestUsedAndExpiresRememberTokensOnSuccess(): void
{
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$passwordResetRepository->method('findById')->willReturn([
'id' => 84,
'user_id' => 18,
'used_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 300),
]);
$passwordResetRepository->expects($this->once())->method('markUsed')->with(84)->willReturn(true);
$userPasswordService = $this->createMock(UserPasswordService::class);
$userPasswordService->expects($this->once())
->method('resetPassword')
->with(18, 'StrongPass1!', 'StrongPass1!')
->willReturn(['ok' => true]);
$rememberMeService = $this->createMock(RememberMeService::class);
$rememberMeService->expects($this->once())->method('forgetAllForUser')->with(18);
$service = $this->newService(
passwordResetRepository: $passwordResetRepository,
userPasswordService: $userPasswordService,
rememberMeService: $rememberMeService
);
$result = $service->resetPassword(84, 'StrongPass1!', 'StrongPass1!');
$this->assertTrue($result['ok']);
}
private function newService(
?UserReadRepositoryInterface $userReadRepository = null,
?PasswordResetRepositoryInterface $passwordResetRepository = null,
?UserPasswordService $userPasswordService = null,
?RememberMeService $rememberMeService = null,
?MailService $mailService = null
): PasswordResetService {
$userReadRepository ??= $this->createMock(UserReadRepositoryInterface::class);
$passwordResetRepository ??= $this->createMock(PasswordResetRepositoryInterface::class);
$userPasswordService ??= $this->createMock(UserPasswordService::class);
$rememberMeService ??= $this->createMock(RememberMeService::class);
$mailService ??= $this->createMock(MailService::class);
return new PasswordResetService(
$userReadRepository,
$passwordResetRepository,
$userPasswordService,
$rememberMeService,
$mailService
);
}
}

View File

@@ -631,10 +631,6 @@
box-shadow: none;
}
.back_to_login {
margin-bottom: 1rem;
}
.login-tenant-select-form {
margin-bottom: 1rem;
}

View File

@@ -21,6 +21,9 @@ export function initPasswordHints() {
if (!containers.length) {return;}
containers.forEach((container) => {
if (!(container instanceof HTMLElement)) {return;}
if (container.dataset.passwordHintsBound === '1') {return;}
const passwordSelector = container.dataset.passwordInput;
const confirmSelector = container.dataset.confirmInput;
const emailSelector = container.dataset.emailInput;
@@ -77,6 +80,7 @@ export function initPasswordHints() {
if (confirmInput) {confirmInput.addEventListener('input', evaluate);}
if (emailInput) {emailInput.addEventListener('input', evaluate);}
evaluate();
container.dataset.passwordHintsBound = '1';
});
}

View File

@@ -2,32 +2,51 @@ export function initPasswordToggles() {
const inputs = document.querySelectorAll('input[type="password"]');
if (!inputs.length) {return;}
const showLabel = (document.documentElement.dataset.passwordToggleShowLabel || '').trim();
const hideLabel = (document.documentElement.dataset.passwordToggleHideLabel || '').trim();
if (!showLabel || !hideLabel) {return;}
inputs.forEach((input) => {
if (!(input instanceof HTMLInputElement)) {return;}
if (input.dataset.passwordToggleBound === '1') {return;}
const label = input.closest('label');
if (!label) {return;}
const parent = input.parentElement;
if (parent instanceof HTMLElement && parent.classList.contains('login-password-wrapper')) {
const existingToggle = parent.querySelector('.login-password-toggle');
if (existingToggle instanceof HTMLButtonElement) {
input.dataset.passwordToggleBound = '1';
existingToggle.dataset.passwordToggleBound = '1';
existingToggle.setAttribute('aria-label', input.type === 'password' ? showLabel : hideLabel);
return;
}
}
const wrapper = document.createElement('div');
wrapper.className = 'login-password-wrapper';
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'login-password-toggle';
toggle.setAttribute('aria-label', 'Show password');
toggle.tabIndex = -1;
toggle.setAttribute('aria-label', showLabel);
toggle.innerHTML = '<i class="bi bi-eye" aria-hidden="true"></i>';
input.replaceWith(wrapper);
wrapper.appendChild(input);
wrapper.appendChild(toggle);
input.dataset.passwordToggleBound = '1';
toggle.dataset.passwordToggleBound = '1';
toggle.addEventListener('click', () => {
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
toggle.innerHTML = isPassword
? '<i class="bi bi-eye-slash" aria-hidden="true"></i>'
: '<i class="bi bi-eye" aria-hidden="true"></i>';
toggle.setAttribute('aria-label', isPassword ? 'Hide password' : 'Show password');
toggle.setAttribute('aria-label', isPassword ? hideLabel : showLabel);
input.focus();
});
});