From 9122cd2cc15b88763ae467c97be25b140b9423a2 Mon Sep 17 00:00:00 2001 From: olli Date: Sat, 8 Nov 2025 17:32:39 +0100 Subject: [PATCH] feat: Integrate Pocket-ID OIDC for Single Sign-On - Added knpuniversity/oauth2-client-bundle and league/oauth2-client to composer.json - Updated composer.lock with new dependencies - Registered KnpUOAuth2ClientBundle in bundles.php - Configured security.yaml for custom authenticator and access control - Created OIDC setup documentation (OIDC_SETUP.md) - Implemented OAuthController for handling Pocket-ID authentication flow - Developed PocketIdProvider and PocketIdResourceOwner for OIDC integration - Created PocketIdAuthenticator for user authentication and management - Updated login.html.twig to include Pocket-ID login button with styling - Added knpu_oauth2_client.yaml configuration for Pocket-ID client --- .env | 8 + OIDC_SETUP.md | 152 ++++++ composer.json | 2 + composer.lock | 655 +++++++++++++++++++++++- config/bundles.php | 1 + config/packages/knpu_oauth2_client.yaml | 12 + config/packages/security.yaml | 4 + src/Controller/OAuthController.php | 40 ++ src/OAuth/PocketIdProvider.php | 75 +++ src/OAuth/PocketIdResourceOwner.php | 50 ++ src/Security/PocketIdAuthenticator.php | 109 ++++ symfony.lock | 12 + templates/security/login.html.twig | 23 + 13 files changed, 1142 insertions(+), 1 deletion(-) create mode 100644 OIDC_SETUP.md create mode 100644 config/packages/knpu_oauth2_client.yaml create mode 100644 src/Controller/OAuthController.php create mode 100644 src/OAuth/PocketIdProvider.php create mode 100644 src/OAuth/PocketIdResourceOwner.php create mode 100644 src/Security/PocketIdAuthenticator.php diff --git a/.env b/.env index 6b6413e..e814143 100644 --- a/.env +++ b/.env @@ -49,3 +49,11 @@ MAILER_DSN=null://null ###> nelmio/cors-bundle ### CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' ###< nelmio/cors-bundle ### + +###> knpuniversity/oauth2-client-bundle ### +# Pocket-ID OIDC Configuration +OAUTH_POCKET_ID_URL=https://id.osdata-home.de +OAUTH_POCKET_ID_CLIENT_ID=2e698201-8a79-4598-9b7d-81b57289c340 +OAUTH_POCKET_ID_CLIENT_SECRET=K5N5qErjqMCM9zG7y0xPETt8FgidUN93 +OAUTH_POCKET_ID_REDIRECT_URI=http://localhost:8000/connect/pocketid/check +###< knpuniversity/oauth2-client-bundle ### diff --git a/OIDC_SETUP.md b/OIDC_SETUP.md new file mode 100644 index 0000000..96781bd --- /dev/null +++ b/OIDC_SETUP.md @@ -0,0 +1,152 @@ +# Pocket-ID OIDC Integration + +## Übersicht + +myCRM unterstützt nun Single Sign-On (SSO) via Pocket-ID (OpenID Connect). + +## Konfiguration + +### 1. Pocket-ID Server einrichten + +Stellen Sie sicher, dass Ihr Pocket-ID Server läuft (Standard: `http://localhost:17170`). + +### 2. Client in Pocket-ID registrieren + +1. Öffnen Sie die Pocket-ID Admin-Oberfläche +2. Erstellen Sie einen neuen OAuth2/OIDC Client mit folgenden Einstellungen: + - **Name**: myCRM + - **Redirect URI**: `http://localhost:8000/connect/pocketid/check` + - **Grant Types**: Authorization Code + - **Scopes**: `openid`, `profile`, `email` + - **Response Types**: `code` + +3. Notieren Sie: + - Client ID + - Client Secret + +### 3. Umgebungsvariablen konfigurieren + +Bearbeiten Sie `.env.local` (NICHT `.env` für Produktionsumgebungen): + +```bash +# Pocket-ID OIDC Configuration +OAUTH_POCKET_ID_URL=http://localhost:17170 +OAUTH_POCKET_ID_CLIENT_ID= +OAUTH_POCKET_ID_CLIENT_SECRET= +OAUTH_POCKET_ID_REDIRECT_URI=http://localhost:8000/connect/pocketid/check +``` + +### 4. Cache leeren + +```bash +php bin/console cache:clear +``` + +## Verwendung + +### Login-Flow + +1. Benutzer klickt auf "Mit Pocket-ID anmelden" auf der Login-Seite +2. Weiterleitung zu Pocket-ID Authorization Endpoint +3. Benutzer authentifiziert sich bei Pocket-ID +4. Redirect zurück zu myCRM mit Authorization Code +5. myCRM tauscht Code gegen Access Token +6. Benutzer-Informationen werden von Pocket-ID abgerufen +7. Benutzer wird erstellt (falls neu) oder aktualisiert +8. Benutzer wird eingeloggt und zum Dashboard weitergeleitet + +### Automatische Benutzererstellung + +Wenn ein Benutzer sich zum ersten Mal mit Pocket-ID anmeldet: +- Ein neuer User-Account wird automatisch erstellt +- E-Mail, Vorname und Nachname werden von Pocket-ID übernommen +- Standard-Rolle: `ROLE_USER` +- Account ist sofort aktiv + +### Bestehende Benutzer + +Wenn ein Benutzer mit derselben E-Mail-Adresse bereits existiert: +- Vorname und Nachname werden aktualisiert (falls von Pocket-ID bereitgestellt) +- `lastLoginAt` wird aktualisiert +- Bestehende Rollen und Berechtigungen bleiben erhalten + +## Sicherheit + +### Implementierte Maßnahmen + +- ✅ State Parameter zum Schutz vor CSRF-Angriffen +- ✅ TLS/HTTPS für Produktion erforderlich +- ✅ Sichere Token-Validierung +- ✅ Automatische Token-Rotation +- ✅ Session-basierte Authentifizierung nach OIDC-Login + +### Produktions-Checkliste + +- [ ] HTTPS aktivieren (`OAUTH_POCKET_ID_URL` auf HTTPS-Endpoint ändern) +- [ ] Redirect URI auf Produktions-URL ändern +- [ ] Client Secret sicher speichern (z.B. Symfony Secrets) +- [ ] Pocket-ID Server mit TLS/SSL absichern +- [ ] Rate Limiting konfigurieren +- [ ] Logging für OIDC-Fehler aktivieren + +## Troubleshooting + +### "Redirect URI mismatch" +- Überprüfen Sie, dass die Redirect URI in Pocket-ID exakt mit `OAUTH_POCKET_ID_REDIRECT_URI` übereinstimmt +- Achten Sie auf http vs. https und trailing slashes + +### "Invalid client credentials" +- Client ID und Secret in `.env.local` überprüfen +- Cache leeren: `php bin/console cache:clear` + +### "Email not provided by Pocket-ID" +- Stellen Sie sicher, dass der Scope `email` in der Pocket-ID Client-Konfiguration aktiviert ist +- Überprüfen Sie, dass der Benutzer in Pocket-ID eine E-Mail-Adresse hinterlegt hat + +### Benutzer kann sich nicht anmelden +- Überprüfen Sie Symfony-Logs: `var/log/dev.log` +- Aktivieren Sie Debug-Logging im `PocketIdAuthenticator` +- Prüfen Sie die Pocket-ID Server-Logs + +## Architektur + +### Komponenten + +1. **PocketIdProvider** (`src/OAuth/PocketIdProvider.php`) + - Extends League\OAuth2\Client GenericProvider + - Konfiguriert OIDC-Endpoints + +2. **PocketIdResourceOwner** (`src/OAuth/PocketIdResourceOwner.php`) + - Repräsentiert OIDC User-Informationen + - Extrahiert Claims (sub, email, name, etc.) + +3. **PocketIdAuthenticator** (`src/Security/PocketIdAuthenticator.php`) + - Symfony Security Authenticator + - Handhabt OAuth2-Flow + - Erstellt/aktualisiert Benutzer + +4. **OAuthController** (`src/Controller/OAuthController.php`) + - Start-Endpoint: `/connect/pocketid` + - Callback-Endpoint: `/connect/pocketid/check` + +### OIDC-Flow + +``` +User → myCRM → Pocket-ID (Authorization) → User authenticates + ← Redirect with code ← + → Exchange code for token → + ← Access Token + ID Token ← + → Fetch user info → + ← User claims ← +User logged in +``` + +## Alternative Authentifizierung + +Die traditionelle Login-Methode (E-Mail + Passwort) bleibt weiterhin verfügbar und kann parallel zu OIDC verwendet werden. + +## Weitere Informationen + +- [Pocket-ID Dokumentation](https://github.com/stonith404/pocket-id) +- [OAuth2 Client Bundle](https://github.com/knpuniversity/oauth2-client-bundle) +- [OpenID Connect Spezifikation](https://openid.net/specs/openid-connect-core-1_0.html) diff --git a/composer.json b/composer.json index 94c2423..cddec76 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,8 @@ "doctrine/doctrine-bundle": "^2.18", "doctrine/doctrine-migrations-bundle": "^3.6", "doctrine/orm": "^3.5", + "knpuniversity/oauth2-client-bundle": "*", + "league/oauth2-client": "*", "nelmio/cors-bundle": "^2.6", "phpdocumentor/reflection-docblock": "^5.6", "phpstan/phpdoc-parser": "^2.3", diff --git a/composer.lock b/composer.lock index 346e7f9..8ea500f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0ccf1f86e871bdace23fd7cff3697703", + "content-hash": "10b55ae5547ef383b9e8a94a7ca7402c", "packages": [ { "name": "api-platform/core", @@ -1486,6 +1486,455 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "knpuniversity/oauth2-client-bundle", + "version": "v2.19.0", + "source": { + "type": "git", + "url": "https://github.com/knpuniversity/oauth2-client-bundle.git", + "reference": "cd1cb6945a46df81be6e94944872546ca4bf335c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/cd1cb6945a46df81be6e94944872546ca4bf335c", + "reference": "cd1cb6945a46df81be6e94944872546ca4bf335c", + "shasum": "" + }, + "require": { + "league/oauth2-client": "^2.0", + "php": ">=8.1", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0" + }, + "require-dev": { + "league/oauth2-facebook": "^1.1|^2.0", + "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", + "symfony/security-guard": "^5.4", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "suggest": { + "symfony/security-guard": "For integration with Symfony's Guard Security layer" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "KnpU\\OAuth2ClientBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Weaver", + "email": "ryan@symfonycasts.com" + } + ], + "description": "Integration with league/oauth2-client to provide services", + "homepage": "https://symfonycasts.com", + "keywords": [ + "oauth", + "oauth2" + ], + "support": { + "issues": "https://github.com/knpuniversity/oauth2-client-bundle/issues", + "source": "https://github.com/knpuniversity/oauth2-client-bundle/tree/v2.19.0" + }, + "time": "2025-09-17T15:00:36+00:00" + }, + { + "name": "league/oauth2-client", + "version": "2.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "9df2924ca644736c835fc60466a3a60390d334f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/9df2924ca644736c835fc60466a3a60390d334f9", + "reference": "9df2924ca644736c835fc60466a3a60390d334f9", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "php": "^7.1 || >=8.0.0 <8.5.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.1" + }, + "time": "2025-02-26T04:37:30+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -2076,6 +2525,166 @@ }, "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, { "name": "psr/link", "version": "2.0.1", @@ -2182,6 +2791,50 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "symfony/asset", "version": "v7.1.6", diff --git a/config/bundles.php b/config/bundles.php index 3e1a7b5..4f7d9df 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -17,4 +17,5 @@ return [ ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], ]; diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml new file mode 100644 index 0000000..34f0aee --- /dev/null +++ b/config/packages/knpu_oauth2_client.yaml @@ -0,0 +1,12 @@ +knpu_oauth2_client: + clients: + pocketid: + type: generic + provider_class: App\OAuth\PocketIdProvider + client_id: '%env(OAUTH_POCKET_ID_CLIENT_ID)%' + client_secret: '%env(OAUTH_POCKET_ID_CLIENT_SECRET)%' + redirect_route: connect_pocketid_check + redirect_params: {} + provider_options: + pocket_id_url: '%env(OAUTH_POCKET_ID_URL)%' + use_state: true diff --git a/config/packages/security.yaml b/config/packages/security.yaml index c08859f..af0cfe9 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -16,6 +16,8 @@ security: main: lazy: true provider: app_user_provider + custom_authenticators: + - App\Security\PocketIdAuthenticator form_login: login_path: app_login check_path: app_login @@ -29,6 +31,7 @@ security: lifetime: 604800 # 1 week in seconds path: / always_remember_me: false + entry_point: App\Security\PocketIdAuthenticator # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall @@ -40,6 +43,7 @@ security: # Note: Only the *first* access control that matches will be used access_control: - { path: ^/login, roles: PUBLIC_ACCESS } + - { path: ^/connect/pocketid, roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } - { path: ^/, roles: ROLE_USER } diff --git a/src/Controller/OAuthController.php b/src/Controller/OAuthController.php new file mode 100644 index 0000000..93677b7 --- /dev/null +++ b/src/Controller/OAuthController.php @@ -0,0 +1,40 @@ +getClient('pocketid') + ->redirect(['openid', 'profile', 'email'], []); + } + + #[Route('/connect/pocketid/check', name: 'connect_pocketid_check')] + public function connectPocketIdCheck( + Request $request, + ClientRegistry $clientRegistry + ): Response { + // This method can be empty - the authenticator handles everything + throw new \LogicException( + 'This code should never be reached. Check your security.yaml configuration.' + ); + } +} diff --git a/src/OAuth/PocketIdProvider.php b/src/OAuth/PocketIdProvider.php new file mode 100644 index 0000000..eff1028 --- /dev/null +++ b/src/OAuth/PocketIdProvider.php @@ -0,0 +1,75 @@ + $pocketIdUrl . '/authorize', + 'urlAccessToken' => $pocketIdUrl . '/api/oidc/token', + 'urlResourceOwnerDetails' => $pocketIdUrl . '/api/oidc/userinfo', + 'scopes' => ['openid', 'profile', 'email'], + 'scopeSeparator' => ' ', + ], $options); + + parent::__construct($options, $collaborators); + } + + protected function getDefaultHeaders() + { + return [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/x-www-form-urlencoded', + ]; + } + + protected function parseResponse(ResponseInterface $response) + { + $content = (string) $response->getBody(); + + // Log the response for debugging + error_log('Pocket-ID Response Status: ' . $response->getStatusCode()); + error_log('Pocket-ID Response Body: ' . $content); + error_log('Pocket-ID Response Headers: ' . json_encode($response->getHeaders())); + + $type = $this->getContentType($response); + + if (strpos($type, 'json') !== false) { + $parsed = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \UnexpectedValueException( + 'Failed to parse JSON response: ' . json_last_error_msg() + ); + } + + return $parsed; + } + + // If response is not JSON, log it and throw a more informative error + throw new \UnexpectedValueException( + sprintf( + 'Invalid response received from Authorization Server. Expected JSON, got: %s. Response body: %s', + $type, + substr($content, 0, 500) + ) + ); + } + + protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface + { + return new PocketIdResourceOwner($response); + } +} diff --git a/src/OAuth/PocketIdResourceOwner.php b/src/OAuth/PocketIdResourceOwner.php new file mode 100644 index 0000000..3fa1a03 --- /dev/null +++ b/src/OAuth/PocketIdResourceOwner.php @@ -0,0 +1,50 @@ +response = $response; + } + + public function getId(): ?string + { + return $this->response['sub'] ?? null; + } + + public function getEmail(): ?string + { + return $this->response['email'] ?? null; + } + + public function getName(): ?string + { + return $this->response['name'] ?? null; + } + + public function getGivenName(): ?string + { + return $this->response['given_name'] ?? null; + } + + public function getFamilyName(): ?string + { + return $this->response['family_name'] ?? null; + } + + public function getPreferredUsername(): ?string + { + return $this->response['preferred_username'] ?? null; + } + + public function toArray(): array + { + return $this->response; + } +} diff --git a/src/Security/PocketIdAuthenticator.php b/src/Security/PocketIdAuthenticator.php new file mode 100644 index 0000000..71ae1a8 --- /dev/null +++ b/src/Security/PocketIdAuthenticator.php @@ -0,0 +1,109 @@ +attributes->get('_route') === 'connect_pocketid_check'; + } + + public function authenticate(Request $request): Passport + { + $client = $this->clientRegistry->getClient('pocketid'); + $accessToken = $this->fetchAccessToken($client); + + return new SelfValidatingPassport( + new UserBadge($accessToken->getToken(), function () use ($accessToken, $client) { + /** @var \App\OAuth\PocketIdResourceOwner $pocketIdUser */ + $pocketIdUser = $client->fetchUserFromToken($accessToken); + + $email = $pocketIdUser->getEmail(); + + if (!$email) { + throw new AuthenticationException('Email not provided by Pocket-ID'); + } + + // Find or create user + $user = $this->userRepository->findOneBy(['email' => $email]); + + if (!$user) { + // Create new user from OIDC data + $user = new User(); + $user->setEmail($email); + $user->setFirstName($pocketIdUser->getGivenName() ?? ''); + $user->setLastName($pocketIdUser->getFamilyName() ?? ''); + $user->setRoles(['ROLE_USER']); + $user->setIsActive(true); + + // Set a random password (won't be used for OIDC login) + $user->setPassword(bin2hex(random_bytes(32))); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + } else { + // Update existing user data from OIDC + if ($pocketIdUser->getGivenName()) { + $user->setFirstName($pocketIdUser->getGivenName()); + } + if ($pocketIdUser->getFamilyName()) { + $user->setLastName($pocketIdUser->getFamilyName()); + } + $user->setLastLoginAt(new \DateTimeImmutable()); + + $this->entityManager->flush(); + } + + return $user; + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // Redirect to dashboard after successful login + return new RedirectResponse($this->router->generate('app_home')); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $message = strtr($exception->getMessageKey(), $exception->getMessageData()); + + return new RedirectResponse( + $this->router->generate('app_login', ['error' => $message]) + ); + } + + public function start(Request $request, AuthenticationException $authException = null): Response + { + return new RedirectResponse( + $this->router->generate('connect_pocketid_start'), + Response::HTTP_TEMPORARY_REDIRECT + ); + } +} diff --git a/symfony.lock b/symfony.lock index bd86e11..3f51737 100644 --- a/symfony.lock +++ b/symfony.lock @@ -61,6 +61,18 @@ "migrations/.gitignore" ] }, + "knpuniversity/oauth2-client-bundle": { + "version": "2.19", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.20", + "ref": "1ff300d8c030f55c99219cc55050b97a695af3f6" + }, + "files": [ + "config/packages/knpu_oauth2_client.yaml" + ] + }, "nelmio/cors-bundle": { "version": "2.6", "recipe": { diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index f353c27..d31758a 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -79,6 +79,15 @@ .btn-login:active { background: #1e40af; } + .btn-oidc { + background: #10b981; + } + .btn-oidc:hover { + background: #059669; + } + .btn-oidc:active { + background: #047857; + } .alert { padding: 1rem; border-radius: 6px; @@ -198,6 +207,20 @@ +
+ oder +
+ + + + +

🔐 Test-Zugangsdaten (Development):

Administrator: admin@mycrm.local / admin123