From af8ecc748f2e55711afc0cc5ad2ab6523847e628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 8 Apr 2023 19:06:14 +0200 Subject: [PATCH 1/8] RVRNEXT-2 add firebase/php-jwt --- composer.json | 3 ++- composer.lock | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 379c569..f10c652 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ } ], "require": { - "esoko/soko-web": "0.1" + "esoko/soko-web": "0.1", + "firebase/php-jwt": "^6.4" }, "require-dev": { "phpunit/phpunit": "^9.6", diff --git a/composer.lock b/composer.lock index 3a34ebb..e2f002b 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": "3479948070678fa4e980114fbe8b71b5", + "content-hash": "f2dcf297a4a619bc5edfe7b4fac0836e", "packages": [ { "name": "esoko/soko-web", @@ -35,6 +35,69 @@ "description": "Lightweight web framework", "time": "2023-04-07T17:32:15+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.4.0", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "4dd1e007f22a927ac77da5a3fbb067b42d3bc224" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/4dd1e007f22a927ac77da5a3fbb067b42d3bc224", + "reference": "4dd1e007f22a927ac77da5a3fbb067b42d3bc224", + "shasum": "" + }, + "require": { + "php": "^7.1||^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.5||^7.4", + "phpspec/prophecy-phpunit": "^1.1", + "phpunit/phpunit": "^7.5||^9.5", + "psr/cache": "^1.0||^2.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.4.0" + }, + "time": "2023-02-09T21:01:23+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.1", From e4dc8ace0479e141578d7f9bb4735a0d8b3f1359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 8 Apr 2023 19:06:45 +0200 Subject: [PATCH 2/8] RVRNEXT-2 add new endpoints for oauth --- web.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web.php b/web.php index 47c1db1..ac60ae3 100644 --- a/web.php +++ b/web.php @@ -20,6 +20,12 @@ Container::$routeCollection->group('login', function (SokoWeb\Routing\RouteColle $routeCollection->get('login-google', 'google', [RVR\Controller\LoginController::class, 'getGoogleLoginRedirect']); $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->get('oauth-getToken', 'getToken', [RVR\Controller\OAuthLoginController::class, 'getToken']); + $routeCollection->get('oauth-getJwtPublicKey', 'getJwtPublicKey', [RVR\Controller\OAuthLoginController::class, 'getJwtPublicKey']); +}); Container::$routeCollection->group('password', function (SokoWeb\Routing\RouteCollection $routeCollection) { $routeCollection->get('password-requestReset', 'requestReset', [RVR\Controller\LoginController::class, 'getRequestPasswordResetForm']); $routeCollection->post('password-requestReset-action', 'requestReset', [RVR\Controller\LoginController::class, 'requestPasswordReset']); From 364d55a4b27b05d4d29a9ff9e7b22479f2341384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 8 Apr 2023 19:07:18 +0200 Subject: [PATCH 3/8] RVRNEXT-2 fix redirect after login --- src/Controller/LoginController.php | 35 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 8588ebf..9c2445b 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -27,21 +27,27 @@ class LoginController private UserPasswordResetterRepository $userPasswordResetterRepository; + private string $redirectUrl; + public function __construct(IRequest $request) { $this->request = $request; $this->pdm = new PersistentDataManager(); $this->userRepository = new UserRepository(); $this->userPasswordResetterRepository = new UserPasswordResetterRepository(); + $this->redirectUrl = $this->request->session()->has('redirect_after_login') ? + $this->request->session()->get('redirect_after_login') : + \Container::$routeCollection->getRoute('index')->generateLink(); } public function getLoginForm() { if ($this->request->user() !== null) { - return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY); + $this->deleteRedirectUrl(); + return new Redirect($this->redirectUrl, IRedirect::TEMPORARY); } - return new HtmlContent('login/login', ['redirectUrl' => $this->getRedirectUrl()]); + return new HtmlContent('login/login', ['redirectUrl' => $this->redirectUrl]); } public function getGoogleLoginRedirect(): IRedirect @@ -65,7 +71,8 @@ class LoginController public function getRequestPasswordResetForm() { if ($this->request->user() !== null) { - return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY); + $this->deleteRedirectUrl(); + return new Redirect($this->redirectUrl, IRedirect::TEMPORARY); } return new HtmlContent('login/password_reset_request', ['email' => $this->request->query('email')]); @@ -79,7 +86,8 @@ class LoginController public function getResetPasswordForm() { if ($this->request->user() !== null) { - return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY); + $this->deleteRedirectUrl(); + return new Redirect($this->redirectUrl, IRedirect::TEMPORARY); } $token = $this->request->query('token'); @@ -91,7 +99,7 @@ class LoginController $user = $this->userRepository->getById($resetter->getUserId()); - return new HtmlContent('login/reset_password', ['success' => true, 'token' => $token, 'email' => $user->getEmail(), 'redirectUrl' => $this->getRedirectUrl()]); + return new HtmlContent('login/reset_password', ['success' => true, 'token' => $token, 'email' => $user->getEmail(), 'redirectUrl' => $this->redirectUrl]); } public function login(): IContent @@ -123,7 +131,7 @@ class LoginController if ($this->request->user() !== null) { $this->deleteRedirectUrl(); - return new Redirect($this->getRedirectUrl(), IRedirect::TEMPORARY); + return new Redirect($this->redirectUrl, IRedirect::TEMPORARY); } if ($this->request->query('state') !== $this->request->session()->get('oauth_state')) { @@ -159,7 +167,7 @@ class LoginController $this->request->setUser($user); $this->deleteRedirectUrl(); - return new Redirect($this->getRedirectUrl(), IRedirect::TEMPORARY); + return new Redirect($this->redirectUrl, IRedirect::TEMPORARY); } public function logout(): IRedirect @@ -175,7 +183,7 @@ class LoginController $this->deleteRedirectUrl(); return new JsonContent([ 'redirect' => [ - 'target' => $this->getRedirectUrl() + 'target' => $this->redirectUrl ] ]); } @@ -239,7 +247,7 @@ class LoginController $this->deleteRedirectUrl(); return new JsonContent([ 'redirect' => [ - 'target' => $this->getRedirectUrl() + 'target' => $this->redirectUrl ] ]); } @@ -298,15 +306,6 @@ class LoginController $mail->send(); } - private function getRedirectUrl(): string - { - $redirectUrl = $this->request->session()->get('redirect_after_login'); - if ($redirectUrl === null) { - return \Container::$routeCollection->getRoute('index')->generateLink(); - } - return $redirectUrl; - } - private function deleteRedirectUrl(): void { $this->request->session()->delete('redirect_after_login'); From 13b62c8c022cba67fab61a4f8e4884f2d71eb46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 8 Apr 2023 19:07:42 +0200 Subject: [PATCH 4/8] RVRNEXT-2 add new db table for oauth tokens --- database/migrations/structure/20230408_1129_oauth.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 database/migrations/structure/20230408_1129_oauth.sql diff --git a/database/migrations/structure/20230408_1129_oauth.sql b/database/migrations/structure/20230408_1129_oauth.sql new file mode 100644 index 0000000..bf66496 --- /dev/null +++ b/database/migrations/structure/20230408_1129_oauth.sql @@ -0,0 +1,10 @@ +CREATE TABLE `oauth_tokens` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `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, + `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; From 89c7d3b0eacd7e853918c2d11794e7f63a4cfef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 8 Apr 2023 19:08:15 +0200 Subject: [PATCH 5/8] RVRNEXT-2 add database accessors for oauth tokens --- src/PersistentData/Model/OAuthToken.php | 105 ++++++++++++++++++++++++ src/Repository/OAuthTokenRepository.php | 38 +++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/PersistentData/Model/OAuthToken.php create mode 100644 src/Repository/OAuthTokenRepository.php diff --git a/src/PersistentData/Model/OAuthToken.php b/src/PersistentData/Model/OAuthToken.php new file mode 100644 index 0000000..2cf5456 --- /dev/null +++ b/src/PersistentData/Model/OAuthToken.php @@ -0,0 +1,105 @@ + User::class]; + + private string $nonce = ''; + + private ?User $user = null; + + private ?int $userId = null; + + private string $code = ''; + + private DateTime $created; + + private DateTime $expires; + + 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 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 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 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'); + } +} diff --git a/src/Repository/OAuthTokenRepository.php b/src/Repository/OAuthTokenRepository.php new file mode 100644 index 0000000..6898a3a --- /dev/null +++ b/src/Repository/OAuthTokenRepository.php @@ -0,0 +1,38 @@ +pdm = new PersistentDataManager(); + } + + public function getById(int $id): ?OAuthToken + { + return $this->pdm->selectFromDbById($id, OAuthToken::class); + } + + public function getByCode(string $code): ?OAuthToken + { + $select = new Select(\Container::$dbConnection); + $select->where('code', '=', $code); + + return $this->pdm->selectFromDb($select, OAuthToken::class); + } + + public function getAllExpired(): Generator + { + $select = new Select(\Container::$dbConnection); + $select->where('expires', '<', (new DateTime())->format('Y-m-d H:i:s')); + + yield from $this->pdm->selectMultipleFromDb($select, OAuthToken::class); + } +} From 35b7db81b24ca604ad498baeadcd780084c90851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 8 Apr 2023 19:08:56 +0200 Subject: [PATCH 6/8] RVRNEXT-2 add controller and view for oauth --- src/Controller/OAuthLoginController.php | 124 ++++++++++++++++++++++++ views/oauth/oauth_error.php | 8 ++ 2 files changed, 132 insertions(+) create mode 100644 src/Controller/OAuthLoginController.php create mode 100644 views/oauth/oauth_error.php diff --git a/src/Controller/OAuthLoginController.php b/src/Controller/OAuthLoginController.php new file mode 100644 index 0000000..0e3fec5 --- /dev/null +++ b/src/Controller/OAuthLoginController.php @@ -0,0 +1,124 @@ +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_state', [ + '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() + { + $oauthState = $this->request->session()->get('oauth_state'); + if ($oauthState === null) { + return new HtmlContent('oauth/oauth_error', ['error' => 'An invalid request was made. Please start authentication again.']); + } + + $this->request->session()->delete('oauth_state'); + + /** + * @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($oauthState['nonce']); + $token->setUser($user); + $token->setCode($code); + $token->setCreatedDate(new DateTime()); + $token->setExpiresDate(new DateTime('+5 minutes')); + $this->pdm->saveToDb($token); + + $redirectUri = $oauthState['redirect_uri']; + $additionalUriParams = [ + 'state' => $oauthState['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->query('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() + ]; + $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/views/oauth/oauth_error.php b/views/oauth/oauth_error.php new file mode 100644 index 0000000..971bdfb --- /dev/null +++ b/views/oauth/oauth_error.php @@ -0,0 +1,8 @@ +@extends(templates/layout_normal) + +@section(main) +

OAuth error

+
+

+
+@endsection From b6018a0715e8cbec4ca2d9480e2dda4c364a4907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 8 Apr 2023 19:09:25 +0200 Subject: [PATCH 7/8] RVRNEXT-2 add new environment variables for jwt rsa keys --- .env.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.env.example b/.env.example index 35ed44c..7eebf5d 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,5 @@ GOOGLE_OAUTH_CLIENT_SECRET=your_google_oauth_client_secret GOOGLE_ANALITICS_ID=your_google_analytics_id 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 From 72618c6c66b3d1b0daf54be9a363de9cb33a193d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 8 Apr 2023 19:10:07 +0200 Subject: [PATCH 8/8] RVRNEXT-2 ignore *.pem files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2cdd4fd..eed5388 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ installed vendor node_modules +*.pem