feat: add oauth2 login against myep

This commit is contained in:
fromme committed 2026-09-13 17:58:11 +02:00
1 parent d433d271d0
commit d63df2cc1b
18 files changed
+1599 -33

No files matched your search

+19
View File
@@ -46,3 +46,22 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###> symfony/mailer ###
MAILER_DSN=null://null
###< symfony/mailer ###
###> app/myep-oauth2 ###
# OAuth2 against myep, the EP identity provider. ephub is a confidential client using the
# authorization_code grant; it reads /api/userinfo once at login and then uses its own session,
# so the 10-minute access token is never stored or refreshed.
#
# Credentials come from `league:oauth2-server:create-client` ON MYEP. Register the callback
# https://ephub.ddev.site/auth/check for local dev, plus the production host. Leave the id and
# secret empty until then -- the login page detects it and says so.
#
# `email` is the identity claim. Do NOT use profile.communication.email: it comes from the CRM
# address record and is not necessarily the login email.
EP_OAUTH2_CLIENT_ID=
EP_OAUTH2_CLIENT_SECRET=
EP_OAUTH2_URL_AUTHORIZE=https://myep.ddev.site/authorize
EP_OAUTH2_URL_ACCESS_TOKEN=https://myep.ddev.site/token
EP_OAUTH2_URL_USERINFO=https://myep.ddev.site/api/userinfo
EP_OAUTH2_SCOPES=email,profile,roles
###< app/myep-oauth2 ###
+1
View File
@@ -10,6 +10,7 @@
"doctrine/doctrine-bundle": "^3.3",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.7",
"league/oauth2-client": "^2.9",
"phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3",
"symfony/apache-pack": "*",
Generated
+601 -1
View File
@@ -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": "3e68f01587473077081d93ce2360e229",
"content-hash": "8f1bfc30d968cee3e1cb4b846d7b1e73",
"packages": [
{
"name": "composer/semver",
@@ -1269,6 +1269,402 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "7.15.5",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a",
"reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^2.5.3",
"guzzlehttp/psr7": "^2.13.1",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
},
"provide": {
"psr/http-client-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.3",
"guzzlehttp/test-server": "^0.7",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
"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": "[email protected]",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "[email protected]",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Jeremy Lindblom",
"email": "[email protected]",
"homepage": "https://github.com/jeremeamia"
},
{
"name": "George Mponos",
"email": "[email protected]",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "[email protected]",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "[email protected]",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "[email protected]",
"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.15.5"
},
"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": "2026-08-24T09:21:06+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "2.5.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1",
"reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"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": "[email protected]",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "[email protected]",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Tobias Nyholm",
"email": "[email protected]",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Tobias Schultze",
"email": "[email protected]",
"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.5.3"
},
"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": "2026-08-24T09:11:28+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.13.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "95e7828100de18b4e269fb1703be530082d5166d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d",
"reference": "95e7828100de18b4e269fb1703be530082d5166d",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
},
"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": "1.1.0",
"jshttp/mime-db": "1.54.0.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"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": "[email protected]",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "[email protected]",
"homepage": "https://github.com/mtdowling"
},
{
"name": "George Mponos",
"email": "[email protected]",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "[email protected]",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "[email protected]",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "[email protected]",
"homepage": "https://github.com/Tobion"
},
{
"name": "Márk Sági-Kazár",
"email": "[email protected]",
"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.13.1"
},
"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": "2026-08-24T09:13:11+00:00"
},
{
"name": "league/oauth2-client",
"version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth2-client.git",
"reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e",
"reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/guzzle": "^6.5.8 || ^7.4.5",
"php": "^7.1 || >=8.0.0 <8.6.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": "[email protected]",
"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.9.0"
},
"time": "2025-11-25T22:17:17+00:00"
},
{
"name": "monolog/monolog",
"version": "3.12.0",
@@ -1797,6 +2193,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",
@@ -1903,6 +2459,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": "[email protected]"
}
],
"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/apache-pack",
"version": "v1.0.1",
+18 -10
View File
@@ -1,6 +1,6 @@
security:
# No password_hashers: there are no passwords here. Authentication is OAuth2 against myep
# (plan step 6); until that lands there is no way to log in outside dev.
# No password_hashers: there are no passwords here. Authentication is OAuth2 against myep,
# which owns the credentials; ephub only ever sees a userinfo response.
providers:
app_user_provider:
@@ -15,23 +15,31 @@ security:
main:
lazy: true
provider: app_user_provider
entry_point: App\Security\LoginEntryPoint
custom_authenticators:
- App\Security\EpAuthenticator
logout:
path: app_logout
target: app_login
access_control:
# ROLE_EMPLOYEE is the gate, not ROLE_USER: myep authenticates more people than are staff,
# and only staff may book a desk here.
# The login surface itself has to stay reachable, or the entry point redirects in a loop.
- { path: ^/(login|auth|logout)$, roles: PUBLIC_ACCESS }
- { path: ^/auth/, roles: PUBLIC_ACCESS }
- { path: ^/dev/, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_EMPLOYEE }
# Either role opens the app -- access_control treats a list of roles as an OR. Not
# ROLE_USER: myep authenticates far more people than work here, and someone who holds
# neither of these is refused at login with an explanation rather than let in to an
# empty screen.
- { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_ADMIN] }
when@dev:
security:
firewalls:
main:
# Real login is blocked on OAuth2 client registration on myep. Until then
# /dev/login lists the seeded users and logs one in without credentials.
# The entry point, the route and the controller are all dev-only, so this
# cannot leak into another environment by accident.
entry_point: App\Security\DevLoginEntryPoint
# Until a client is registered on myep, /dev/login lists the seeded users and
# logs one in without credentials. The route and the controller are both
# dev-only, so this cannot leak into another environment by accident.
custom_authenticators:
- App\Security\EpAuthenticator
- App\Security\DevLoginAuthenticator
+14
View File
@@ -19,5 +19,19 @@ services:
App\:
resource: '../src/'
# The myep OAuth2 client. Endpoint URLs are configuration rather than constants (same as
# myep-team), so dev, staging and production differ by .env alone. Empty credentials are a
# valid state: the login page then says the client is not registered yet instead of bouncing
# the browser at a broken authorize URL.
App\Security\OAuth2\EpClient:
arguments:
$options:
client_id: '%env(string:EP_OAUTH2_CLIENT_ID)%'
client_secret: '%env(string:EP_OAUTH2_CLIENT_SECRET)%'
url_authorize: '%env(string:EP_OAUTH2_URL_AUTHORIZE)%'
url_access_token: '%env(string:EP_OAUTH2_URL_ACCESS_TOKEN)%'
url_userinfo: '%env(string:EP_OAUTH2_URL_USERINFO)%'
scopes: '%env(csv:EP_OAUTH2_SCOPES)%'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
+17 -11
View File
@@ -9,6 +9,7 @@ use App\Entity\ResourceType;
use App\Entity\User;
use App\Repository\ResourceRepository;
use App\Repository\UserRepository;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -51,14 +52,16 @@ final class SeedCommand extends Command
$created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i), \sprintf('parking-%02d', $i));
}
// One administrator on purpose, so the dev environment exercises both roles that open
// the app. An admin here is also staff, which is the realistic case.
foreach ([
['[email protected]', 'Anna Berger'],
['[email protected]', 'Ben Kuhn'],
['[email protected]', 'Clara Dietz'],
['[email protected]', 'David Roth'],
['[email protected]', 'Elif Yılmaz'],
] as [$email, $name]) {
$created += $this->ensureUser($email, $name);
['[email protected]', 'Anna Berger', [Role::EMPLOYEE]],
['[email protected]', 'Ben Kuhn', [Role::EMPLOYEE, Role::ADMIN]],
['[email protected]', 'Clara Dietz', [Role::EMPLOYEE]],
['[email protected]', 'David Roth', [Role::EMPLOYEE]],
['[email protected]', 'Elif Yılmaz', [Role::EMPLOYEE]],
] as [$email, $name, $roles]) {
$created += $this->ensureUser($email, $name, $roles);
}
$this->em->flush();
@@ -79,16 +82,19 @@ final class SeedCommand extends Command
return 1;
}
private function ensureUser(string $email, string $name): int
/**
* @param list<string> $roles
*/
private function ensureUser(string $email, string $name, array $roles): int
{
if (null !== $this->users->findOneBy(['email' => $email])) {
return 0;
}
$user = new User($email, $name);
// Mirrors what the OAuth2 authenticator will do: the remote roles claim is filtered
// through a local whitelist, and ROLE_EMPLOYEE is what the access_control gate wants.
$user->setRoles(['ROLE_EMPLOYEE']);
// Mirrors what EpAuthenticator does: whatever myep claims is filtered through
// Role::ELIGIBLE, and only those roles are ever stored.
$user->setRoles(Role::eligibleOnly($roles));
$this->em->persist($user);
return 1;
+47 -3
View File
@@ -4,14 +4,58 @@ declare(strict_types=1);
namespace App\Controller;
use App\Security\OAuth2\EpClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Gains /auth/init and /auth/check when OAuth2 against myep lands (plan step 6).
*/
final class SecurityController extends AbstractController
{
public function __construct(private readonly EpClient $client)
{
}
#[Route('/login', name: 'app_login', methods: ['GET'])]
public function login(): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('app_dashboard');
}
return $this->render('security/login.html.twig', [
'oauth2_configured' => $this->client->isConfigured(),
]);
}
/**
* Starts the authorization code flow: stores the state on the session and sends the browser
* to myep.
*/
#[Route('/auth/init', name: 'app_auth_init', methods: ['GET'])]
public function authInit(Request $request): Response
{
if (!$this->client->isConfigured()) {
$this->addFlash('error', 'Für ephub ist auf myep noch kein OAuth2-Client registriert.');
return $this->redirectToRoute('app_login');
}
return new RedirectResponse($this->client->authorizationUrl($request));
}
/**
* The registered callback URL. Never executed: EpAuthenticator::supports() matches this
* route and the firewall handles the request before it reaches a controller. The route has
* to exist all the same, because the redirect URI is generated from it.
*/
#[Route('/auth/check', name: 'app_auth_check', methods: ['GET'])]
public function authCheck(): never
{
throw new \LogicException('Intercepted by EpAuthenticator on the main firewall.');
}
/**
* Never executed: the firewall's logout listener intercepts this route.
*/
+172
View File
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\EpClient;
use App\Security\OAuth2\UserinfoUnavailableException;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
/**
* Logs someone in from myep's authorization-code callback.
*
* ephub reads `/api/userinfo` exactly once, here, and then relies on its own session. The access
* token lives ten minutes and is never stored or refreshed, because nothing else in this
* application talks to myep's API.
*/
class EpAuthenticator extends AbstractAuthenticator
{
use TargetPathTrait;
public function __construct(
private readonly EpClient $client,
private readonly UserRepository $users,
private readonly EntityManagerInterface $em,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LoggerInterface $logger,
) {
}
public function supports(Request $request): ?bool
{
return 'app_auth_check' === $request->attributes->get('_route');
}
public function authenticate(Request $request): Passport
{
try {
$accessToken = $this->client->fetchAccessToken($request);
} catch (AuthorizationRequestException|IdentityProviderException $e) {
$this->logger->error('Login via myep failed: no access token', ['exception' => $e]);
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
}
try {
$userinfo = $this->client->fetchUserinfo($accessToken);
} catch (UserinfoUnavailableException $e) {
$this->logger->error('Login via myep failed: userinfo unreadable', ['exception' => $e]);
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
}
// The guide is explicit that profile.communication.email comes from the CRM address
// record and is NOT necessarily the login email -- one address can hold several
// contacts. The top-level claim is the identity, and it is what User is keyed on.
$email = $userinfo['email'] ?? null;
if (!\is_string($email) || '' === $email) {
$this->logger->error('Login via myep failed: userinfo carried no email claim');
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
}
$roles = Role::eligibleOnly($this->claimedRoles($userinfo));
if ([] === $roles) {
$this->logger->info('Login via myep refused: no eligible role', ['email' => $email]);
throw new CustomUserMessageAuthenticationException(
'Für ephub ist eine Mitarbeiter- oder Administrator-Rolle erforderlich.',
);
}
$user = $this->createOrUpdate($email, $this->nameFrom($userinfo, $email), $roles);
// Resolved here rather than by the user provider: the account may have just been
// created in this very request, and handing the provider an email would mean a second
// lookup of a row we already hold.
return new SelfValidatingPassport(
new UserBadge($user->getUserIdentifier(), static fn (): User => $user),
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// Whatever they were trying to reach before the firewall intervened.
$targetPath = $this->getTargetPath($request->getSession(), $firewallName);
return new RedirectResponse($targetPath ?? $this->urlGenerator->generate('app_dashboard'));
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$request->getSession()->getFlashBag()->add('error', $exception->getMessageKey());
return new RedirectResponse($this->urlGenerator->generate('app_login'));
}
/**
* @param array<string, mixed> $userinfo
*
* @return list<string>
*/
private function claimedRoles(array $userinfo): array
{
$claimed = $userinfo['roles'] ?? [];
if (!\is_array($claimed)) {
return [];
}
return array_values(array_filter($claimed, \is_string(...)));
}
/**
* @param array<string, mixed> $userinfo
*/
private function nameFrom(array $userinfo, string $fallback): string
{
$profile = $userinfo['profile'] ?? null;
if (\is_array($profile)) {
$name = trim(\sprintf('%s %s', $profile['first_name'] ?? '', $profile['last_name'] ?? ''));
if ('' !== $name) {
return mb_substr($name, 0, 100);
}
}
// The profile scope may not have been granted, and a nameless account would render as a
// blank holder on the floor plan -- the email at least identifies somebody.
return mb_substr($fallback, 0, 100);
}
/**
* @param list<string> $roles
*/
private function createOrUpdate(string $email, string $name, array $roles): User
{
$user = $this->users->findOneBy(['email' => $email]);
if (null === $user) {
$user = new User($email, $name);
$this->em->persist($user);
} else {
$user->setName($name);
}
// myep leads: a role it no longer reports is withdrawn here, not kept because it was
// granted once.
$user->setRoles($roles);
$this->em->flush();
return $user;
}
}
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Security;
use Symfony\Component\DependencyInjection\Attribute\When;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -13,14 +12,12 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
/**
* Sends an unauthenticated visitor to the dev login picker.
* Sends an unauthenticated visitor to the login page.
*
* Dev only -- it exists so UI work is not gated on the OAuth2 client registration on myep.
* When the real authenticator lands (plan step 6) the entry point for every environment becomes
* the OAuth2 init route and this class goes away.
* Deliberately the login *page* rather than straight into /auth/init: the page is where a failed
* login's flash message is shown, and bouncing there instead would swallow it.
*/
#[When('dev')]
final readonly class DevLoginEntryPoint implements AuthenticationEntryPointInterface
final readonly class LoginEntryPoint implements AuthenticationEntryPointInterface
{
public function __construct(private UrlGeneratorInterface $urlGenerator)
{
@@ -28,6 +25,6 @@ final readonly class DevLoginEntryPoint implements AuthenticationEntryPointInter
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
return new RedirectResponse($this->urlGenerator->generate('app_dev_login'));
return new RedirectResponse($this->urlGenerator->generate('app_login'));
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Security\OAuth2;
/**
* The callback from myep was not a usable authorization response -- no code, or a state that
* does not match the one this session started with.
*/
final class AuthorizationRequestException extends \RuntimeException
{
}
+155
View File
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Security\OAuth2;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\GenericProvider;
use League\OAuth2\Client\Token\AccessTokenInterface;
use Psr\Http\Client\ClientExceptionInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Thin wrapper around league/oauth2-client's GenericProvider, holding everything specific to
* myep in one place.
*
* The endpoint URLs are configuration rather than constants -- same as `myep-team` -- so this
* works against a local myep, a staging one, or production without a code change.
*/
class EpClient
{
/** @var array<string, mixed> */
private array $config;
/**
* @param array<string, mixed> $options
*/
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
array $options,
) {
$this->config = $this->resolveConfig($options);
}
/**
* Whether a client has actually been registered on myep yet. Until it has, the login page
* says so instead of bouncing the user to a broken authorize URL.
*/
public function isConfigured(): bool
{
return '' !== $this->config['client_id'] && '' !== $this->config['client_secret'];
}
/**
* The URL to send the browser to, having stored the state this session expects back.
*/
public function authorizationUrl(Request $request): string
{
$provider = $this->getProvider();
$url = $provider->getAuthorizationUrl();
$request->getSession()->set('oauth2state', $provider->getState());
return $url;
}
/**
* Exchanges the callback's code for an access token.
*
* The state check is the CSRF protection of the authorization code flow: without it an
* attacker can hand a victim a callback URL carrying the attacker's code and have the
* victim's session bound to the attacker's identity. The stored state is consumed either
* way, so replaying the same callback cannot succeed twice.
*
* @throws AuthorizationRequestException
*/
public function fetchAccessToken(Request $request): AccessTokenInterface
{
$session = $request->getSession();
$expectedState = $session->get('oauth2state');
$session->remove('oauth2state');
$code = $request->query->get('code');
if (null === $code || '' === $code) {
throw new AuthorizationRequestException('Die Antwort von myep enthielt keinen Code.');
}
if (null === $expectedState || $request->query->get('state') !== $expectedState) {
throw new AuthorizationRequestException('Der state-Parameter passt nicht zur Sitzung.');
}
return $this->getProvider()->getAccessToken('authorization_code', ['code' => $code]);
}
/**
* Reads /api/userinfo with the freshly obtained token.
*
* Lives here rather than in the authenticator so that the authenticator depends on two plain
* method calls and can be tested without standing up a PSR-18 client -- and so that
* everything that knows about myep's wire format sits in one class.
*
* @return array<string, mixed>
*
* @throws UserinfoUnavailableException
*/
public function fetchUserinfo(AccessTokenInterface $accessToken): array
{
$provider = $this->getProvider();
try {
$response = $provider->getHttpClient()->sendRequest($provider->getAuthenticatedRequest(
'GET',
$provider->getResourceOwnerDetailsUrl($accessToken),
$accessToken,
['headers' => ['Accept' => 'application/json']],
));
} catch (ClientExceptionInterface $e) {
throw new UserinfoUnavailableException('userinfo konnte nicht abgerufen werden.', previous: $e);
}
$userinfo = json_decode((string) $response->getBody(), true);
if (!\is_array($userinfo)) {
throw new UserinfoUnavailableException('userinfo lieferte keine lesbare Antwort.');
}
return $userinfo;
}
public function getProvider(): AbstractProvider
{
return new GenericProvider([
'clientId' => $this->config['client_id'],
'clientSecret' => $this->config['client_secret'],
'redirectUri' => $this->urlGenerator->generate('app_auth_check', [], UrlGeneratorInterface::ABSOLUTE_URL),
'urlAuthorize' => $this->config['url_authorize'],
'urlAccessToken' => $this->config['url_access_token'],
'urlResourceOwnerDetails' => $this->config['url_userinfo'],
'scopes' => $this->config['scopes'],
// myep's server expects space-separated scopes, not the library's default comma.
'scopeSeparator' => ' ',
]);
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private function resolveConfig(array $options): array
{
$resolver = new OptionsResolver();
$resolver->setRequired(['client_id', 'client_secret', 'url_authorize', 'url_access_token', 'url_userinfo', 'scopes']);
$resolver->setAllowedTypes('client_id', 'string');
$resolver->setAllowedTypes('client_secret', 'string');
$resolver->setAllowedTypes('url_authorize', 'string');
$resolver->setAllowedTypes('url_access_token', 'string');
$resolver->setAllowedTypes('url_userinfo', 'string');
$resolver->setAllowedTypes('scopes', 'string[]');
return $resolver->resolve($options);
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Security\OAuth2;
/**
* myep accepted the token but its userinfo endpoint could not be read.
*/
final class UserinfoUnavailableException extends \RuntimeException
{
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Security;
/**
* The roles this application knows about.
*
* **This list is a whitelist, not a mirror.** myep's `roles` claim is filtered through it on every
* login, so a role invented on the identity provider -- or injected into a userinfo response --
* can never appear on a local account. myep already strips its own `*_PENDING` markers and
* non-granting roles before exporting them, but that is its policy, not our guarantee.
*
* Kept deliberately small: ephub has no notion of teamers, managers or house managers, and
* storing roles it assigns no meaning to would only invite someone to start checking them.
*/
final class Role
{
/** Derived on myep from the account's email domain: "works here". */
public const EMPLOYEE = 'ROLE_EMPLOYEE';
/** Administrative on myep -- nominated by the CRM, but only exported once approved there. */
public const ADMIN = 'ROLE_ADMIN';
/**
* The roles that entitle someone to use ephub at all. Someone who holds neither is refused
* at login rather than let in without access, so they get an explanation instead of an
* empty screen they cannot act on.
*
* @var list<string>
*/
public const ELIGIBLE = [
self::EMPLOYEE,
self::ADMIN,
];
/**
* Whatever of a claimed role set this application recognises, in a stable order.
*
* @param list<string> $claimed
*
* @return list<string>
*/
public static function eligibleOnly(array $claimed): array
{
return array_values(array_filter(
self::ELIGIBLE,
static fn (string $role): bool => \in_array($role, $claimed, true),
));
}
}
+4
View File
@@ -12,6 +12,10 @@
Die echte Anmeldung läuft über myep (OAuth2) und wartet auf die Client-Registrierung dort.
</div>
<p class="pb-4 text-sm">
<a class="underline text-gray-500" href="{{ path('app_login') }}">Zur regulären Anmeldung</a>
</p>
{% if users is empty %}
<p class="text-sm">
Keine Benutzer vorhanden. <code class="font-mono">bin/console app:seed</code> ausführen.
+35
View File
@@ -0,0 +1,35 @@
{% extends 'base.html.twig' %}
{% block title %}Anmelden · ephub{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-md text-center">
<img class="h-8 w-auto mx-auto mb-8" src="{{ asset('images/logo_ep.svg') }}" alt="ephub">
<h1>Anmelden</h1>
<p class="subline pb-8">Schreibtische und Parkplätze buchen.</p>
{% if oauth2_configured %}
<a class="button button--primary w-full" href="{{ path('app_auth_init') }}">
Mit myep anmelden
</a>
{% else %}
<div class="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-left">
<strong>Anmeldung noch nicht eingerichtet.</strong>
Für ephub muss auf myep erst ein OAuth2-Client registriert werden.
</div>
{% endif %}
{% if app.environment == 'dev' %}
<p class="pt-6 text-sm">
<a class="underline text-gray-500" href="{{ path('app_dev_login') }}">Entwickler-Login</a>
</p>
{% endif %}
<div class="fixed bottom-4 right-4 z-50 space-y-2">
{% include '_partials/_flashes.html.twig' %}
</div>
</div>
</div>
{% endblock %}
+251
View File
@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\EpAuthenticator;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\EpClient;
use App\Security\OAuth2\UserinfoUnavailableException;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Client\Token\AccessToken;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
/**
* The authenticator against a stubbed EpClient.
*
* Everything that speaks HTTP lives in EpClient, so this exercises the part that actually
* carries policy: which roles let somebody in, what identifies them, and what is written back.
*/
class EpAuthenticatorTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
}
protected function tearDown(): void
{
$this->em->getConnection()->rollBack();
parent::tearDown();
}
public function testAnEmployeeIsLoggedInAndCreatedLocally(): void
{
$user = $this->authenticate([
'email' => '[email protected]',
'roles' => ['ROLE_EMPLOYEE'],
'profile' => ['first_name' => 'Anna', 'last_name' => 'Berger'],
]);
self::assertSame('[email protected]', $user->getEmail());
self::assertSame('Anna Berger', $user->getName());
self::assertContains(Role::EMPLOYEE, $user->getRoles());
}
public function testAnAdministratorIsLoggedInToo(): void
{
$user = $this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_ADMIN']]);
self::assertContains(Role::ADMIN, $user->getRoles());
}
public function testSomebodyWithNeitherRoleIsRefused(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Mitarbeiter- oder Administrator-Rolle');
$this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_CUSTOMER', 'ROLE_TEAMER']]);
}
public function testARefusedLoginCreatesNoLocalAccount(): void
{
try {
$this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_CUSTOMER']]);
} catch (CustomUserMessageAuthenticationException) {
}
self::assertNull($this->em->getRepository(User::class)->findOneBy(['email' => '[email protected]']));
}
/**
* The roles claim is the identity provider's word, and myep is not the only thing that could
* ever put a value in it. Only what this application recognises is stored.
*/
public function testOnlyWhitelistedRolesAreStored(): void
{
$user = $this->authenticate([
'email' => '[email protected]',
'roles' => ['ROLE_EMPLOYEE', 'ROLE_SUPER_ADMIN', 'ROLE_HOUSE_MANAGER'],
]);
self::assertSame([Role::EMPLOYEE, 'ROLE_USER'], $user->getRoles());
}
public function testALoginWithoutAnEmailClaimIsRefused(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->authenticate(['roles' => ['ROLE_EMPLOYEE']]);
}
/**
* The consumer guide is explicit: profile.communication.email comes from the CRM address
* record and can belong to a different contact entirely. Keying on it would log somebody in
* as their colleague.
*/
public function testIdentityComesFromTheTopLevelEmailNotTheProfileOne(): void
{
$user = $this->authenticate([
'email' => '[email protected]',
'roles' => ['ROLE_EMPLOYEE'],
'profile' => [
'first_name' => 'Anna',
'last_name' => 'Berger',
'communication' => ['email' => '[email protected]'],
],
]);
self::assertSame('[email protected]', $user->getEmail());
}
public function testAnExistingAccountIsUpdatedRatherThanDuplicated(): void
{
$existing = new User('[email protected]', 'Anna Alt');
$existing->setRoles([Role::EMPLOYEE]);
$this->em->persist($existing);
$this->em->flush();
$user = $this->authenticate([
'email' => '[email protected]',
'roles' => ['ROLE_EMPLOYEE'],
'profile' => ['first_name' => 'Anna', 'last_name' => 'Neu'],
]);
self::assertSame($existing->getId(), $user->getId());
self::assertSame('Anna Neu', $user->getName());
self::assertCount(1, $this->em->getRepository(User::class)->findBy(['email' => '[email protected]']));
}
/**
* myep leads: a role it stops reporting is withdrawn here, not kept because it was granted
* once.
*/
public function testARoleMyepNoLongerReportsIsWithdrawn(): void
{
$existing = new User('[email protected]', 'Chef');
$existing->setRoles([Role::EMPLOYEE, Role::ADMIN]);
$this->em->persist($existing);
$this->em->flush();
$user = $this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
self::assertNotContains(Role::ADMIN, $user->getRoles());
}
public function testTheEmailStandsInForAMissingProfileName(): void
{
// The profile scope may simply not have been granted.
$user = $this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
self::assertSame('[email protected]', $user->getName());
}
public function testAMalformedRolesClaimIsTreatedAsNoRoles(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->authenticate(['email' => '[email protected]', 'roles' => 'ROLE_EMPLOYEE']);
}
public function testAFailedTokenExchangeBecomesAFriendlyFailure(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Anmeldung über myep fehlgeschlagen.');
$this->authenticator(throwOnToken: new AuthorizationRequestException('state mismatch'))
->authenticate(new Request());
}
public function testAnUnreachableUserinfoBecomesAFriendlyFailure(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Anmeldung über myep fehlgeschlagen.');
$this->authenticator(throwOnUserinfo: new UserinfoUnavailableException('boom'))
->authenticate(new Request());
}
public function testItOnlyFiresOnTheCallbackRoute(): void
{
$authenticator = $this->authenticator();
$callback = new Request();
$callback->attributes->set('_route', 'app_auth_check');
self::assertTrue($authenticator->supports($callback));
$dashboard = new Request();
$dashboard->attributes->set('_route', 'app_dashboard');
self::assertFalse($authenticator->supports($dashboard));
}
/**
* @param array<string, mixed> $userinfo
*/
private function authenticate(array $userinfo): User
{
$passport = $this->authenticator($userinfo)->authenticate(new Request());
$badge = $passport->getBadge(UserBadge::class);
self::assertInstanceOf(UserBadge::class, $badge);
$user = $badge->getUser();
self::assertInstanceOf(User::class, $user);
return $user;
}
/**
* @param array<string, mixed> $userinfo
*/
private function authenticator(
array $userinfo = [],
?\Throwable $throwOnToken = null,
?\Throwable $throwOnUserinfo = null,
): EpAuthenticator {
$client = $this->createStub(EpClient::class);
if (null !== $throwOnToken) {
$client->method('fetchAccessToken')->willThrowException($throwOnToken);
} else {
$client->method('fetchAccessToken')->willReturn(new AccessToken(['access_token' => 'token']));
}
if (null !== $throwOnUserinfo) {
$client->method('fetchUserinfo')->willThrowException($throwOnUserinfo);
} else {
$client->method('fetchUserinfo')->willReturn($userinfo);
}
return new EpAuthenticator(
$client,
self::getContainer()->get(UserRepository::class),
$this->em,
self::getContainer()->get(UrlGeneratorInterface::class),
new NullLogger(),
);
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\EpClient;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class EpClientTest extends KernelTestCase
{
public function testAnUnregisteredClientIsReportedAsSuch(): void
{
// The state the project is in until somebody runs create-client on myep. The login page
// checks this so it can explain itself instead of redirecting into a broken flow.
self::assertFalse($this->client(clientId: '', clientSecret: '')->isConfigured());
self::assertFalse($this->client(clientId: 'ephub', clientSecret: '')->isConfigured());
self::assertTrue($this->client()->isConfigured());
}
public function testTheAuthorizationUrlCarriesTheClientAndScopes(): void
{
$request = $this->request();
$url = $this->client()->authorizationUrl($request);
self::assertStringStartsWith('https://myep.test/authorize?', $url);
parse_str((string) parse_url($url, \PHP_URL_QUERY), $query);
self::assertSame('ephub', $query['client_id']);
self::assertSame('code', $query['response_type']);
// myep's server wants space-separated scopes; the library's default is a comma.
self::assertSame('email profile roles', $query['scope']);
self::assertStringEndsWith('/auth/check', $query['redirect_uri']);
}
public function testTheStateIsRememberedOnTheSession(): void
{
$request = $this->request();
$url = $this->client()->authorizationUrl($request);
parse_str((string) parse_url($url, \PHP_URL_QUERY), $query);
self::assertNotEmpty($query['state']);
self::assertSame($query['state'], $request->getSession()->get('oauth2state'));
}
public function testACallbackWithoutACodeIsRefused(): void
{
$request = $this->request(['state' => 'abc']);
$request->getSession()->set('oauth2state', 'abc');
$this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('keinen Code');
$this->client()->fetchAccessToken($request);
}
/**
* Without this check an attacker can hand a victim a callback URL carrying the attacker's
* code and bind the victim's session to the attacker's identity.
*/
public function testACallbackWhoseStateDoesNotMatchIsRefused(): void
{
$request = $this->request(['code' => 'xyz', 'state' => 'forged']);
$request->getSession()->set('oauth2state', 'expected');
$this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('state-Parameter');
$this->client()->fetchAccessToken($request);
}
public function testACallbackWithNoStateOnTheSessionIsRefused(): void
{
$request = $this->request(['code' => 'xyz', 'state' => 'anything']);
$this->expectException(AuthorizationRequestException::class);
$this->client()->fetchAccessToken($request);
}
/**
* The stored state is consumed whether or not the check passed, so the same callback URL
* cannot be replayed.
*/
public function testTheStoredStateIsConsumedEvenOnFailure(): void
{
$request = $this->request(['code' => 'xyz', 'state' => 'forged']);
$session = $request->getSession();
$session->set('oauth2state', 'expected');
try {
$this->client()->fetchAccessToken($request);
} catch (AuthorizationRequestException) {
}
self::assertNull($session->get('oauth2state'));
}
/**
* @param array<string, string> $query
*/
private function request(array $query = []): Request
{
$request = new Request($query);
$request->setSession(new Session(new MockArraySessionStorage()));
return $request;
}
private function client(string $clientId = 'ephub', string $clientSecret = 'secret'): EpClient
{
self::bootKernel();
return new EpClient(self::getContainer()->get(UrlGeneratorInterface::class), [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'url_authorize' => 'https://myep.test/authorize',
'url_access_token' => 'https://myep.test/token',
'url_userinfo' => 'https://myep.test/api/userinfo',
'scopes' => ['email', 'profile', 'roles'],
]);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
class RoleTest extends TestCase
{
public function testBothEligibleRolesAreRecognised(): void
{
self::assertSame([Role::EMPLOYEE, Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_EMPLOYEE']));
}
public function testEitherRoleOnItsOwnIsEnough(): void
{
self::assertSame([Role::EMPLOYEE], Role::eligibleOnly(['ROLE_EMPLOYEE']));
self::assertSame([Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN']));
}
/**
* The whole point of the whitelist: myep knows roles this application assigns no meaning to,
* and an injected one must not become storable just because it was in the response.
*/
public function testRolesThisApplicationDoesNotKnowAreDropped(): void
{
$claimed = ['ROLE_TEAMER', 'ROLE_CUSTOMER', 'ROLE_HOUSE_MANAGER', 'ROLE_SUPER_ADMIN', 'ROLE_EMPLOYEE'];
self::assertSame([Role::EMPLOYEE], Role::eligibleOnly($claimed));
}
public function testAPendingMarkerDoesNotCountAsTheRole(): void
{
// myep strips these before exporting, but that is its policy and not our guarantee.
self::assertSame([], Role::eligibleOnly(['ROLE_ADMIN_PENDING']));
}
public function testTheOrderIsStableRegardlessOfWhatMyepSends(): void
{
self::assertSame(
Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_EMPLOYEE']),
Role::eligibleOnly(['ROLE_EMPLOYEE', 'ROLE_ADMIN']),
);
}
public function testAnEmptyClaimYieldsNothing(): void
{
self::assertSame([], Role::eligibleOnly([]));
self::assertSame([], Role::eligibleOnly(['ROLE_USER']));
}
}