feat(auth): harden login flows and finalize quality-check coverage
This commit is contained in:
@@ -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": []
|
||||||
|
}
|
||||||
10
agent-system/runs/LOGIN-AUTH-QUALITY-CHECK-001/finalize.json
Normal file
10
agent-system/runs/LOGIN-AUTH-QUALITY-CHECK-001/finalize.json
Normal 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."
|
||||||
|
}
|
||||||
231
agent-system/runs/LOGIN-AUTH-QUALITY-CHECK-001/guard-matrix.json
Normal file
231
agent-system/runs/LOGIN-AUTH-QUALITY-CHECK-001/guard-matrix.json
Normal 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."
|
||||||
|
}
|
||||||
249
agent-system/runs/LOGIN-AUTH-QUALITY-CHECK-001/plan.json
Normal file
249
agent-system/runs/LOGIN-AUTH-QUALITY-CHECK-001/plan.json
Normal 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@
|
|||||||
"Select your tenant to continue": "Mandant auswählen und weiter",
|
"Select your tenant to continue": "Mandant auswählen und weiter",
|
||||||
"Choose how you want to sign in": "Anmeldeart auswählen",
|
"Choose how you want to sign in": "Anmeldeart auswählen",
|
||||||
"Continue": "Weiter",
|
"Continue": "Weiter",
|
||||||
|
"Show password": "Passwort anzeigen",
|
||||||
|
"Hide password": "Passwort verbergen",
|
||||||
"Use different email": "Andere E-Mail verwenden",
|
"Use different email": "Andere E-Mail verwenden",
|
||||||
"Back to login": "Zurück zum Login",
|
"Back to login": "Zurück zum Login",
|
||||||
"Encrypted connection": "Verschlüsselt",
|
"Encrypted connection": "Verschlüsselt",
|
||||||
|
|||||||
@@ -52,6 +52,8 @@
|
|||||||
"Select your tenant to continue": "Select your tenant to continue",
|
"Select your tenant to continue": "Select your tenant to continue",
|
||||||
"Choose how you want to sign in": "Choose how you want to sign in",
|
"Choose how you want to sign in": "Choose how you want to sign in",
|
||||||
"Continue": "Continue",
|
"Continue": "Continue",
|
||||||
|
"Show password": "Show password",
|
||||||
|
"Hide password": "Hide password",
|
||||||
"Use different email": "Use different email",
|
"Use different email": "Use different email",
|
||||||
"Back to login": "Back to login",
|
"Back to login": "Back to login",
|
||||||
"Encrypted connection": "Encrypted",
|
"Encrypted connection": "Encrypted",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use MintyPHP\Http\SessionStoreInterface;
|
|||||||
use MintyPHP\Router;
|
use MintyPHP\Router;
|
||||||
use MintyPHP\Service\Auth\AuthServicesFactory;
|
use MintyPHP\Service\Auth\AuthServicesFactory;
|
||||||
use MintyPHP\Service\User\UserServicesFactory;
|
use MintyPHP\Service\User\UserServicesFactory;
|
||||||
|
use MintyPHP\Session;
|
||||||
use MintyPHP\Support\Flash;
|
use MintyPHP\Support\Flash;
|
||||||
|
|
||||||
$sessionStore = app(SessionStoreInterface::class);
|
$sessionStore = app(SessionStoreInterface::class);
|
||||||
@@ -15,29 +16,35 @@ if (!allowRegistration()) {
|
|||||||
Router::redirect('login');
|
Router::redirect('login');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->hasBody('email')) {
|
if ($request->isMethod('POST')) {
|
||||||
$authService = (app(AuthServicesFactory::class))->createAuthService();
|
if (!Session::checkCsrfToken()) {
|
||||||
$firstName = $request->bodyString('first_name');
|
$error = t('Form expired, please try again');
|
||||||
$lastName = $request->bodyString('last_name');
|
} elseif ($request->hasBody('email')) {
|
||||||
$email = $request->bodyString('email');
|
$authService = (app(AuthServicesFactory::class))->createAuthService();
|
||||||
$password = (string) $request->body('password', '');
|
$firstName = $request->bodyString('first_name');
|
||||||
$password2 = (string) $request->body('password2', '');
|
$lastName = $request->bodyString('last_name');
|
||||||
|
$email = $request->bodyString('email');
|
||||||
|
$password = (string) $request->body('password', '');
|
||||||
|
$password2 = (string) $request->body('password2', '');
|
||||||
|
|
||||||
$result = $authService->register([
|
$result = $authService->register([
|
||||||
'first_name' => $firstName,
|
'first_name' => $firstName,
|
||||||
'last_name' => $lastName,
|
'last_name' => $lastName,
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'password' => $password,
|
'password' => $password,
|
||||||
'password2' => $password2,
|
'password2' => $password2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!($result['ok'] ?? false)) {
|
if (!($result['ok'] ?? false)) {
|
||||||
$error = $result['error'] ?? t('User can not be registered');
|
$error = $result['error'] ?? t('User can not be registered');
|
||||||
|
} else {
|
||||||
|
// Store email in session for verify-email page
|
||||||
|
$sessionStore->set('email_verification_email', $result['email'] ?? $email);
|
||||||
|
Flash::success(t('Registration successful! Please check your email for the verification code.'), 'verify-email', 'registration_success');
|
||||||
|
Router::redirect('verify-email');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Store email in session for verify-email page
|
$error = t('User can not be registered');
|
||||||
$sessionStore->set('email_verification_email', $result['email'] ?? $email);
|
|
||||||
Flash::success(t('Registration successful! Please check your email for the verification code.'), 'verify-email', 'registration_success');
|
|
||||||
Router::redirect('verify-email');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ if ($bufferTitle !== '') {
|
|||||||
|
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!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>
|
<head>
|
||||||
<base href="<?php e(localeBase()); ?>">
|
<base href="<?php e(localeBase()); ?>">
|
||||||
|
|||||||
@@ -59,4 +59,19 @@ class AuthLoginAccessibilityContractTest extends TestCase
|
|||||||
$this->assertStringContainsString('aria-invalid="true"', $content, $file);
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
tests/Architecture/AuthLoginFrontendCleanupContractTest.php
Normal file
34
tests/Architecture/AuthLoginFrontendCleanupContractTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tests/Architecture/AuthRegisterSecurityContractTest.php
Normal file
23
tests/Architecture/AuthRegisterSecurityContractTest.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
315
tests/Service/Auth/EmailVerificationServiceTest.php
Normal file
315
tests/Service/Auth/EmailVerificationServiceTest.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -397,6 +397,83 @@ class MicrosoftOidcServiceTest extends TestCase
|
|||||||
$this->assertSame('tenant-id-123', (string) (($result['claims'] ?? [])['tid'] ?? ''));
|
$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
|
private function newServiceForTokenFlow(array $discoveryResponse, ?array $tokenResponse = null): MicrosoftOidcService
|
||||||
{
|
{
|
||||||
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
||||||
|
|||||||
362
tests/Service/Auth/PasswordResetServiceTest.php
Normal file
362
tests/Service/Auth/PasswordResetServiceTest.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -631,10 +631,6 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back_to_login {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-tenant-select-form {
|
.login-tenant-select-form {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export function initPasswordHints() {
|
|||||||
if (!containers.length) {return;}
|
if (!containers.length) {return;}
|
||||||
|
|
||||||
containers.forEach((container) => {
|
containers.forEach((container) => {
|
||||||
|
if (!(container instanceof HTMLElement)) {return;}
|
||||||
|
if (container.dataset.passwordHintsBound === '1') {return;}
|
||||||
|
|
||||||
const passwordSelector = container.dataset.passwordInput;
|
const passwordSelector = container.dataset.passwordInput;
|
||||||
const confirmSelector = container.dataset.confirmInput;
|
const confirmSelector = container.dataset.confirmInput;
|
||||||
const emailSelector = container.dataset.emailInput;
|
const emailSelector = container.dataset.emailInput;
|
||||||
@@ -77,6 +80,7 @@ export function initPasswordHints() {
|
|||||||
if (confirmInput) {confirmInput.addEventListener('input', evaluate);}
|
if (confirmInput) {confirmInput.addEventListener('input', evaluate);}
|
||||||
if (emailInput) {emailInput.addEventListener('input', evaluate);}
|
if (emailInput) {emailInput.addEventListener('input', evaluate);}
|
||||||
evaluate();
|
evaluate();
|
||||||
|
container.dataset.passwordHintsBound = '1';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,32 +2,51 @@ export function initPasswordToggles() {
|
|||||||
const inputs = document.querySelectorAll('input[type="password"]');
|
const inputs = document.querySelectorAll('input[type="password"]');
|
||||||
if (!inputs.length) {return;}
|
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) => {
|
inputs.forEach((input) => {
|
||||||
if (!(input instanceof HTMLInputElement)) {return;}
|
if (!(input instanceof HTMLInputElement)) {return;}
|
||||||
|
if (input.dataset.passwordToggleBound === '1') {return;}
|
||||||
|
|
||||||
const label = input.closest('label');
|
const label = input.closest('label');
|
||||||
if (!label) {return;}
|
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');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'login-password-wrapper';
|
wrapper.className = 'login-password-wrapper';
|
||||||
|
|
||||||
const toggle = document.createElement('button');
|
const toggle = document.createElement('button');
|
||||||
toggle.type = 'button';
|
toggle.type = 'button';
|
||||||
toggle.className = 'login-password-toggle';
|
toggle.className = 'login-password-toggle';
|
||||||
toggle.setAttribute('aria-label', 'Show password');
|
toggle.setAttribute('aria-label', showLabel);
|
||||||
toggle.tabIndex = -1;
|
|
||||||
toggle.innerHTML = '<i class="bi bi-eye" aria-hidden="true"></i>';
|
toggle.innerHTML = '<i class="bi bi-eye" aria-hidden="true"></i>';
|
||||||
|
|
||||||
input.replaceWith(wrapper);
|
input.replaceWith(wrapper);
|
||||||
wrapper.appendChild(input);
|
wrapper.appendChild(input);
|
||||||
wrapper.appendChild(toggle);
|
wrapper.appendChild(toggle);
|
||||||
|
|
||||||
|
input.dataset.passwordToggleBound = '1';
|
||||||
|
toggle.dataset.passwordToggleBound = '1';
|
||||||
|
|
||||||
toggle.addEventListener('click', () => {
|
toggle.addEventListener('click', () => {
|
||||||
const isPassword = input.type === 'password';
|
const isPassword = input.type === 'password';
|
||||||
input.type = isPassword ? 'text' : 'password';
|
input.type = isPassword ? 'text' : 'password';
|
||||||
toggle.innerHTML = isPassword
|
toggle.innerHTML = isPassword
|
||||||
? '<i class="bi bi-eye-slash" aria-hidden="true"></i>'
|
? '<i class="bi bi-eye-slash" aria-hidden="true"></i>'
|
||||||
: '<i class="bi bi-eye" 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();
|
input.focus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user