diff --git a/README.md b/README.md index ab6fb6c..67fd644 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Most of the time, oAuth2 client capacities on third party websites are available # Setup -This project uses `league/oauth2-server`, `defuse/php-encryption` and `hassankhan/config` packages; `symfony/yaml` is provided by Galette. +This project uses `league/oauth2-server` and `defuse/php-encryption` packages; `symfony/yaml` is provided by Galette. To automatically download these packages: ``` diff --git a/_dependencies.php b/_dependencies.php index 4df4089..6f5bf39 100644 --- a/_dependencies.php +++ b/_dependencies.php @@ -45,9 +45,13 @@ function (ContainerInterface $container) { 'lifetime' => (int)$container->get(Preferences::class)->getConfigValue('pref_session_timeout') ]); - $galette_sid = session_id(); + //close Galette session; OAuth one has its own cookie, so its identifier can be renewed on login session_write_close(); - session_id('galette-oauth-' . $galette_sid); + $sid = $_COOKIE[$session_name] ?? ''; + if (!is_string($sid) || !preg_match('/^[a-zA-Z0-9,-]{22,256}$/', $sid)) { + $sid = session_create_id('galette-oauth-'); + } + session_id($sid); $session->start(); $container->get(Messages::class)->__construct($_SESSION); @@ -57,27 +61,7 @@ function (ContainerInterface $container) { $container->set( Config::class, - static function (ContainerInterface $container) { - $conf = new GaletteOAuth2\Tools\Config(OAUTH2_CONFIGPATH . '/config.yml'); - - do { - $key = $conf->key(); - $current = $conf->current(); - if (isset($current['options'])) { - Analog::log( - '"options" is deprecated, please use "authorize" instead for ' . $key, - Analog::WARNING - ); - - if (!isset($current['authorize'])) { - $conf->set($key . '.authorize', $current['options']); - } - $conf->remove($key . '.options'); - } - } while ($conf->next()); - - return $conf; - }, + static fn() => Config::fromFile(OAUTH2_CONFIGPATH . '/config.yml') ); $container->set( @@ -85,22 +69,18 @@ static function (ContainerInterface $container) { function (ContainerInterface $container) { // Setup the authorization server $server = new AuthorizationServer( - // instance of ClientRepositoryInterface - new ClientRepository($container), - // instance of AccessTokenRepositoryInterface - new AccessTokenRepository(), - // instance of ScopeRepositoryInterface - new ScopeRepository(), + $container->get(ClientRepository::class), + $container->get(AccessTokenRepository::class), + $container->get(ScopeRepository::class), // path to private key 'file://' . OAUTH2_CONFIGPATH . '/private.key', // encryption key EncryptionKey::load($container->get(Config::class), OAUTH2_CONFIGPATH), ); - $refreshTokenRepository = new RefreshTokenRepository(); + $refreshTokenRepository = $container->get(RefreshTokenRepository::class); $grant = new AuthCodeGrant( - new AuthCodeRepository(), - // instance of RefreshTokenRepositoryInterface + $container->get(AuthCodeRepository::class), $refreshTokenRepository, new DateInterval('PT10M'), ); @@ -133,7 +113,7 @@ static function (ContainerInterface $container) { $publicKeyPath = 'file://' . OAUTH2_CONFIGPATH . '/public.key'; return new ResourceServer( - new AccessTokenRepository(), + $container->get(AccessTokenRepository::class), $publicKeyPath, ); }, diff --git a/composer.json b/composer.json index 0046573..547e8e6 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,6 @@ "require": { "php": ">=8.3", "league/oauth2-server": "^9.4", - "hassankhan/config": "^3.2", "defuse/php-encryption": "^2.4", "psr/http-message": "^2.0", "league/uri": "^7.8", diff --git a/composer.lock b/composer.lock index 929a184..69851c7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2e6ef2c412009efcb015564e2410d33c", + "content-hash": "3cfb2eb7ead600b44b923cd6140f3655", "packages": [ { "name": "defuse/php-encryption", @@ -73,68 +73,6 @@ }, "time": "2023-06-19T06:10:36+00:00" }, - { - "name": "hassankhan/config", - "version": "3.2.0", - "source": { - "type": "git", - "url": "https://github.com/hassankhan/config.git", - "reference": "cf63da451c4d226df983017932b9cef1b6d49db5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/hassankhan/config/zipball/cf63da451c4d226df983017932b9cef1b6d49db5", - "reference": "cf63da451c4d226df983017932b9cef1b6d49db5", - "shasum": "" - }, - "require": { - "php": ">=7.4" - }, - "require-dev": { - "phpunit/phpunit": "^9.5", - "scrutinizer/ocular": "^1.9", - "squizlabs/php_codesniffer": "^3.6", - "symfony/yaml": "^5.4" - }, - "suggest": { - "symfony/yaml": "^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Noodlehaus\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Hassan Khan", - "homepage": "http://hassankhan.me/", - "role": "Developer" - } - ], - "description": "Lightweight configuration file loader that supports PHP, INI, XML, JSON, and YAML files", - "homepage": "http://hassankhan.me/config/", - "keywords": [ - "config", - "configuration", - "ini", - "json", - "microphp", - "unframework", - "xml", - "yaml", - "yml" - ], - "support": { - "issues": "https://github.com/hassankhan/config/issues", - "source": "https://github.com/hassankhan/config/tree/3.2.0" - }, - "time": "2024-12-09T16:20:44+00:00" - }, { "name": "lcobucci/jwt", "version": "5.6.0", diff --git a/lib/GaletteOAuth2/Authorization/UserHelper.php b/lib/GaletteOAuth2/Authorization/UserHelper.php index 8f9f94b..301ae45 100644 --- a/lib/GaletteOAuth2/Authorization/UserHelper.php +++ b/lib/GaletteOAuth2/Authorization/UserHelper.php @@ -11,7 +11,7 @@ namespace GaletteOAuth2\Authorization; use Analog\Analog; -use DI\Container; +use DI\Attribute\Inject; use Galette\Core\Db; use Galette\Core\History; use Galette\Core\Login; @@ -20,6 +20,7 @@ use Galette\Entity\Social; use GaletteOAuth2\Tools\Config; use GaletteOAuth2\Tools\Debug; +use RKA\Session; use Slim\Flash\Messages; /** @@ -34,28 +35,37 @@ final class UserHelper public const AUTH_UPTODATE = 'uptodate'; public const AUTH_ACTIVE = 'active'; - public static function login(Container $container, string $nick, string $password): int|false - { - $preferences = $container->get(Preferences::class); - /** @var Login $login */ - $login = $container->get(Login::class); - $history = $container->get(History::class); - $session = $container->get('oauth_session'); - $flash = $container->get(Messages::class); + public function __construct( + private readonly Db $zdb, + private readonly Login $login, + private readonly History $history, + private readonly Preferences $preferences, + private readonly Messages $flash, + #[Inject('oauth_session')] + private readonly Session $session + ) { + } + /** + * Log in a member + * + * @return int|false Member ID, false on failure + */ + public function login(string $nick, string $password): int|false + { if (trim($nick) === '' || trim($password) === '') { return false; } - if ($nick === $preferences->pref_admin_login) { + if ($nick === $this->preferences->pref_admin_login) { $pw_superadmin = password_verify( $password, - $preferences->pref_admin_pass, + $this->preferences->pref_admin_pass, ); if (!$pw_superadmin) { $pw_superadmin = ( - md5($password) === $preferences->pref_admin_pass + md5($password) === $this->preferences->pref_admin_pass ); } @@ -64,25 +74,25 @@ public static function login(Container $container, string $nick, string $passwor 'OAuth login attempt from superadmin account', Analog::WARNING ); - $flash->addMessage( + $this->flash->addMessage( 'error_detected', _T('Cannot OAuth login from superadmin account!', 'oauth2') ); return false; } } else { - $login->logIn($nick, $password); + $this->login->logIn($nick, $password); } - if ($login->isLogged()) { - $session->login = $login; - $history->add(_T('Login')); + if ($this->login->isLogged()) { + $this->session->login = $this->login; + $this->history->add(_T('Login')); - return $login->id; + return $this->login->id; } - $history->add(_T('Authentication failed'), $nick); + $this->history->add(_T('Authentication failed'), $nick); - $flash->addMessage( + $this->flash->addMessage( 'error_detected', _T('Check your login / email or password.', 'oauth2') ); @@ -90,39 +100,31 @@ public static function login(Container $container, string $nick, string $passwor return false; } - public static function logout(Container $container): void + /** + * Log out current member + */ + public function logout(): void { - /** @var Login $login */ - $login = $container->get(Login::class); - $history = $container->get(History::class); - $session = $container->get('oauth_session'); - - $login->logout(); - $session->login = $login; - $history->add(_T('Logout')); + $this->login->logout(); + $this->session->login = $this->login; + $this->history->add(_T('Logout')); } /** * Get user data * - * @param Container $container Container instance - * @param int $id User ID - * @param string $acl Requested authorization - * @param string[] $scopes Scopes - * @param bool $legacy Legacy mode for data + * @param int $id User ID + * @param string $acl Requested authorization + * @param string[] $scopes Scopes + * @param bool $legacy Legacy mode for data * * @return array * @throws UserAuthorizationException - * @throws \DI\DependencyException - * @throws \DI\NotFoundException * @throws \Throwable */ - public static function getUserData(Container $container, int $id, string $acl, array $scopes, bool $legacy = false): array + public function getUserData(int $id, string $acl, array $scopes, bool $legacy = false): array { - /** @var Db $zdb */ - $zdb = $container->get(Db::class); - - $member = new Adherent($zdb); + $member = new Adherent($this->zdb); if (!$member->load($id)) { throw new UserAuthorizationException(_T('User not found.', 'oauth2')); } @@ -347,7 +349,7 @@ public static function getAuthorization(Config $config, string $client_id): stri $acl = self::AUTH_TEAMONLY; $conf_acls = $config->get($client_id . '.authorize'); - if ($conf_acls === '') { + if ($conf_acls === null) { //not set: use default return $acl; } diff --git a/lib/GaletteOAuth2/Controllers/ApiController.php b/lib/GaletteOAuth2/Controllers/ApiController.php index 4adc8ca..c5f7dfd 100755 --- a/lib/GaletteOAuth2/Controllers/ApiController.php +++ b/lib/GaletteOAuth2/Controllers/ApiController.php @@ -12,7 +12,6 @@ use Analog\Analog; use DI\Attribute\Inject; -use DI\Container; use Galette\Controllers\AbstractPluginController; use GaletteOAuth2\Authorization\UserAuthorizationException; use GaletteOAuth2\Authorization\UserHelper; @@ -38,32 +37,21 @@ final class ApiController extends AbstractPluginController */ #[Inject("Plugin Galette OAuth2")] protected array $module_info; - protected Container $container; + #[Inject] protected Config $config; #[Inject("oauth_session")] protected Session $session; - - /** - * Default constructor - * - * @param Container $container COntainer instance - * @throws \DI\DependencyException - * @throws \DI\NotFoundException - */ - public function __construct(Container $container) - { - $this->container = $container; - $this->config = $container->get(Config::class); - parent::__construct($container); - } + #[Inject] + protected ResourceServer $server; + #[Inject] + protected UserHelper $userHelper; public function user(Request $request, Response $response): Response|ResponseInterface { Debug::logRequest('api/user()', $request); - $server = $this->container->get(ResourceServer::class); try { - $rep = $server->validateAuthenticatedRequest($request); + $rep = $this->server->validateAuthenticatedRequest($request); } catch (OAuthServerException $exception) { return $exception->generateHttpResponse($response); } @@ -73,8 +61,7 @@ public function user(Request $request, Response $response): Response|ResponseInt Debug::log("api/user() load user #{$oauth_user_id}"); try { - $data = UserHelper::getUserData( - $this->container, + $data = $this->userHelper->getUserData( $oauth_user_id, UserHelper::getAuthorization($this->config, $client_id), //only scopes the user has consented to, stored in the token @@ -86,7 +73,7 @@ public function user(Request $request, Response $response): Response|ResponseInt (bool)$this->config->get($client_id . '.legacy_data', false) ); } catch (UserAuthorizationException $e) { - UserHelper::logout($this->container); + $this->userHelper->logout(); Analog::log( 'api/user() error : ' . $e->getMessage(), Analog::ERROR diff --git a/lib/GaletteOAuth2/Controllers/AuthorizationController.php b/lib/GaletteOAuth2/Controllers/AuthorizationController.php index c07e789..0e7714d 100755 --- a/lib/GaletteOAuth2/Controllers/AuthorizationController.php +++ b/lib/GaletteOAuth2/Controllers/AuthorizationController.php @@ -12,14 +12,13 @@ use Analog\Analog; use DI\Attribute\Inject; -use DI\Container; use Exception; use Galette\Controllers\AbstractPluginController; use GaletteOAuth2\Authorization\UserHelper; use GaletteOAuth2\Entities\UserEntity; use GaletteOAuth2\Repositories\ScopeRepository; -use GaletteOAuth2\Tools\Config as Config; -use GaletteOAuth2\Tools\Debug as Debug; +use GaletteOAuth2\Tools\Config; +use GaletteOAuth2\Tools\Debug; use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\Exception\OAuthServerException; use Psr\Http\Message\ResponseInterface; @@ -40,36 +39,22 @@ final class AuthorizationController extends AbstractPluginController */ #[Inject("Plugin Galette OAuth2")] protected array $module_info; - protected Container $container; + #[Inject] protected Config $config; #[Inject("oauth_session")] protected Session $session; - - /** - * Default constructor - * - * @param Container $container Container instance - * @throws \DI\DependencyException - * @throws \DI\NotFoundException - */ - public function __construct(Container $container) - { - $this->container = $container; - $this->config = $this->container->get(Config::class); - parent::__construct($container); - } + #[Inject] + protected AuthorizationServer $server; + #[Inject] + protected ScopeRepository $scopeRepository; /** * Display authorization form - * - * @throws \DI\DependencyException - * @throws \DI\NotFoundException */ public function authorize(Request $request, Response $response): Response|ResponseInterface { Debug::logRequest('authorization/authorize()', $request); - $server = $this->container->get(AuthorizationServer::class); try { $queryParams = $request->getQueryParams(); @@ -77,7 +62,7 @@ public function authorize(Request $request, Response $response): Response|Respon // Validate the HTTP request and return an AuthorizationRequest object. // The auth request object can be serialized into a user's session - $authRequest = $server->validateAuthorizationRequest($request); + $authRequest = $this->server->validateAuthorizationRequest($request); $user = new UserEntity(); //FIXME: for both isLoggedIn and user_id, we can rely on login object stored in session @@ -123,15 +108,11 @@ public function authorize(Request $request, Response $response): Response|Respon /** * Proceed authorization - * - * @throws \DI\DependencyException - * @throws \DI\NotFoundException */ public function doAuthorize(Request $request, Response $response): Response|ResponseInterface { Debug::logRequest('authorization/doAuthorize()', $request); - $server = $this->container->get(AuthorizationServer::class); try { $params = (array)$request->getParsedBody(); @@ -139,7 +120,7 @@ public function doAuthorize(Request $request, Response $response): Response|Resp // Validate the HTTP request and return an AuthorizationRequest object. // The auth request object can be serialized into a user's session - $authRequest = $server->validateAuthorizationRequest($request); + $authRequest = $this->server->validateAuthorizationRequest($request); $user = new UserEntity(); //FIXME: for both isLoggedIn and user_id, we can rely on login object stored in session $user->setIdentifier((string)$this->session->user_id); @@ -156,9 +137,8 @@ public function doAuthorize(Request $request, Response $response): Response|Resp true ); $req_scopes = []; - $srepo = new ScopeRepository(); foreach ($scopes as $scope) { - $scope_entity = $srepo->getScopeEntityByIdentifier($scope); + $scope_entity = $this->scopeRepository->getScopeEntityByIdentifier($scope); if ($scope_entity !== null) { $req_scopes[] = $scope_entity; } @@ -170,7 +150,7 @@ public function doAuthorize(Request $request, Response $response): Response|Resp } // Return the HTTP redirect response - $r = $server->completeAuthorizationRequest($authRequest, $response); + $r = $this->server->completeAuthorizationRequest($authRequest, $response); Analog::log( 'authorization/doAuthorize() exit ok', Analog::DEBUG @@ -189,11 +169,10 @@ public function doAuthorize(Request $request, Response $response): Response|Resp public function token(Request $request, Response $response): Response|ResponseInterface { Debug::logRequest('authorization/token()', $request); - $server = $this->container->get(AuthorizationServer::class); try { // Try to respond to the access token request - $r = $server->respondToAccessTokenRequest($request, $response); + $r = $this->server->respondToAccessTokenRequest($request, $response); Debug::log('authorization/token() exit ok'); return $r; diff --git a/lib/GaletteOAuth2/Controllers/LoginController.php b/lib/GaletteOAuth2/Controllers/LoginController.php index ac21091..2b40907 100755 --- a/lib/GaletteOAuth2/Controllers/LoginController.php +++ b/lib/GaletteOAuth2/Controllers/LoginController.php @@ -12,7 +12,6 @@ use Analog\Analog; use DI\Attribute\Inject; -use DI\Container; use Galette\Controllers\AbstractPluginController; use GaletteOAuth2\Authorization\UserAuthorizationException; use GaletteOAuth2\Authorization\UserHelper; @@ -37,9 +36,11 @@ final class LoginController extends AbstractPluginController #[Inject("Plugin Galette OAuth2")] protected array $module_info; #[Inject] - protected Container $container; - #[Inject] protected Config $config; + #[Inject] + protected ClientRepository $clientRepository; + #[Inject] + protected UserHelper $userHelper; #[Inject("oauth_session")] protected Session $session; @@ -69,7 +70,7 @@ public function login(Request $request, Response $response): Response } // Validate client_id before displaying login form - $vars = $this->prepareVarsForm(); + $vars = $this->prepareVarsForm($request); if ($vars === null) { return $response ->withStatus(302) @@ -113,7 +114,7 @@ public function doLogin(Request $request, Response $response): Response $this->session->isLoggedIn = 'no'; unset($this->session->client_id); $nick = (string)($params['login'] ?? ''); - $this->session->user_id = $uid = UserHelper::login($this->container, $nick, (string)($params['password'] ?? '')); + $this->session->user_id = $uid = $this->userHelper->login($nick, (string)($params['password'] ?? '')); Debug::log("UserHelper::login({$nick}) return '{$uid}'"); if (false === $uid) { @@ -127,8 +128,7 @@ public function doLogin(Request $request, Response $response): Response try { $client_id = $this->session->request_args['client_id']; - UserHelper::getUserData( - $this->container, + $this->userHelper->getUserData( $uid, UserHelper::getAuthorization($this->config, $client_id), UserHelper::mergeScopes( @@ -140,7 +140,7 @@ public function doLogin(Request $request, Response $response): Response (bool)$this->config->get($client_id . '.legacy_data', false) ); } catch (UserAuthorizationException $e) { - UserHelper::logout($this->container); + $this->userHelper->logout(); Debug::log('login() check rights error ' . $e->getMessage()); $this->flash->addMessage( @@ -155,6 +155,8 @@ public function doLogin(Request $request, Response $response): Response ); } + //new session identifier once logged in + Session::regenerate(); //FIXME: for both isLoggedIn and user_id, we can rely on login object stored in session $this->session->isLoggedIn = 'yes'; $this->session->client_id = $client_id; @@ -181,7 +183,7 @@ public function doLogin(Request $request, Response $response): Response public function logout(Request $request, Response $response): Response { Debug::logRequest('logout()', $request); - UserHelper::logout($this->container); + $this->userHelper->logout(); //read client before cleaning session $client_id = $this->session->client_id ?? $this->session->request_args['client_id'] ?? null; @@ -230,9 +232,11 @@ public function error(Request $request, Response $response): Response /** * Prepare login form variables, null if client is invalid * + * @param Request $request Received request + * * @return ?array */ - private function prepareVarsForm(): ?array + private function prepareVarsForm(Request $request): ?array { $client_id = $this->session->request_args['client_id'] ?? null; @@ -241,7 +245,7 @@ private function prepareVarsForm(): ?array Analog::log( sprintf( 'OAuth2: Missing client_id in request from IP %s', - $_SERVER['REMOTE_ADDR'] ?? 'unknown' + $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown' ), Analog::WARNING ); @@ -249,13 +253,12 @@ private function prepareVarsForm(): ?array } // Check if client exists in configuration - $clientRepository = new ClientRepository($this->container); - if (!$clientRepository->clientExists($client_id)) { + if (!$this->clientRepository->clientExists($client_id)) { Analog::log( sprintf( 'OAuth2: Invalid client_id "%s" in request from IP %s', $client_id, - $_SERVER['REMOTE_ADDR'] ?? 'unknown' + $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown' ), Analog::WARNING ); diff --git a/lib/GaletteOAuth2/Middleware/Authentication.php b/lib/GaletteOAuth2/Middleware/Authentication.php index 5e027f0..aad6189 100755 --- a/lib/GaletteOAuth2/Middleware/Authentication.php +++ b/lib/GaletteOAuth2/Middleware/Authentication.php @@ -11,12 +11,12 @@ namespace GaletteOAuth2\Middleware; use Analog\Analog; +use DI\Attribute\Inject; use GaletteOAuth2\Repositories\ClientRepository; use GaletteOAuth2\Tools\Debug; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Server\RequestHandlerInterface as RequestHandler; -use DI\Container; use RKA\Session; use Slim\Routing\RouteParser; @@ -28,15 +28,12 @@ */ final class Authentication { - private Container $container; - private RouteParser $routeparser; - private Session $session; - - public function __construct(Container $container) - { - $this->container = $container; - $this->routeparser = $container->get(RouteParser::class); - $this->session = $container->get('oauth_session'); + public function __construct( + private readonly ClientRepository $clientRepository, + private readonly RouteParser $routeparser, + #[Inject('oauth_session')] + private readonly Session $session + ) { } /** @@ -51,13 +48,12 @@ public function __invoke(Request $request, RequestHandler $handler): Response $queryParams = $request->getQueryParams(); $client_id = $queryParams['client_id'] ?? null; - $clientRepository = new ClientRepository($this->container); - if (!$clientRepository->clientExists($client_id)) { + if (!$this->clientRepository->clientExists($client_id)) { Analog::log( sprintf( 'OAuth2: Invalid or missing client_id "%s" in authorization request from IP %s', $client_id ?? 'null', - $_SERVER['REMOTE_ADDR'] ?? 'unknown' + $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown' ), Analog::WARNING ); diff --git a/lib/GaletteOAuth2/Repositories/ClientRepository.php b/lib/GaletteOAuth2/Repositories/ClientRepository.php index a96d127..98c80dc 100755 --- a/lib/GaletteOAuth2/Repositories/ClientRepository.php +++ b/lib/GaletteOAuth2/Repositories/ClientRepository.php @@ -11,7 +11,6 @@ namespace GaletteOAuth2\Repositories; use Analog\Analog; -use Psr\Container\ContainerInterface; use GaletteOAuth2\Entities\ClientEntity; use GaletteOAuth2\Tools\Config; use GaletteOAuth2\Tools\Debug; @@ -28,13 +27,8 @@ final class ClientRepository implements ClientRepositoryInterface { private const string EXAMPLE_PASSWORD = 'abc123'; - private ContainerInterface $container; - private Config $config; - - public function __construct(ContainerInterface $container) + public function __construct(private readonly Config $config) { - $this->container = $container; - $this->config = $this->container->get(Config::class); } /** @@ -45,7 +39,7 @@ public function clientExists(?string $client_id): bool if (empty($client_id) || $client_id === 'global') { return false; } - if ($this->config->get($client_id) === '') { + if (!is_array($this->config->get($client_id))) { return false; } if (count($this->getRedirectUris($client_id)) === 0) { diff --git a/lib/GaletteOAuth2/Tools/Config.php b/lib/GaletteOAuth2/Tools/Config.php index d0ab548..4d0757e 100644 --- a/lib/GaletteOAuth2/Tools/Config.php +++ b/lib/GaletteOAuth2/Tools/Config.php @@ -10,42 +10,104 @@ namespace GaletteOAuth2\Tools; +use Analog\Analog; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + /** - * Config class + * Read only configuration, with dot notation access * * @author Manuel Hervouet * @author Johan Cwiklinski */ -final class Config extends \Noodlehaus\Config +final class Config { - /** @var string[]|string */ - private array|string $path; + /** @var array */ + private array $data; /** - * @param string[]|string $values Configuration file(s) + * @param array $data Configuration values */ - public function __construct(array|string $values) + public function __construct(array $data) { - $this->path = $values; + $this->data = $this->migrate($data); + } + /** + * Load configuration from a YAML file + * + * An unreadable file gives an empty configuration: every client will be refused. + */ + public static function fromFile(string $path): self + { try { - parent::__construct($values, new \Noodlehaus\Parser\Yaml()); - } catch (\Exception $e) { - Debug::log("Error load file {$this->path}"); + $data = Yaml::parseFile($path); + } catch (ParseException $e) { + Analog::log( + sprintf( + 'OAuth2: unable to read configuration file %1$s: %2$s', + $path, + $e->getMessage() + ), + Analog::ERROR + ); + $data = []; } + + return new self(is_array($data) ? $data : []); } - public function writeFile(): void + /** + * Get a value, using dot notation (client.entry) + * + * @param string $key Key + * @param mixed $default Value returned when key is missing or empty + */ + public function get(string $key, mixed $default = null): mixed { - try { - $this->toFile($this->path, new \Noodlehaus\Writer\Yaml()); - } catch (\Exception $e) { - Debug::log("Error Write file {$this->path} " . $e->getMessage()); + $value = $this->data; + foreach (explode('.', $key) as $part) { + if (!is_array($value) || !array_key_exists($part, $value)) { + return $default; + } + $value = $value[$part]; } + + return $value ?? $default; } - public function get($name, $default = null) + /** + * Is a value set? + */ + public function has(string $key): bool + { + return $this->get($key) !== null; + } + + /** + * Handle deprecated entries + * + * @param array $data Configuration values + * + * @return array + */ + private function migrate(array $data): array { - return parent::get($name, $default) ?? ''; + foreach ($data as $key => $entry) { + if (!is_array($entry) || !array_key_exists('options', $entry)) { + continue; + } + + Analog::log( + '"options" is deprecated, please use "authorize" instead for ' . $key, + Analog::WARNING + ); + if (!isset($entry['authorize'])) { + $data[$key]['authorize'] = $entry['options']; + } + unset($data[$key]['options']); + } + + return $data; } } diff --git a/tests/GaletteOAuth2/Authorization/tests/units/UserHelper.php b/tests/GaletteOAuth2/Authorization/tests/units/UserHelper.php index 89a4230..bea5cf0 100644 --- a/tests/GaletteOAuth2/Authorization/tests/units/UserHelper.php +++ b/tests/GaletteOAuth2/Authorization/tests/units/UserHelper.php @@ -61,14 +61,11 @@ public function testStripAccents(): void */ public function testGetUserData(): void { - global $container; - $member_one = $this->getMemberOne(); $this->getAdminMember($member_one); //set admin //test for default scope - legacy data mode - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', ['member'], @@ -96,8 +93,7 @@ public function testGetUserData(): void ); //test for default scope - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', ['member'] @@ -124,8 +120,7 @@ public function testGetUserData(): void ); //test personal scope - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', ['member', 'member:personal'] @@ -143,8 +138,7 @@ public function testGetUserData(): void ); //test phones scope - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', ['member', 'member:phones'] @@ -156,8 +150,7 @@ public function testGetUserData(): void ); //test groups scope - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', ['member', 'member:groups'] @@ -173,8 +166,7 @@ public function testGetUserData(): void $user_data ); - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, \GaletteOAuth2\Authorization\UserHelper::AUTH_TEAMONLY, ['member', 'member:groups'] @@ -191,8 +183,7 @@ public function testGetUserData(): void ); //test due date scope - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', ['member', 'member:due_date'] @@ -204,8 +195,7 @@ public function testGetUserData(): void ); //test localization scope - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', ['member', 'member:localization'] @@ -224,8 +214,7 @@ public function testGetUserData(): void ); //test precise localization scope - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', ['member', 'member:localization:precise'] @@ -241,8 +230,7 @@ public function testGetUserData(): void ); //test socials scope - no socials - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', ['member', 'member:socials'] @@ -264,8 +252,7 @@ public function testGetUserData(): void ); //get again, with socials - $user_data = \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $user_data = $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', ['member', 'member:socials'] @@ -283,8 +270,7 @@ public function testGetUserData(): void //no scope => error $this->expectExceptionMessage('Default scope (member) has not been authorized.'); - \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_one->id, '', [] @@ -298,13 +284,10 @@ public function testGetUserData(): void */ public function testRequireAdmin() { - global $container; - $adh1 = $this->getMemberOne(); $this->expectExceptionMessage("Sorry, you can't login because your are not a team member."); - \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $adh1->id, \GaletteOAuth2\Authorization\UserHelper::AUTH_TEAMONLY, ['member'] @@ -334,12 +317,9 @@ public static function memberNotFoundProvider(): array #[DataProvider('memberNotFoundProvider')] public function testMemberNotFound(int $member_id): void { - global $container; - $exception_thrown = false; try { - \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $member_id, \GaletteOAuth2\Authorization\UserHelper::AUTH_TEAMONLY, ['member'] @@ -359,8 +339,6 @@ public function testMemberNotFound(int $member_id): void */ public function testMemberInactive() { - global $container; - $this->logSuperAdmin(); $adh = new \Galette\Entity\Adherent($this->zdb); $adh->setDependencies( @@ -382,8 +360,7 @@ public function testMemberInactive() $this->login->logout(); $this->expectExceptionMessage("Sorry, you can't login because you are not an active member."); - \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $adh->id, \GaletteOAuth2\Authorization\UserHelper::AUTH_TEAMONLY, ['member'] @@ -397,8 +374,6 @@ public function testMemberInactive() */ public function testMemberNoMail() { - global $container; - $this->logSuperAdmin(); $adh = new \Galette\Entity\Adherent($this->zdb); $adh->setDependencies( @@ -420,8 +395,7 @@ public function testMemberNoMail() $this->login->logout(); $this->expectExceptionMessage("Sorry, you can't login. Please, add an email address to your account."); - \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $adh->id, \GaletteOAuth2\Authorization\UserHelper::AUTH_TEAMONLY, ['member'] @@ -435,8 +409,6 @@ public function testMemberNoMail() */ public function testMemberNotUp2Date(): void { - global $container; - $this->logSuperAdmin(); $adh = new \Galette\Entity\Adherent($this->zdb); $adh->setDependencies( @@ -457,8 +429,7 @@ public function testMemberNotUp2Date(): void $this->login->logout(); $this->expectExceptionMessage("Sorry, you can't login because your are not an up-to-date member."); - \GaletteOAuth2\Authorization\UserHelper::getUserData( - $container, + $this->container->get(\GaletteOAuth2\Authorization\UserHelper::class)->getUserData( $adh->id, \GaletteOAuth2\Authorization\UserHelper::AUTH_UPTODATE, ['member'] @@ -472,7 +443,7 @@ public function testMemberNotUp2Date(): void */ public function testGetAuthorizations(): void { - $config = new \GaletteOAuth2\Tools\Config(OAUTH2_CONFIGPATH . '/config.yml'); + $config = \GaletteOAuth2\Tools\Config::fromFile(OAUTH2_CONFIGPATH . '/config.yml'); //always defaults to \GaletteOAuth2\Authorization\UserHelper::AUTH_TEAMONLY, silently when not set $this->assertSame( @@ -482,10 +453,10 @@ public function testGetAuthorizations(): void $this->expectNoLogEntry(); $client_id = 'galette_test'; - $config->set($client_id . '.authorize', 'unknown'); + $test_config = new \GaletteOAuth2\Tools\Config([$client_id => ['authorize' => 'unknown']]); $this->assertSame( \GaletteOAuth2\Authorization\UserHelper::AUTH_TEAMONLY, - \GaletteOAuth2\Authorization\UserHelper::getAuthorization($config, 'galette_test') + \GaletteOAuth2\Authorization\UserHelper::getAuthorization($test_config, 'galette_test') ); $this->expectLogEntry( Analog::ERROR, @@ -503,10 +474,10 @@ public function testGetAuthorizations(): void ); foreach (\GaletteOAuth2\Authorization\UserHelper::getKnownAuthorizations() as $authorization) { - $config->set($client_id . '.authorize', $authorization); + $test_config = new \GaletteOAuth2\Tools\Config([$client_id => ['authorize' => $authorization]]); $this->assertSame( $authorization, - \GaletteOAuth2\Authorization\UserHelper::getAuthorization($config, 'galette_test') + \GaletteOAuth2\Authorization\UserHelper::getAuthorization($test_config, 'galette_test') ); } } @@ -518,7 +489,7 @@ public function testGetAuthorizations(): void */ public function testMergeScopes(): void { - $config = new \GaletteOAuth2\Tools\Config(OAUTH2_CONFIGPATH . '/config.yml'); + $config = \GaletteOAuth2\Tools\Config::fromFile(OAUTH2_CONFIGPATH . '/config.yml'); $this->assertSame( [], @@ -612,14 +583,14 @@ public function testMergeScopes(): void ); $client_id = 'galette_test'; - $config->set($client_id . '.scopes', 'member:phones;member:localization:precise'); + $test_config = new \GaletteOAuth2\Tools\Config([$client_id => ['scopes' => 'member:phones;member:localization:precise']]); $this->assertSame( [ 'member:phones', 'member:localization:precise' ], \GaletteOAuth2\Authorization\UserHelper::mergeScopes( - $config, + $test_config, 'galette_test', [] ) diff --git a/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php b/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php index dd7bbe4..8fa3444 100644 --- a/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php +++ b/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php @@ -316,4 +316,43 @@ public function testDoLoginWithoutCredentials(): void $this->assertSame(['Location' => [$this->routeparser->urlFor(OAUTH2_PREFIX . '_login')]], $test_response->getHeaders()); $this->assertSame(302, $test_response->getStatusCode()); } + + /** + * Test successful login binds the client and renews session identifier + * + * @return void + */ + public function testDoLoginSuccess(): void + { + $member = $this->getAdminMember($this->getMemberOne()); + $data = $this->dataAdherentOne(); + $this->session->request_args = [ + 'response_type' => 'code', + 'client_id' => 'galette_cli', + 'redirect_uri' => 'http://localhost:8888', + 'scope' => 'member', + 'state' => 'login-state', + ]; + $sid = session_id(); + + $request = $this->createRequest( + route_name: OAUTH2_PREFIX . '_doLogin', + method: 'POST' + ); + $request = $request->withParsedBody([ + 'login' => $data['login_adh'], + 'password' => $data['mdp_adh'], + ]); + $test_response = $this->app->handle($request); + + $this->assertSame(302, $test_response->getStatusCode()); + $this->assertStringStartsWith( + $this->routeparser->urlFor(OAUTH2_PREFIX . '_authorize') . '?', + $test_response->getHeaderLine('Location') + ); + $this->assertSame('yes', $this->session->isLoggedIn); + $this->assertSame($member->id, $this->session->user_id); + $this->assertSame('galette_cli', $this->session->client_id); + $this->assertNotSame($sid, session_id()); + } } diff --git a/tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php b/tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php index 0537dd9..2e8e069 100644 --- a/tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php +++ b/tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php @@ -76,7 +76,7 @@ public static function invalidClientIdsProvider(): array #[DataProvider('validClientIdsProvider')] public function testClientExistsWithValidClients(string $client_id): void { - $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container); + $clientRepository = $this->container->get(\GaletteOAuth2\Repositories\ClientRepository::class); $this->assertTrue( $clientRepository->clientExists($client_id), "Client '$client_id' should exist in configuration" @@ -92,7 +92,7 @@ public function testClientExistsWithValidClients(string $client_id): void #[DataProvider('invalidClientIdsProvider')] public function testClientExistsWithInvalidClients(?string $client_id): void { - $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container); + $clientRepository = $this->container->get(\GaletteOAuth2\Repositories\ClientRepository::class); $this->assertFalse( $clientRepository->clientExists($client_id), "Client '$client_id' should not exist in configuration" @@ -113,7 +113,7 @@ public function testGetClientEntityUsesConfiguredRedirectUri(): void file_put_contents($cache_file, 'https://attacker.example/cb'); try { - $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container); + $clientRepository = $this->container->get(\GaletteOAuth2\Repositories\ClientRepository::class); $client = $clientRepository->getClientEntity('galette_flarum'); $this->assertNotNull($client); @@ -142,7 +142,7 @@ public function testGetClientEntityUsesConfiguredRedirectUri(): void */ public function testClientWithoutRedirectUriIsRefused(): void { - $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container); + $clientRepository = $this->container->get(\GaletteOAuth2\Repositories\ClientRepository::class); $this->assertFalse($clientRepository->clientExists('galette_noredirect')); $this->expectLogEntry( @@ -164,7 +164,7 @@ public function testClientWithoutRedirectUriIsRefused(): void */ public function testValidateClient(): void { - $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container); + $clientRepository = $this->container->get(\GaletteOAuth2\Repositories\ClientRepository::class); $this->assertTrue($clientRepository->validateClient('galette_cli', 'cli-secret-for-tests', 'authorization_code')); $this->assertFalse($clientRepository->validateClient('galette_cli', 'wrong-secret', 'authorization_code')); @@ -182,7 +182,7 @@ public function testValidateClient(): void */ public function testValidateClientWithoutPassword(): void { - $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container); + $clientRepository = $this->container->get(\GaletteOAuth2\Repositories\ClientRepository::class); $this->assertFalse($clientRepository->validateClient('galette_nopassword', 'abc123', 'authorization_code')); $this->expectLogEntry( @@ -198,7 +198,7 @@ public function testValidateClientWithoutPassword(): void */ public function testValidateClientWithDefaultPassword(): void { - $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container); + $clientRepository = $this->container->get(\GaletteOAuth2\Repositories\ClientRepository::class); $this->assertFalse($clientRepository->validateClient('galette_defaultpassword', 'abc123', 'authorization_code')); $this->expectLogEntry( diff --git a/tests/GaletteOAuth2/Tools/tests/units/Config.php b/tests/GaletteOAuth2/Tools/tests/units/Config.php new file mode 100644 index 0000000..6b9d7f8 --- /dev/null +++ b/tests/GaletteOAuth2/Tools/tests/units/Config.php @@ -0,0 +1,104 @@ + + */ +class Config extends GaletteTestCase +{ + protected int $seed = 20260927090000; + + /** + * Test reading values + * + * @return void + */ + public function testGet(): void + { + $config = new \GaletteOAuth2\Tools\Config([ + 'global' => ['title' => 'Galette'], + 'galette_app' => [ + 'title' => 'My app', + 'legacy_data' => false, + 'redirect_logout' => null, + 'scopes' => ['member', 'member:phones'], + ], + 'galette_empty' => null, + ]); + + $this->assertSame('Galette', $config->get('global.title')); + $this->assertSame('My app', $config->get('galette_app.title', 'default')); + $this->assertSame(['member', 'member:phones'], $config->get('galette_app.scopes')); + $this->assertSame(false, $config->get('galette_app.legacy_data', true)); + $this->assertIsArray($config->get('galette_app')); + + //missing or empty values give the default + $this->assertNull($config->get('galette_app.password')); + $this->assertSame('default', $config->get('galette_app.password', 'default')); + $this->assertSame('default', $config->get('galette_app.redirect_logout', 'default')); + $this->assertSame('default', $config->get('galette_unknown.title', 'default')); + $this->assertSame('default', $config->get('galette_empty.title', 'default')); + $this->assertSame('default', $config->get('global.title.sub', 'default')); + + $this->assertTrue($config->has('galette_app.title')); + $this->assertFalse($config->has('galette_app.password')); + $this->assertFalse($config->has('galette_empty')); + } + + /** + * Test deprecated "options" entry is read as "authorize" + * + * @return void + */ + public function testOptionsMigration(): void + { + $config = new \GaletteOAuth2\Tools\Config([ + 'galette_old' => ['options' => 'uptodate'], + 'galette_both' => ['options' => 'uptodate', 'authorize' => 'active'], + ]); + + $this->assertSame('uptodate', $config->get('galette_old.authorize')); + $this->assertFalse($config->has('galette_old.options')); + $this->assertSame('active', $config->get('galette_both.authorize')); + $this->assertFalse($config->has('galette_both.options')); + + $this->expectLogEntry( + Analog::WARNING, + '"options" is deprecated, please use "authorize" instead for galette_old' + ); + $this->expectLogEntry( + Analog::WARNING, + '"options" is deprecated, please use "authorize" instead for galette_both' + ); + } + + /** + * Test loading configuration file + * + * @return void + */ + public function testFromFile(): void + { + $config = \GaletteOAuth2\Tools\Config::fromFile(OAUTH2_CONFIGPATH . '/config.yml'); + $this->assertSame('Forum Flarum', $config->get('galette_flarum.title')); + + $config = \GaletteOAuth2\Tools\Config::fromFile(OAUTH2_CONFIGPATH . '/missing.yml'); + $this->assertNull($config->get('galette_flarum.title')); + $this->expectLogEntry( + Analog::ERROR, + 'OAuth2: unable to read configuration file' + ); + } +} diff --git a/tests/GaletteOAuth2/Tools/tests/units/EncryptionKey.php b/tests/GaletteOAuth2/Tools/tests/units/EncryptionKey.php index 4fb63ae..e52205a 100644 --- a/tests/GaletteOAuth2/Tools/tests/units/EncryptionKey.php +++ b/tests/GaletteOAuth2/Tools/tests/units/EncryptionKey.php @@ -66,7 +66,7 @@ private function writeConfig(?string $config_key, ?string $file_key): Config ); } - return new Config($this->config_path . '/config.yml'); + return Config::fromFile($this->config_path . '/config.yml'); } /**