From a5286bf62f4ad243b366cd6c9182c61b13ef083a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Tue, 26 Nov 2024 20:05:40 +0100 Subject: [PATCH 1/4] add .well-known/openid-configuration to the root as well --- web.php | 1 + 1 file changed, 1 insertion(+) diff --git a/web.php b/web.php index 6ce08b2..c56baf8 100644 --- a/web.php +++ b/web.php @@ -29,6 +29,7 @@ if (!empty($_ENV['DEV'])) { Container::$routeCollection = new RouteCollection(); Container::$routeCollection->get('home', '', [HomeController::class, 'getHome']); +Container::$routeCollection->get('oauth-config-root', '.well-known/openid-configuration', [OAuthController::class, 'getConfig']); Container::$routeCollection->group('login', function (RouteCollection $routeCollection) { $routeCollection->get('login', '', [LoginController::class, 'getLoginForm']); $routeCollection->post('login-action', '', [LoginController::class, 'login']); -- 2.45.2 From dfcdd8dca7f757c4e2e577283299cd2bf77d4ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Tue, 26 Nov 2024 20:06:03 +0100 Subject: [PATCH 2/4] update yarn.lock --- public/static/yarn.lock | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/public/static/yarn.lock b/public/static/yarn.lock index 6dac2d6..31c2c98 100644 --- a/public/static/yarn.lock +++ b/public/static/yarn.lock @@ -2,6 +2,23 @@ # yarn lockfile v1 +"@fortawesome/fontawesome-free@^6.4.0": + version "6.7.1" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.1.tgz#160a48730d533ec77578ed0141661a8f0150a71d" + integrity sha512-ALIk/MOh5gYe1TG/ieS5mVUsk7VUIJTJKPMK9rFFqOgfp0Q3d5QiBXbcOMwUvs37fyZVCz46YjOE6IFeOAXCHA== + +"@orchidjs/sifter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@orchidjs/sifter/-/sifter-1.1.0.tgz#b36154ad0cda4898305d1ac44f318b41048a0438" + integrity sha512-mYwHCfr736cIWWdhhSZvDbf90AKt2xyrJspKFC3qyIJG1LtrJeJunYEqCGG4Aq2ijENbc4WkOjszcvNaIAS/pQ== + dependencies: + "@orchidjs/unicode-variants" "^1.1.2" + +"@orchidjs/unicode-variants@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.1.2.tgz#1fd71791a67fdd1591ebe0dcaadd3964537a824e" + integrity sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA== + leaflet.markercluster@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/leaflet.markercluster/-/leaflet.markercluster-1.4.1.tgz#b53f2c4f2ca7306ddab1dbb6f1861d5e8aa6c5e5" @@ -11,3 +28,11 @@ leaflet@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.6.0.tgz#aecbb044b949ec29469eeb31c77a88e2f448f308" integrity sha512-CPkhyqWUKZKFJ6K8umN5/D2wrJ2+/8UIpXppY7QDnUZW5bZL5+SEI2J7GBpwh4LIupOKqbNSQXgqmrEJopHVNQ== + +tom-select@^2.2.2: + version "2.4.1" + resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.1.tgz#6a0b6df8af3df7b09b22dd965eb75ce4d1c547bc" + integrity sha512-adI8H8+wk8RRzHYLQ3bXSk2Q+FAq/kzAATrcWlJ2fbIrEzb0VkwaXzKHTAlBwSJrhqbPJvhV/0eypFkED/nAug== + dependencies: + "@orchidjs/sifter" "^1.1.0" + "@orchidjs/unicode-variants" "^1.1.2" -- 2.45.2 From cde14ee779f673aadc1bbeb93ab59284a65c9f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Tue, 26 Nov 2024 21:00:18 +0100 Subject: [PATCH 3/4] modernize oauth token handling --- .env.example | 1 + .../structure/202411226_2042_oauth_update.sql | 26 ++ src/Cli/MaintainDatabaseCommand.php | 27 ++ src/Controller/OAuthAuthController.php | 79 ------ src/Controller/OAuthController.php | 242 ++++++++++++++++-- src/Controller/OAuthSessionController.php | 90 +++++++ src/PersistentData/Model/OAuthSession.php | 182 +++++++++++++ src/PersistentData/Model/OAuthToken.php | 84 +----- src/Repository/OAuthSessionRepository.php | 30 +++ src/Repository/OAuthTokenRepository.php | 22 +- web.php | 8 +- 11 files changed, 600 insertions(+), 191 deletions(-) create mode 100644 database/migrations/structure/202411226_2042_oauth_update.sql delete mode 100644 src/Controller/OAuthAuthController.php create mode 100644 src/Controller/OAuthSessionController.php create mode 100644 src/PersistentData/Model/OAuthSession.php create mode 100644 src/Repository/OAuthSessionRepository.php diff --git a/.env.example b/.env.example index 7eebf5d..5da0ea6 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,4 @@ RECAPTCHA_SITEKEY=your_recaptcha_sitekey RECAPTCHA_SECRET=your_recaptcha_secret JWT_RSA_PRIVATE_KEY=jwt-rsa256-private.pem JWT_RSA_PUBLIC_KEY=jwt-rsa256-public.pem +JWT_KEY_KID=1 diff --git a/database/migrations/structure/202411226_2042_oauth_update.sql b/database/migrations/structure/202411226_2042_oauth_update.sql new file mode 100644 index 0000000..9ce2891 --- /dev/null +++ b/database/migrations/structure/202411226_2042_oauth_update.sql @@ -0,0 +1,26 @@ +CREATE TABLE `oauth_sessions` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `client_id` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `scope` varchar(255) NOT NULL DEFAULT '', + `nonce` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `user_id` int(10) unsigned DEFAULT NULL, + `code` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `code_challenge` varchar(128) NULL, + `code_challenge_method` enum('plain', 'S256') NULL, + `token_claimed` tinyint(1) NOT NULL DEFAULT 0, + `created` timestamp NOT NULL DEFAULT current_timestamp(), + `expires` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `code` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +ALTER TABLE `oauth_tokens` +ADD `session_id` int(10) unsigned NULL, +ADD CONSTRAINT `oauth_tokens_session_id` FOREIGN KEY (`session_id`) REFERENCES `oauth_sessions` (`id`), +DROP INDEX `code`, +DROP INDEX `access_token`, +DROP `scope`, +DROP `nonce`, +DROP `user_id`, +DROP `code`, +DROP `access_token`; diff --git a/src/Cli/MaintainDatabaseCommand.php b/src/Cli/MaintainDatabaseCommand.php index c17b876..4701534 100644 --- a/src/Cli/MaintainDatabaseCommand.php +++ b/src/Cli/MaintainDatabaseCommand.php @@ -5,6 +5,8 @@ use SokoWeb\Database\Query\Modify; use SokoWeb\Database\Query\Select; use SokoWeb\Interfaces\Database\IResultSet; use RVR\Repository\UserPasswordResetterRepository; +use RVR\Repository\OAuthTokenRepository; +use RVR\Repository\OAuthSessionRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -13,11 +15,17 @@ class MaintainDatabaseCommand extends Command { private UserPasswordResetterRepository $userPasswordResetterRepository; + private OAuthTokenRepository $oauthTokenRepository; + + private OAuthSessionRepository $oauthSessionRepository; + public function __construct() { parent::__construct(); $this->userPasswordResetterRepository = new UserPasswordResetterRepository(); + $this->oauthTokenRepository = new OAuthTokenRepository(); + $this->oauthSessionRepository = new OAuthSessionRepository(); } public function configure(): void @@ -31,6 +39,8 @@ class MaintainDatabaseCommand extends Command try { $this->deleteExpiredPasswordResetters(); $this->deleteExpiredSessions(); + $this->deleteExpiredOauthTokens(); + $this->deleteExpiredOauthSessions(); } catch (\Exception $e) { $output->writeln('Maintenance failed!'); $output->writeln(''); @@ -69,4 +79,21 @@ class MaintainDatabaseCommand extends Command $modify->delete(); } } + + private function deleteExpiredOauthTokens(): void + { + foreach ($this->oauthTokenRepository->getAllExpired() as $oauthToken) { + \Container::$persistentDataManager->deleteFromDb($oauthToken); + } + } + + private function deleteExpiredOauthSessions(): void + { + foreach ($this->oauthSessionRepository->getAllExpired() as $oauthSession) { + if ($this->oauthTokenRepository->countAllBySession($oauthSession) > 0) { + continue; + } + \Container::$persistentDataManager->deleteFromDb($oauthSession); + } + } } diff --git a/src/Controller/OAuthAuthController.php b/src/Controller/OAuthAuthController.php deleted file mode 100644 index 7c5d3e6..0000000 --- a/src/Controller/OAuthAuthController.php +++ /dev/null @@ -1,79 +0,0 @@ -oAuthClientRepository = new OAuthClientRepository(); - } - - public function isAuthenticationRequired(): bool - { - return true; - } - - public function auth() - { - $redirectUri = \Container::$request->query('redirect_uri'); - $clientId = \Container::$request->query('client_id'); - $scope = \Container::$request->query('scope') ? \Container::$request->query('scope'): ''; - $state = \Container::$request->query('state'); - $nonce = \Container::$request->query('nonce') ? \Container::$request->query('nonce'): ''; - - if (!$clientId || !$redirectUri || !$state) { - return new HtmlContent('oauth/oauth_error', ['error' => 'An invalid request was made. Please start authentication again.']); - } - - $client = $this->oAuthClientRepository->getByClientId($clientId); - if ($client === null) { - return new HtmlContent('oauth/oauth_error', ['error' => 'Client is not authorized.']); - } - - $redirectUriParsed = parse_url($redirectUri); - $redirectUriBase = $redirectUriParsed['scheme'] . '://' . $redirectUriParsed['host'] . $redirectUriParsed['path']; - $redirectUriQuery = []; - if (isset($redirectUriParsed['query'])) { - parse_str($redirectUriParsed['query'], $redirectUriQuery); - } - - if (!in_array($redirectUriBase, $client->getRedirectUrisArray())) { - return new HtmlContent('oauth/oauth_error', ['error' => 'Redirect URI \'' . $redirectUriBase .'\' is not allowed for this client.']); - } - - /** - * @var ?User $user - */ - $user = \Container::$request->user(); - $code = bin2hex(random_bytes(16)); - $accessToken = bin2hex(random_bytes(16)); - - $token = new OAuthToken(); - $token->setNonce($nonce); - $token->setScope($scope); - $token->setUser($user); - $token->setCode($code); - $token->setAccessToken($accessToken); - $token->setCreatedDate(new DateTime()); - $token->setExpiresDate(new DateTime('+5 minutes')); - \Container::$persistentDataManager->saveToDb($token); - - $redirectUriQuery = array_merge($redirectUriQuery, [ - 'state' => $state, - 'code' => $code - ]); - $finalRedirectUri = $redirectUriBase . '?' . http_build_query($redirectUriQuery); - - return new Redirect($finalRedirectUri, IRedirect::TEMPORARY); - } -} diff --git a/src/Controller/OAuthController.php b/src/Controller/OAuthController.php index 48b318e..66aaa4b 100644 --- a/src/Controller/OAuthController.php +++ b/src/Controller/OAuthController.php @@ -2,9 +2,16 @@ use DateTime; use Firebase\JWT\JWT; +use Firebase\JWT\Key; +use Firebase\JWT\SignatureInvalidException; +use Firebase\JWT\BeforeValidException; +use Firebase\JWT\ExpiredException; +use RVR\Repository\OAuthSessionRepository; use RVR\Repository\OAuthTokenRepository; use RVR\Repository\UserRepository; use RVR\PersistentData\Model\User; +use RVR\PersistentData\Model\OAuthSession; +use RVR\PersistentData\Model\OAuthToken; use RVR\Repository\OAuthClientRepository; use SokoWeb\Interfaces\Response\IContent; use SokoWeb\Response\JsonContent; @@ -13,6 +20,8 @@ class OAuthController { private OAuthClientRepository $oAuthClientRepository; + private OAuthSessionRepository $oAuthSessionRepository; + private OAuthTokenRepository $oAuthTokenRepository; private UserRepository $userRepository; @@ -20,60 +29,181 @@ class OAuthController public function __construct() { $this->oAuthClientRepository = new OAuthClientRepository(); + $this->oAuthSessionRepository = new OAuthSessionRepository(); $this->oAuthTokenRepository = new OAuthTokenRepository(); $this->userRepository = new UserRepository(); } - public function getToken(): ?IContent + public function generateToken(): ?IContent { - $clientId = \Container::$request->post('client_id'); - $clientSecret = \Container::$request->post('client_secret'); + $credentials = $this->getClientCredentials(); $code = \Container::$request->post('code'); + $redirectUri = \Container::$request->post('redirect_uri'); - if (!$clientId || !$clientSecret || !$code) { + if (!$credentials['clientId'] || !$code || !$redirectUri) { return new JsonContent([ 'error' => 'An invalid request was made.' ]); } - $client = $this->oAuthClientRepository->getByClientId($clientId); - if ($client === null || $client->getClientSecret() !== $clientSecret) { + $client = $this->oAuthClientRepository->getByClientId($credentials['clientId']); + if ($client === null) { return new JsonContent([ - 'error' => 'Client is not authorized.' + 'error' => 'Client is not found.' ]); } - $token = $this->oAuthTokenRepository->getByCode($code); - if ($token === null || $token->getExpiresDate() < new DateTime()) { + $redirectUriBase = explode('?', $redirectUri)[0]; + if (!in_array($redirectUriBase, $client->getRedirectUrisArray())) { + return new JsonContent([ + 'error' => 'Redirect URI \'' . $redirectUriBase .'\' is not allowed for this client.' + ]); + } + + $session = $this->oAuthSessionRepository->getByCode($code); + if ($session === null || $session->getTokenClaimed() || $session->getExpiresDate() < new DateTime()) { return new JsonContent([ 'error' => 'The provided code is invalid.' ]); } - $payload = array_merge([ + $codeChallenge = $session->getCodeChallenge(); + if ($codeChallenge === null && $client->getClientSecret() !== $credentials['clientSecret']) { + return new JsonContent([ + 'error' => 'Client is not authorized.' + ]); + } + + if ($session->getClientId() !== $credentials['clientId']) { + return new JsonContent([ + 'error' => 'This code cannot be used by this client!' + ]); + } + + if ($codeChallenge !== null) { + $codeVerifier = \Container::$request->post('code_verifier') ?: ''; + if ($session->getCodeChallengeMethod() === 'S256') { + $hash = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); + } else { + $hash = $codeVerifier; + } + if ($codeChallenge !== $hash) { + return new JsonContent([ + 'error' => 'Code challenge failed!' + ]); + } + } + + $session->setTokenClaimed(true); + \Container::$persistentDataManager->saveToDb($session); + + $token = new OAuthToken(); + $token->setSession($session); + $token->setCreatedDate(new DateTime()); + $token->setExpiresDate(new DateTime('+1 hours')); + \Container::$persistentDataManager->saveToDb($token); + + $commonPayload = [ 'iss' => $_ENV['APP_URL'], - 'iat' => (int)$token->getCreatedDate()->getTimestamp(), - 'nbf' => (int)$token->getCreatedDate()->getTimestamp(), - 'exp' => (int)$token->getExpiresDate()->getTimestamp(), - 'aud' => $clientId, - 'nonce' => $token->getNonce() - ], $this->getUserInfoInternal( - $this->userRepository->getById($token->getUserId()), - $token->getScopeArray()) + 'iat' => $token->getCreatedDate()->getTimestamp(), + 'nbf' => $session->getCreatedDate()->getTimestamp(), + 'exp' => $token->getExpiresDate()->getTimestamp(), + 'aud' => $session->getClientId(), + 'nonce' => $session->getNonce() + ]; + $idTokenPayload = array_merge($commonPayload, $this->getUserInfoInternal( + $this->userRepository->getById($session->getUserId()), + $session->getScopeArray()) ); + $accessTokenPayload = array_merge($commonPayload, [ + 'jti' => $token->getId(), + ]); $privateKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PRIVATE_KEY']); - $jwt = JWT::encode($payload, $privateKey, 'RS256'); + $idToken = JWT::encode($idTokenPayload, $privateKey, 'RS256', $_ENV['JWT_KEY_KID']); + $accessToken = JWT::encode($accessTokenPayload, $privateKey, 'RS256', $_ENV['JWT_KEY_KID']); return new JsonContent([ - 'access_token' => $token->getAccessToken(), + 'access_token' => $accessToken, 'expires_in' => $token->getExpiresDate()->getTimestamp() - (new DateTime())->getTimestamp(), - 'scope' => $token->getScope(), - 'id_token' => $jwt, + 'scope' => $session->getScope(), + 'id_token' => $idToken, 'token_type' => 'Bearer' ]); } + public function introspectToken(): ?IContent + { + $credentials = $this->getClientCredentials(); + $accessToken = \Container::$request->post('token'); + + if (!$credentials['clientId'] || !$credentials['clientSecret'] || !$accessToken) { + return new JsonContent([ + 'error' => 'An invalid request was made.' + ]); + } + + $client = $this->oAuthClientRepository->getByClientId($credentials['clientId']); + if ($client === null || $client->getClientSecret() !== $credentials['clientSecret']) { + return new JsonContent([ + 'error' => 'Client is not authorized.' + ]); + } + + $tokenValidated = $this->validateTokenAndSession($accessToken, $token, $session); + if (!$tokenValidated) { + return new JsonContent([ + 'active' => false + ]); + } + + if ($session->getClientId() !== $credentials['clientId']) { + return new JsonContent([ + 'active' => false + ]); + } + + return new JsonContent([ + 'active' => true, + 'scope' => $session->getScope(), + 'client_id' => $session->getClientId(), + 'exp' => $token->getExpiresDate()->getTimestamp(), + ]); + } + + public function revokeToken(): ?IContent + { + $credentials = $this->getClientCredentials(); + $accessToken = \Container::$request->post('token'); + + if (!$credentials['clientId'] || !$credentials['clientSecret'] || !$accessToken) { + return new JsonContent([ + 'error' => 'An invalid request was made.' + ]); + } + + $client = $this->oAuthClientRepository->getByClientId($credentials['clientId']); + if ($client === null || $client->getClientSecret() !== $credentials['clientSecret']) { + return new JsonContent([ + 'error' => 'Client is not authorized.' + ]); + } + + $tokenValidated = $this->validateTokenAndSession($accessToken, $token, $session); + if (!$tokenValidated) { + return new JsonContent([]); + } + + $session = $this->oAuthSessionRepository->getById($token->getSessionId()); + if ($session->getClientId() !== $credentials['clientId']) { + return new JsonContent([]); + } + + \Container::$persistentDataManager->deleteFromDb($token); + + return new JsonContent([]); + } + public function getUserInfo() : IContent { $authorization = \Container::$request->header('Authorization'); @@ -84,9 +214,8 @@ class OAuthController } $accessToken = substr($authorization, strlen('Bearer ')); - $token = $this->oAuthTokenRepository->getByAccessToken($accessToken); - - if ($token === null || $token->getExpiresDate() < new DateTime()) { + $tokenValidated = $this->validateTokenAndSession($accessToken, $token, $session); + if (!$tokenValidated) { return new JsonContent([ 'error' => 'The provided access token is invalid.' ]); @@ -94,8 +223,8 @@ class OAuthController return new JsonContent( $this->getUserInfoInternal( - $this->userRepository->getById($token->getUserId()), - $token->getScopeArray() + $this->userRepository->getById($session->getUserId()), + $session->getScopeArray() ) ); } @@ -106,7 +235,10 @@ class OAuthController 'issuer' => $_ENV['APP_URL'], 'authorization_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.auth')->generateLink(), 'token_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.token')->generateLink(), + 'introspection_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.token.introspect')->generateLink(), + 'revocation_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.token.revoke')->generateLink(), 'userinfo_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.userinfo')->generateLink(), + 'end_session_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('logout')->generateLink(), 'jwks_uri' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.certs')->generateLink(), 'response_types_supported' => [ @@ -128,6 +260,7 @@ class OAuthController ], 'token_endpoint_auth_methods_supported' => [ + 'client_secret_basic', 'client_secret_post', ], 'claims_supported' => @@ -167,13 +300,66 @@ class OAuthController 'kty' => 'RSA', 'alg' => 'RS256', 'use' => 'sig', - 'kid' => '1', + 'kid' => $_ENV['JWT_KEY_KID'], 'n' => str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['n'])), 'e' => str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['e'])), ] ]]); } + private function getClientCredentials(): array + { + $authorization = \Container::$request->header('Authorization'); + if ($authorization !== null) { + $basicAuthEncoded = substr($authorization, strlen('Basic ')); + $basicAuth = explode(':', base64_decode($basicAuthEncoded)); + if (count($basicAuth) === 2) { + $clientId = rawurldecode($basicAuth[0]); + $clientSecret = rawurldecode($basicAuth[1]); + } else { + $clientId = null; + $clientSecret = null; + } + } else { + $clientId = \Container::$request->post('client_id'); + $clientSecret = \Container::$request->post('client_secret'); + } + return ['clientId' => $clientId, 'clientSecret' => $clientSecret]; + } + + private function validateTokenAndSession( + string $accessToken, + ?OAuthToken &$token, + ?OAuthSession &$session): bool + { + $publicKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PUBLIC_KEY']); + try { + $payload = JWT::decode($accessToken, new Key($publicKey, 'RS256')); + $token = $this->oAuthTokenRepository->getById($payload->jti); + } catch (SignatureInvalidException | BeforeValidException | ExpiredException) { + $token = null; + } catch (\UnexpectedValueException $e) { + error_log($e->getMessage() . ' Token was: ' . $accessToken); + $token = null; + } + + if ($token === null || $token->getExpiresDate() < new DateTime()) { + return false; + } + + $session = $this->oAuthSessionRepository->getById($token->getSessionId()); + if ($session === null) { + return false; + } + + return true; + } + + /** + * @param User $user + * @param string[] $scope + * @return array + */ private function getUserInfoInternal(User $user, array $scope): array { $userInfo = []; diff --git a/src/Controller/OAuthSessionController.php b/src/Controller/OAuthSessionController.php new file mode 100644 index 0000000..6644ca5 --- /dev/null +++ b/src/Controller/OAuthSessionController.php @@ -0,0 +1,90 @@ +oAuthClientRepository = new OAuthClientRepository(); + } + + public function isAuthenticationRequired(): bool + { + return true; + } + + public function auth(): HtmlContent|Redirect + { + $redirectUri = \Container::$request->query('redirect_uri'); + $clientId = \Container::$request->query('client_id'); + $scope = \Container::$request->query('scope') ? \Container::$request->query('scope'): ''; + $state = \Container::$request->query('state'); + $nonce = \Container::$request->query('nonce') ? \Container::$request->query('nonce'): ''; + $codeChallenge = \Container::$request->query('code_challenge') ?: null; + $codeChallengeMethod = \Container::$request->query('code_challenge_method') ?: null; + + if (!$clientId || !$redirectUri || !$state || (!$codeChallenge && $codeChallengeMethod)) { + return new HtmlContent('oauth/oauth_error', ['error' => 'An invalid request was made. Please start authentication again.']); + } + + if ($codeChallenge && (strlen($codeChallenge) < 43 || strlen($codeChallenge) > 128)) { + return new HtmlContent('oauth/oauth_error', ['error' => 'Code challenge should be one between 43 and 128 characters long.']); + } + $possibleCodeChallengeMethods = ['plain', 'S256']; + if ($codeChallenge && !in_array($codeChallengeMethod, $possibleCodeChallengeMethods)) { + return new HtmlContent('oauth/oauth_error', ['error' => 'Code challenge method should be one of the following: ' . implode(',', $possibleCodeChallengeMethods)]); + } + + $client = $this->oAuthClientRepository->getByClientId($clientId); + if ($client === null) { + return new HtmlContent('oauth/oauth_error', ['error' => 'Client is not found.']); + } + + $redirectUriQueryParsed = []; + if (str_contains('?', $redirectUri)) { + [$redirectUriBase, $redirectUriQuery] = explode('?', $redirectUri, 2); + parse_str($redirectUriQuery, $redirectUriQueryParsed); + } else { + $redirectUriBase = $redirectUri; + } + if (!in_array($redirectUriBase, $client->getRedirectUrisArray())) { + return new HtmlContent('oauth/oauth_error', ['error' => 'Redirect URI \'' . $redirectUriBase .'\' is not allowed for this client.']); + } + + /** + * @var ?User $user + */ + $user = \Container::$request->user(); + $code = bin2hex(random_bytes(16)); + + $session = new OAuthSession(); + $session->setClientId($clientId); + $session->setNonce($nonce); + $session->setScope($scope); + $session->setCodeChallenge($codeChallenge); + $session->setCodeChallengeMethod($codeChallengeMethod); + $session->setUser($user); + $session->setCode($code); + $session->setCreatedDate(new DateTime()); + $session->setExpiresDate(new DateTime('+5 minutes')); + \Container::$persistentDataManager->saveToDb($session); + + $redirectUriQueryParsed = array_merge($redirectUriQueryParsed, [ + 'state' => $state, + 'code' => $code + ]); + $finalRedirectUri = $redirectUriBase . '?' . http_build_query($redirectUriQueryParsed); + + return new Redirect($finalRedirectUri, IRedirect::TEMPORARY); + } +} diff --git a/src/PersistentData/Model/OAuthSession.php b/src/PersistentData/Model/OAuthSession.php new file mode 100644 index 0000000..d0f1579 --- /dev/null +++ b/src/PersistentData/Model/OAuthSession.php @@ -0,0 +1,182 @@ + User::class]; + + private static array $possibleScopeValues = ['openid', 'email', 'profile', 'union_profile']; + + private static array $possibleCodeChallengeMethodValues = ['plain', 'S256']; + + private string $clientId = ''; + + private array $scope = []; + + private string $nonce = ''; + + private ?string $codeChallenge = null; + + private ?string $codeChallengeMethod = null; + + private ?User $user = null; + + private ?int $userId = null; + + private string $code = ''; + + private DateTime $created; + + private DateTime $expires; + + private bool $tokenClaimed = false; + + public function setScopeArray(array $scope): void + { + $this->scope = array_intersect($scope, self::$possibleScopeValues); + } + + public function setClientId(string $clientId): void + { + $this->clientId = $clientId; + } + + public function setScope(string $scope): void + { + $this->setScopeArray(explode(' ', $scope)); + } + + public function setNonce(string $nonce): void + { + $this->nonce = $nonce; + } + + public function setCodeChallenge(?string $codeChallenge): void + { + $this->codeChallenge = $codeChallenge; + } + + public function setCodeChallengeMethod(?string $codeChallengeMethod): void + { + if ($codeChallengeMethod !== null && !in_array($codeChallengeMethod, self::$possibleCodeChallengeMethodValues)) { + throw new \UnexpectedValueException($codeChallengeMethod . ' is not possible for challengeMethod!'); + } + $this->codeChallengeMethod = $codeChallengeMethod; + } + + public function setUser(User $user): void + { + $this->user = $user; + } + + public function setUserId(int $userId): void + { + $this->userId = $userId; + } + + public function setCode(string $code): void + { + $this->code = $code; + } + + public function setCreatedDate(DateTime $created): void + { + $this->created = $created; + } + + public function setExpiresDate(DateTime $expires): void + { + $this->expires = $expires; + } + + public function setCreated(string $created): void + { + $this->created = new DateTime($created); + } + + public function setExpires(string $expires): void + { + $this->expires = new DateTime($expires); + } + + public function setTokenClaimed(bool $tokenClaimed): void + { + $this->tokenClaimed = $tokenClaimed; + } + + public function getClientId(): string + { + return $this->clientId; + } + + public function getScope(): string + { + return implode(' ', $this->scope); + } + + public function getScopeArray(): array + { + return $this->scope; + } + + public function getNonce(): string + { + return $this->nonce; + } + + public function getCodeChallenge(): ?string + { + return $this->codeChallenge; + } + + public function getCodeChallengeMethod(): ?string + { + return $this->codeChallengeMethod; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function getUserId(): ?int + { + return $this->userId; + } + + public function getCode(): string + { + return $this->code; + } + + public function getCreatedDate(): DateTime + { + return $this->created; + } + + public function getCreated(): string + { + return $this->created->format('Y-m-d H:i:s'); + } + + public function getExpiresDate(): DateTime + { + return $this->expires; + } + + public function getExpires(): string + { + return $this->expires->format('Y-m-d H:i:s'); + } + + public function getTokenClaimed(): bool + { + return $this->tokenClaimed; + } +} diff --git a/src/PersistentData/Model/OAuthToken.php b/src/PersistentData/Model/OAuthToken.php index e82242c..ffe6180 100644 --- a/src/PersistentData/Model/OAuthToken.php +++ b/src/PersistentData/Model/OAuthToken.php @@ -7,61 +7,26 @@ class OAuthToken extends Model { protected static string $table = 'oauth_tokens'; - protected static array $fields = ['scope', 'nonce', 'user_id', 'code', 'access_token', 'created', 'expires']; + protected static array $fields = ['session_id', 'created', 'expires']; - protected static array $relations = ['user' => User::class]; + protected static array $relations = ['session' => OAuthSession::class]; - private static array $possibleScopeValues = ['openid', 'email', 'profile']; + private ?OAuthSession $session = null; - private array $scope = []; - - private string $nonce = ''; - - private ?User $user = null; - - private ?int $userId = null; - - private string $code = ''; - - private string $accessToken = ''; + private ?int $sessionId = null; private DateTime $created; private DateTime $expires; - public function setScopeArray(array $scope): void + public function setSession(OAuthSession $session): void { - $this->scope = array_intersect($scope, self::$possibleScopeValues); + $this->session = $session; } - public function setScope(string $scope): void + public function setSessionId(int $sessionId): void { - $this->setScopeArray(explode(' ', $scope)); - } - - public function setNonce(string $nonce): void - { - $this->nonce = $nonce; - } - - public function setUser(User $user): void - { - $this->user = $user; - } - - public function setUserId(int $userId): void - { - $this->userId = $userId; - } - - public function setCode(string $code): void - { - $this->code = $code; - } - - public function setAccessToken(string $accessToken): void - { - $this->accessToken = $accessToken; + $this->sessionId = $sessionId; } public function setCreatedDate(DateTime $created): void @@ -84,39 +49,14 @@ class OAuthToken extends Model $this->expires = new DateTime($expires); } - public function getScope(): string + public function getSession(): ?OAuthSession { - return implode(' ', $this->scope); + return $this->session; } - public function getScopeArray(): array + public function getSessionId(): ?int { - return $this->scope; - } - - public function getNonce(): string - { - return $this->nonce; - } - - public function getUser(): ?User - { - return $this->user; - } - - public function getUserId(): ?int - { - return $this->userId; - } - - public function getCode(): string - { - return $this->code; - } - - public function getAccessToken(): string - { - return $this->accessToken; + return $this->sessionId; } public function getCreatedDate(): DateTime diff --git a/src/Repository/OAuthSessionRepository.php b/src/Repository/OAuthSessionRepository.php new file mode 100644 index 0000000..2cc4061 --- /dev/null +++ b/src/Repository/OAuthSessionRepository.php @@ -0,0 +1,30 @@ +selectFromDbById($id, OAuthSession::class); + } + + public function getByCode(string $code): ?OAuthSession + { + $select = new Select(\Container::$dbConnection); + $select->where('code', '=', $code); + + return \Container::$persistentDataManager->selectFromDb($select, OAuthSession::class); + } + + public function getAllExpired(): Generator + { + $select = new Select(\Container::$dbConnection); + $select->where('expires', '<', (new DateTime())->format('Y-m-d H:i:s')); + + yield from \Container::$persistentDataManager->selectMultipleFromDb($select, OAuthSession::class); + } +} diff --git a/src/Repository/OAuthTokenRepository.php b/src/Repository/OAuthTokenRepository.php index 4aaa05b..d85ee4b 100644 --- a/src/Repository/OAuthTokenRepository.php +++ b/src/Repository/OAuthTokenRepository.php @@ -4,6 +4,7 @@ use DateTime; use Generator; use SokoWeb\Database\Query\Select; use RVR\PersistentData\Model\OAuthToken; +use RVR\PersistentData\Model\OAuthSession; class OAuthTokenRepository { @@ -12,20 +13,16 @@ class OAuthTokenRepository return \Container::$persistentDataManager->selectFromDbById($id, OAuthToken::class); } - public function getByCode(string $code): ?OAuthToken + public function getAllBySession(OAuthSession $session, bool $useRelations = false, array $withRelations = []): Generator { - $select = new Select(\Container::$dbConnection); - $select->where('code', '=', $code); + $select = $this->selectAllBySession($session); - return \Container::$persistentDataManager->selectFromDb($select, OAuthToken::class); + yield from \Container::$persistentDataManager->selectMultipleFromDb($select, OAuthToken::class, $useRelations, $withRelations); } - public function getByAccessToken(string $accessToken): ?OAuthToken + public function countAllBySession(OAuthSession $session): int { - $select = new Select(\Container::$dbConnection); - $select->where('access_token', '=', $accessToken); - - return \Container::$persistentDataManager->selectFromDb($select, OAuthToken::class); + return $this->selectAllBySession($session)->count(); } public function getAllExpired(): Generator @@ -35,4 +32,11 @@ class OAuthTokenRepository yield from \Container::$persistentDataManager->selectMultipleFromDb($select, OAuthToken::class); } + + private function selectAllBySession(OAuthSession $session): Select + { + $select = new Select(\Container::$dbConnection, OAuthToken::getTable()); + $select->where('session_id', '=', $session->getId()); + return $select; + } } diff --git a/web.php b/web.php index c56baf8..df0f0b3 100644 --- a/web.php +++ b/web.php @@ -7,7 +7,7 @@ use SokoWeb\Request\Request; use SokoWeb\Request\Session; use RVR\Controller\HomeController; use RVR\Controller\LoginController; -use RVR\Controller\OAuthAuthController; +use RVR\Controller\OAuthSessionController; use RVR\Controller\OAuthController; use RVR\Controller\UserController; use RVR\Controller\UserSearchController; @@ -37,8 +37,10 @@ Container::$routeCollection->group('login', function (RouteCollection $routeColl $routeCollection->get('login.google-action', 'google/code', [LoginController::class, 'loginWithGoogle']); }); Container::$routeCollection->group('oauth', function (RouteCollection $routeCollection) { - $routeCollection->get('oauth.auth', 'auth', [OAuthAuthController::class, 'auth']); - $routeCollection->post('oauth.token', 'token', [OAuthController::class, 'getToken']); + $routeCollection->get('oauth.auth', 'auth', [OAuthSessionController::class, 'auth']); + $routeCollection->post('oauth.token', 'token', [OAuthController::class, 'generateToken']); + $routeCollection->post('oauth.token.introspect', 'token/introspect', [OAuthController::class, 'introspectToken']); + $routeCollection->post('oauth.token.revoke', 'token/revoke', [OAuthController::class, 'revokeToken']); $routeCollection->get('oauth.userinfo', 'userinfo', [OAuthController::class, 'getUserInfo']); $routeCollection->get('oauth.config', '.well-known/openid-configuration', [OAuthController::class, 'getConfig']); $routeCollection->get('oauth.certs', 'certs', [OAuthController::class, 'getCerts']); -- 2.45.2 From cac57d7f718a64e57650a80baa8960674f39796c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Tue, 26 Nov 2024 21:01:29 +0100 Subject: [PATCH 4/4] decease session expiration time --- src/Cli/MaintainDatabaseCommand.php | 2 +- web.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cli/MaintainDatabaseCommand.php b/src/Cli/MaintainDatabaseCommand.php index 4701534..af699df 100644 --- a/src/Cli/MaintainDatabaseCommand.php +++ b/src/Cli/MaintainDatabaseCommand.php @@ -69,7 +69,7 @@ class MaintainDatabaseCommand extends Command //TODO: model may be used for sessions too $select = new Select(\Container::$dbConnection, 'sessions'); $select->columns(['id']); - $select->where('updated', '<', (new DateTime('-7 days'))->format('Y-m-d H:i:s')); + $select->where('updated', '<', (new DateTime('-1 days'))->format('Y-m-d H:i:s')); $result = $select->execute(); diff --git a/web.php b/web.php index df0f0b3..587ff34 100644 --- a/web.php +++ b/web.php @@ -118,7 +118,7 @@ Container::$routeCollection->group('communities', function (RouteCollection $rou Container::$sessionHandler = new DatabaseSessionHandler( Container::$dbConnection, 'sessions', - new DateTime('-7 days') + new DateTime('-1 days') ); session_set_save_handler(Container::$sessionHandler, true); -- 2.45.2