Compare commits

...
Author SHA1 Message Date
bence 845f1fe262 Merge pull request 'RVRNEXT-2 disable anti csrf check in case of oauth token' (!8) from feature/RVRNEXT-2-disable-anti-csrf-check-for-oauth-token into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #8
2023-04-08 20:03:20 +02:00
bence bc9f1a1d1f RVRNEXT-2 disable anti csrf check in case of oauth token
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-08 20:02:02 +02:00
bence 84df948012 Merge pull request 'feature/RVRNEXT-2-fixes' (!7) from feature/RVRNEXT-2-fixes into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #7
2023-04-08 19:38:16 +02:00
bence 1200489186 RVRNEXT-2 token endpoint should be POST
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-08 19:37:05 +02:00
bence 74d0b24f5f RVRNEXT-2 make endpoint names simpler 2023-04-08 19:37:02 +02:00
bence cbe6d79ab5 Merge pull request 'feature/RVRNEXT-2-implement-login-to-old-rvr' (!6) from feature/RVRNEXT-2-implement-login-to-old-rvr into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #6
2023-04-08 19:22:09 +02:00
bence 72618c6c66 RVRNEXT-2 ignore *.pem files
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-08 19:20:47 +02:00
bence b6018a0715 RVRNEXT-2 add new environment variables for jwt rsa keys 2023-04-08 19:20:47 +02:00
bence 35b7db81b2 RVRNEXT-2 add controller and view for oauth 2023-04-08 19:20:47 +02:00
bence 89c7d3b0ea RVRNEXT-2 add database accessors for oauth tokens 2023-04-08 19:08:15 +02:00
bence 13b62c8c02 RVRNEXT-2 add new db table for oauth tokens 2023-04-08 19:07:42 +02:00
bence 364d55a4b2 RVRNEXT-2 fix redirect after login 2023-04-08 19:07:18 +02:00
bence e4dc8ace04 RVRNEXT-2 add new endpoints for oauth 2023-04-08 19:06:45 +02:00
bence af8ecc748f RVRNEXT-2 add firebase/php-jwt 2023-04-08 19:06:14 +02:00
bence 6d11be728e Merge pull request 'login-fixes' (!5) from login-fixes into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #5
2023-04-08 10:50:33 +02:00
bence 453940a5ef unify redirect after login logic
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-08 10:45:56 +02:00
bence d45b790122 show correct error message if google login fails 2023-04-08 03:34:07 +02:00
16 changed files with 410 additions and 38 deletions

No files matched your search

