feat: add oauth2 login against myep
This commit is contained in:
1 parent
d433d271d0
commit
d63df2cc1b
18 files changed
+1599
-33
No files matched your search
@@ -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 ###
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user