diff --git a/database/migrations/structure/20230411_1736_oauth_openid.sql b/database/migrations/structure/20230411_1736_oauth_openid.sql new file mode 100644 index 0000000..433ffc7 --- /dev/null +++ b/database/migrations/structure/20230411_1736_oauth_openid.sql @@ -0,0 +1,4 @@ +ALTER TABLE `oauth_tokens` +ADD `scope` varchar(255) NOT NULL DEFAULT '', +ADD `access_token` varchar(255) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, +ADD UNIQUE `access_token` (`access_token`); diff --git a/src/Controller/OAuthAuthController.php b/src/Controller/OAuthAuthController.php new file mode 100644 index 0000000..f68b4c0 --- /dev/null +++ b/src/Controller/OAuthAuthController.php @@ -0,0 +1,70 @@ +request = $request; + $this->pdm = new PersistentDataManager(); + } + + public function authorize(): bool + { + return $this->request->user() !== null; + } + + public function auth() + { + $redirectUri = $this->request->query('redirect_uri'); + $scope = $this->request->query('scope') ? $this->request->query('scope'): ''; + $state = $this->request->query('state'); + $nonce = $this->request->query('nonce') ? $this->request->query('nonce'): ''; + + if (!$redirectUri || !$state) { + return new HtmlContent('oauth/oauth_error', ['error' => 'An invalid request was made. Please start authentication again.']); + } + + $this->request->session()->delete('oauth_payload'); + + /** + * @var ?User $user + */ + $user = $this->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')); + $this->pdm->saveToDb($token); + + $redirectUri = $redirectUri; + $additionalUriParams = [ + 'state' => $state, + 'code' => $code + ]; + $and = (strpos($redirectUri, '?') !== false) ? '&' : '?'; + $finalRedirectUri = $redirectUri . $and . http_build_query($additionalUriParams); + + return new Redirect($finalRedirectUri, IRedirect::TEMPORARY); + } +} diff --git a/src/Controller/OAuthController.php b/src/Controller/OAuthController.php new file mode 100644 index 0000000..4aee701 --- /dev/null +++ b/src/Controller/OAuthController.php @@ -0,0 +1,182 @@ +request = $request; + $this->oAuthTokenRepository = new OAuthTokenRepository(); + $this->userRepository = new UserRepository(); + } + + public function getToken(): ?IContent + { + $token = $this->oAuthTokenRepository->getByCode($this->request->post('code')); + + if ($token === null || $token->getExpiresDate() < new DateTime()) { + return new JsonContent([ + 'error' => 'The provided code is invalid.' + ]); + } + + $payload = array_merge([ + 'iss' => $_ENV['APP_URL'], + 'iat' => (int)$token->getCreatedDate()->getTimestamp(), + 'nbf' => (int)$token->getCreatedDate()->getTimestamp(), + 'exp' => (int)$token->getExpiresDate()->getTimestamp(), + 'nonce' => $token->getNonce() + ], $this->getUserInfoInternal( + $this->userRepository->getById($token->getUserId()), + $token->getScopeArray()) + ); + + $privateKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PRIVATE_KEY']); + $jwt = JWT::encode($payload, $privateKey, 'RS256'); + + return new JsonContent([ + 'access_token' => $token->getAccessToken(), + 'expires_in' => $token->getExpiresDate()->getTimestamp() - (new DateTime())->getTimestamp(), + 'scope' => $token->getScope(), + 'id_token' => $jwt, + 'token_type' => 'Bearer' + ]); + } + + public function getUserInfo() : IContent + { + //TODO: headers should be set by soko-web + $headers = getallheaders(); + + if (!isset($headers['Authorization'])) { + return new JsonContent([ + 'error' => 'No Authorization header was sent.' + ]); + } + + $accessToken = substr($headers['Authorization'], strlen('Bearer ')); + $token = $this->oAuthTokenRepository->getByAccessToken($accessToken); + + if ($token === null || $token->getExpiresDate() < new DateTime()) { + return new JsonContent([ + 'error' => 'The provided access token is invalid.' + ]); + } + + return new JsonContent( + $this->getUserInfoInternal( + $this->userRepository->getById($token->getUserId()), + $token->getScopeArray() + ) + ); + } + + public function getConfig(): IContent + { + return new JsonContent([ + 'issuer' => $_ENV['APP_URL'], + 'authorization_endpoint' => $this->request->getBase() . '/oauth/auth', + 'token_endpoint' => $this->request->getBase() . '/oauth/token', + 'userinfo_endpoint' => $this->request->getBase() . '/oauth/userinfo', + 'jwks_uri' => $this->request->getBase() . '/oauth/certs', + 'response_types_supported' => + [ + 'code', + ], + 'subject_types_supported' => + [ + 'public', + ], + 'id_token_signing_alg_values_supported' => + [ + 'RS256', + ], + 'scopes_supported' => + [ + 'openid', + 'email', + 'profile', + ], + 'token_endpoint_auth_methods_supported' => + [ + 'client_secret_post', + ], + 'claims_supported' => + [ + 'aud', + 'email', + 'exp', + 'full_name', + 'iat', + 'id_number', + 'iss', + 'nickname', + 'phone', + 'picture', + 'sub', + 'username', + ], + 'code_challenge_methods_supported' => + [ + 'plain', + 'S256', + ], + 'grant_types_supported' => + [ + 'authorization_code', + ], + ]); + } + + public function getCerts(): IContent + { + $publicKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PUBLIC_KEY']); + $keyInfo = openssl_pkey_get_details(openssl_pkey_get_public($publicKey)); + + return new JsonContent(['keys' => [ + [ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'use' => 'sig', + 'kid' => '1', + 'n' => str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['n'])), + 'e' => str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['e'])), + ] + ]]); + } + + private function getUserInfoInternal(User $user, array $scope): array + { + $userInfo = []; + if (in_array('openid', $scope)) { + $userInfo['sub'] = $user->getId(); + } + if (in_array('email', $scope)) { + $userInfo['email'] = $user->getEmail(); + } + if (in_array('profile', $scope)) { + if ($user->getUsername() !== null) { + $userInfo['preferred_username'] = $user->getUsername(); + } + $userInfo['name'] = $user->getFullName(); + $userInfo['nickname'] = $user->getNickname(); + $userInfo['phone_number'] = $user->getPhone(); + $userInfo['id_number'] = $user->getIdNumber(); + } + return $userInfo; + } +} diff --git a/src/Controller/OAuthLoginController.php b/src/Controller/OAuthLoginController.php deleted file mode 100644 index 7a970d4..0000000 --- a/src/Controller/OAuthLoginController.php +++ /dev/null @@ -1,129 +0,0 @@ -request = $request; - $this->pdm = new PersistentDataManager(); - } - - public function startOauth() - { - $redirectUri = $this->request->query('redirect_uri'); - $state = $this->request->query('state'); - $nonce = $this->request->query('nonce'); - - if (!$redirectUri || !$state) { - return new HtmlContent('oauth/oauth_error', ['error' => 'An invalid request was made. Please start authentication again.']); - } - - $this->request->session()->set('oauth_payload', [ - 'redirect_uri' => $redirectUri, - 'state' => $state, - 'nonce' => $nonce === null ? '' : $nonce - ]); - - $this->request->session()->set('redirect_after_login', \Container::$routeCollection->getRoute('oauth-finish')->generateLink()); - - return new Redirect(\Container::$routeCollection->getRoute('login')->generateLink(), IRedirect::TEMPORARY); - } - - public function finishOauth() - { - $oAuthPayload = $this->request->session()->get('oauth_payload'); - if ($oAuthPayload === null) { - return new HtmlContent('oauth/oauth_error', ['error' => 'An invalid request was made. Please start authentication again.']); - } - - $this->request->session()->delete('oauth_payload'); - - /** - * @var ?User $user - */ - $user = $this->request->user(); - if ($user === null) { - return new HtmlContent('oauth/oauth_error', ['error' => 'You are not logged in. Please start authentication again.']); - } - - $code = bin2hex(random_bytes(16)); - - $token = new OAuthToken(); - $token->setNonce($oAuthPayload['nonce']); - $token->setUser($user); - $token->setCode($code); - $token->setCreatedDate(new DateTime()); - $token->setExpiresDate(new DateTime('+5 minutes')); - $this->pdm->saveToDb($token); - - $redirectUri = $oAuthPayload['redirect_uri']; - $additionalUriParams = [ - 'state' => $oAuthPayload['state'], - 'code' => $code - ]; - $and = (strpos($redirectUri, '?') !== false) ? '&' : '?'; - $finalRedirectUri = $redirectUri . $and . http_build_query($additionalUriParams); - - return new Redirect($finalRedirectUri, IRedirect::TEMPORARY); - } - - public function getToken(): ?IContent - { - $oAuthTokenRepository = new OAuthTokenRepository(); - $userRepository = new UserRepository(); - $token = $oAuthTokenRepository->getByCode($this->request->post('code')); - - if ($token === null || $token->getExpiresDate() < new DateTime()) { - return new JsonContent([ - 'error' => 'The provided code is invalid.' - ]); - } - - $user = $userRepository->getById($token->getUserId()); - - $payload = [ - 'iss' => $_ENV['APP_URL'], - 'iat' => (int)$token->getCreatedDate()->format('U'), - 'nbf' => (int)$token->getCreatedDate()->format('U'), - 'exp' => (int)$token->getExpiresDate()->format('U'), - 'nonce' => $token->getNonce(), - 'sub' => $user->getId(), - 'email' => $user->getEmail(), - 'username' => $user->getUsername(), - 'full_name' => $user->getFullName(), - 'nickname' => $user->getNickname(), - 'phone' => $user->getPhone(), - 'id_number' => $user->getIdNumber() - ]; - $privateKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PRIVATE_KEY']); - $jwt = JWT::encode($payload, $privateKey, 'RS256'); - - return new JsonContent([ - 'id_token' => $jwt - ]); - } - - public function getJwtPublicKey(): IContent - { - $publicKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PUBLIC_KEY']); - return new JsonContent(['pubkey' => $publicKey]); - } -} diff --git a/src/PersistentData/Model/OAuthToken.php b/src/PersistentData/Model/OAuthToken.php index 2cf5456..e82242c 100644 --- a/src/PersistentData/Model/OAuthToken.php +++ b/src/PersistentData/Model/OAuthToken.php @@ -7,10 +7,14 @@ class OAuthToken extends Model { protected static string $table = 'oauth_tokens'; - protected static array $fields = ['nonce', 'user_id', 'code', 'created', 'expires']; + protected static array $fields = ['scope', 'nonce', 'user_id', 'code', 'access_token', 'created', 'expires']; protected static array $relations = ['user' => User::class]; + private static array $possibleScopeValues = ['openid', 'email', 'profile']; + + private array $scope = []; + private string $nonce = ''; private ?User $user = null; @@ -19,10 +23,22 @@ class OAuthToken extends Model private string $code = ''; + private string $accessToken = ''; + private DateTime $created; private DateTime $expires; + public function setScopeArray(array $scope): void + { + $this->scope = array_intersect($scope, self::$possibleScopeValues); + } + + public function setScope(string $scope): void + { + $this->setScopeArray(explode(' ', $scope)); + } + public function setNonce(string $nonce): void { $this->nonce = $nonce; @@ -43,6 +59,11 @@ class OAuthToken extends Model $this->code = $code; } + public function setAccessToken(string $accessToken): void + { + $this->accessToken = $accessToken; + } + public function setCreatedDate(DateTime $created): void { $this->created = $created; @@ -63,6 +84,16 @@ class OAuthToken extends Model $this->expires = new DateTime($expires); } + public function getScope(): string + { + return implode(' ', $this->scope); + } + + public function getScopeArray(): array + { + return $this->scope; + } + public function getNonce(): string { return $this->nonce; @@ -83,6 +114,11 @@ class OAuthToken extends Model return $this->code; } + public function getAccessToken(): string + { + return $this->accessToken; + } + public function getCreatedDate(): DateTime { return $this->created; diff --git a/src/Repository/OAuthTokenRepository.php b/src/Repository/OAuthTokenRepository.php index 6898a3a..492959e 100644 --- a/src/Repository/OAuthTokenRepository.php +++ b/src/Repository/OAuthTokenRepository.php @@ -28,6 +28,14 @@ class OAuthTokenRepository return $this->pdm->selectFromDb($select, OAuthToken::class); } + public function getByAccessToken(string $accessToken): ?OAuthToken + { + $select = new Select(\Container::$dbConnection); + $select->where('access_token', '=', $accessToken); + + return $this->pdm->selectFromDb($select, OAuthToken::class); + } + public function getAllExpired(): Generator { $select = new Select(\Container::$dbConnection); diff --git a/web.php b/web.php index 45b9028..aaa2601 100644 --- a/web.php +++ b/web.php @@ -21,10 +21,11 @@ Container::$routeCollection->group('login', function (SokoWeb\Routing\RouteColle $routeCollection->get('login-google-action', 'google/code', [RVR\Controller\LoginController::class, 'loginWithGoogle']); }); Container::$routeCollection->group('oauth', function (SokoWeb\Routing\RouteCollection $routeCollection) { - $routeCollection->get('oauth-start', 'start', [RVR\Controller\OAuthLoginController::class, 'startOauth']); - $routeCollection->get('oauth-finish', 'finish', [RVR\Controller\OAuthLoginController::class, 'finishOauth']); - $routeCollection->post('oauth-token', 'token', [RVR\Controller\OAuthLoginController::class, 'getToken']); - $routeCollection->get('oauth-jwtPublicKey', 'jwtPublicKey', [RVR\Controller\OAuthLoginController::class, 'getJwtPublicKey']); + $routeCollection->get('oauth-auth', 'auth', [RVR\Controller\OAuthAuthController::class, 'auth']); + $routeCollection->post('oauth-token', 'token', [RVR\Controller\OAuthController::class, 'getToken']); + $routeCollection->get('oauth-userinfo', 'userinfo', [RVR\Controller\OAuthController::class, 'getUserInfo']); + $routeCollection->get('oauth-config', '.well-known/openid-configuration', [RVR\Controller\OAuthController::class, 'getConfig']); + $routeCollection->get('oauth-certs', 'certs', [RVR\Controller\OAuthController::class, 'getCerts']); }); Container::$routeCollection->group('password', function (SokoWeb\Routing\RouteCollection $routeCollection) { $routeCollection->get('password-requestReset', 'requestReset', [RVR\Controller\LoginController::class, 'getRequestPasswordResetForm']);