+2
View File
@@ -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
+1
View File
@@ -2,3 +2,4 @@
installed
vendor
node_modules
*.pem
+2 -1
View File
@@ -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",
Generated
+64 -1
View File
@@ -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",
@@ -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;
+2 -2
View File
@@ -29,13 +29,13 @@ if ($match !== null) {
}
if (!$authorized) {
Container::$request->session()->set('redirect_after_login', $url);
Container::$request->session()->set('redirect_after_login', '/' . $url);
$response = new Redirect(Container::$routeCollection->getRoute('login')->generateLink(), IRedirect::TEMPORARY);
header('Location: ' . $response->getUrl(), true, $response->getHttpCode());
return;
}
if ($method === 'post' && Container::$request->post('anti_csrf_token') !== Container::$request->session()->get('anti_csrf_token')) {
if ($method === 'post' && !in_array($url, $antiCsrfTokenExceptions) && Container::$request->post('anti_csrf_token') !== Container::$request->session()->get('anti_csrf_token')) {
$content = new SokoWeb\Response\JsonContent(['error' => 'no_valid_anti_csrf_token']);
header('Content-Type: text/html; charset=UTF-8', true, 403);
$content->render();
+35 -24
View File
@@ -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');
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,12 +99,13 @@ class LoginController
$user = $this->userRepository->getById($resetter->getUserId());
return new HtmlContent('login/reset_password', ['success' => true, 'token' => $token, 'email' => $user->getEmail()]);
return new HtmlContent('login/reset_password', ['success' => true, 'token' => $token, 'email' => $user->getEmail(), 'redirectUrl' => $this->redirectUrl]);
}
public function login(): IContent
{
if ($this->request->user() !== null) {
$this->deleteRedirectUrl();
return new JsonContent(['success' => true]);
}
@@ -112,23 +121,21 @@ class LoginController
$this->request->setUser($user);
$this->deleteRedirectUrl();
return new JsonContent(['success' => true]);
}
public function loginWithGoogle()
{
$redirectUrl = $this->request->session()->get('redirect_after_login');
if ($redirectUrl === null) {
$redirectUrl = \Container::$routeCollection->getRoute('index')->generateLink();
}
$defaultError = 'Authentication with Google failed. Please <a href="/login/google" title="Login with Google">try again</a>!';
if ($this->request->user() !== null) {
$this->request->session()->delete('redirect_after_login');
return new Redirect($redirectUrl, IRedirect::TEMPORARY);
$this->deleteRedirectUrl();
return new Redirect($this->redirectUrl, IRedirect::TEMPORARY);
}
if ($this->request->query('state') !== $this->request->session()->get('oauth_state')) {
return new HtmlContent('login/google_login');
return new HtmlContent('login/google_login_error', ['error' => $defaultError]);
}
$oAuth = new GoogleOAuth(new Request());
@@ -138,33 +145,29 @@ class LoginController
);
if (!isset($tokenData['id_token'])) {
return new HtmlContent('login/google_login');
return new HtmlContent('login/google_login_error', ['error' => $defaultError]);
}
$jwtParser = new JwtParser($tokenData['id_token']);
$idToken = $jwtParser->getPayload();
if ($idToken['nonce'] !== $this->request->session()->get('oauth_nonce')) {
return new HtmlContent('login/google_login');
return new HtmlContent('login/google_login_error', ['error' => $defaultError]);
}
if (!$idToken['email_verified']) {
return new HtmlContent('login/google_login');
return new HtmlContent('login/google_login_error', ['error' => $defaultError]);
}
$user = $this->userRepository->getByGoogleSub($idToken['sub']);
if ($user === null) {
return new JsonContent([
'error' => [
'errorText' => 'No user found for this Google account.'
]
]);
return new HtmlContent('login/google_login_error', ['error' => 'No user found for this Google account.']);
}
$this->request->setUser($user);
$this->request->session()->delete('redirect_after_login');
return new Redirect($redirectUrl, IRedirect::TEMPORARY);
$this->deleteRedirectUrl();
return new Redirect($this->redirectUrl, IRedirect::TEMPORARY);
}
public function logout(): IRedirect
@@ -177,9 +180,10 @@ class LoginController
public function requestPasswordReset(): IContent
{
if ($this->request->user() !== null) {
$this->deleteRedirectUrl();
return new JsonContent([
'redirect' => [
'target' => '/' . \Container::$routeCollection->getRoute('home')->generateLink()
'target' => $this->redirectUrl
]
]);
}
@@ -240,9 +244,10 @@ class LoginController
public function resetPassword(): IContent
{
if ($this->request->user() !== null) {
$this->deleteRedirectUrl();
return new JsonContent([
'redirect' => [
'target' => '/' . \Container::$routeCollection->getRoute('home')->generateLink()
'target' => $this->redirectUrl
]
]);
}
@@ -283,6 +288,7 @@ class LoginController
$this->request->setUser($user);
$this->deleteRedirectUrl();
return new JsonContent(['success' => true]);
}
@@ -299,4 +305,9 @@ class LoginController
]);
$mail->send();
}
private function deleteRedirectUrl(): void
{
$this->request->session()->delete('redirect_after_login');
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php namespace RVR\Controller;
use DateTime;
use Firebase\JWT\JWT;
use RVR\PersistentData\Model\OAuthToken;
use RVR\Repository\OAuthTokenRepository;
use RVR\Repository\UserRepository;
use RVR\PersistentData\Model\User;
use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\Response\Redirect;
use SokoWeb\PersistentData\PersistentDataManager;
use SokoWeb\Response\HtmlContent;
use SokoWeb\Response\JsonContent;
class OAuthLoginController
{
private IRequest $request;
private PersistentDataManager $pdm;
public function __construct(IRequest $request)
{
$this->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->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()
];
$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]);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php namespace RVR\PersistentData\Model;
use DateTime;
use SokoWeb\PersistentData\Model\Model;
class OAuthToken extends Model
{
protected static string $table = 'oauth_tokens';
protected static array $fields = ['nonce', 'user_id', 'code', 'created', 'expires'];
protected static array $relations = ['user' => 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');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php namespace RVR\Repository;
use DateTime;
use Generator;
use SokoWeb\Database\Query\Select;
use RVR\PersistentData\Model\OAuthToken;
use SokoWeb\PersistentData\PersistentDataManager;
class OAuthTokenRepository
{
private PersistentDataManager $pdm;
public function __construct()
{
$this->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);
}
}
-8
View File
@@ -1,8 +0,0 @@
@extends(templates/layout_normal)
@section(main)
<h2>Login up with Google</h2>
<div class="box">
<p class="error justify">Authentication with Google failed. Please <a href="/login/google" title="Login with Google">try again</a>!</p>
</div>
@endsection
+8
View File
@@ -0,0 +1,8 @@
@extends(templates/layout_normal)
@section(main)
<h2>Login up with Google</h2>
<div class="box">
<p class="error justify"><?= $error ?></p>
</div>
@endsection
+1 -1
View File
@@ -3,7 +3,7 @@
@section(main)
<h2>Login</h2>
<div class="box">
<form id="loginForm" action="/login" method="post" data-redirect-on-success="/">
<form id="loginForm" action="/login" method="post" data-redirect-on-success="<?= $redirectUrl ?>">
<input type="email" class="text big fullWidth" name="email" placeholder="Email address" required autofocus>
<input type="password" class="text big fullWidth marginTop" name="password" placeholder="Password" required minlength="6">
<p id="loginFormError" class="formError justify marginTop"></p>
+1 -1
View File
@@ -4,7 +4,7 @@
<h2>Reset password</h2>
<div class="box">
<?php if ($success) : ?>
<form id="resetPasswordForm" action="/password/reset/<?= $token ?>" method="post" data-redirect-on-success="/">
<form id="resetPasswordForm" action="/password/reset/<?= $token ?>" method="post" data-redirect-on-success="<?= $redirectUrl ?>">
<input type="email" class="text big fullWidth" name="email" placeholder="Email address" value="<?= $email ?>" disabled>
<input type="password" class="text big fullWidth marginTop" name="password" placeholder="Password" required minlength="6" autofocus>
<input type="password" class="text big fullWidth marginTop" name="password_confirm" placeholder="Password confirmation" required minlength="6">
+8
View File
@@ -0,0 +1,8 @@
@extends(templates/layout_normal)
@section(main)
<h2>OAuth error</h2>
<div class="box">
<p class="error justify"><?= $error ?></p>
</div>
@endsection
+9
View File
@@ -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->post('oauth-token', 'token', [RVR\Controller\OAuthLoginController::class, 'getToken']);
$routeCollection->get('oauth-jwtPublicKey', 'jwtPublicKey', [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']);
@@ -72,3 +78,6 @@ Container::$request = new SokoWeb\Request\Request(
if (!Container::$request->session()->has('anti_csrf_token')) {
Container::$request->session()->set('anti_csrf_token', bin2hex(random_bytes(16)));
}
//TODO: make a nicer logic
$antiCsrfTokenExceptions = ['oauth/token'];