Compare commits

..
Author SHA1 Message Date
bence c9a06e5ada Merge pull request 'add aud to jwt' (!15) from feature/add-oauth-audience into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #15
2023-04-12 00:44:52 +02:00
bence 4f007765f4 Merge pull request 'omit whitespace in main layout' (!14) from bugfix/layout-fix into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #14
2023-04-12 00:42:40 +02:00
bence 97780eb079 add aud to jwt
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-12 00:41:20 +02:00
bence e487a59816 Merge pull request 'restrict oauth access' (!13) from feature/oauth-restrictions into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #13
2023-04-12 00:15:08 +02:00
bence a7790319eb restrict oauth access
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-12 00:10:14 +02:00
bence e143d05801 Merge pull request 'make oauth endpoints openid compliant' (!12) from oauth-improvements into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #12
2023-04-11 19:47:54 +02:00
bence 7c0ebe6668 fix url passed to 'redirect_after_login'
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-11 19:06:22 +02:00
bence 6eb54a6f11 make oauth endpoints openid compliant 2023-04-11 19:06:22 +02:00
bence ed137b38de omit whitespace in main layout
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-09 03:16:45 +02:00
bence db757de71c Merge pull request 'feature/user-data' (!11) from feature/user-data into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #11
2023-04-09 02:59:35 +02:00
bence 71aed9dcec send all user data with oauth
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-09 02:55:42 +02:00
bence b809542083 reload account page after save 2023-04-09 02:55:40 +02:00
bence 749b93e3af make it possible to modify personal user data fields 2023-04-09 02:55:10 +02:00
bence df3bf89079 add personal user data fields 2023-04-09 02:54:18 +02:00
bence de346c0c6e fix HTML syntax error in account.php 2023-04-09 02:29:53 +02:00
bence 6df63373ab fix observeInput logic 2023-04-09 02:29:53 +02:00
bence a0fe77fe66 make it possible to modify email and username 2023-04-09 02:29:53 +02:00
bence 151112bd2a make it possible to have username 2023-04-09 02:29:46 +02:00
bence 366abf61b3 Merge pull request 'use name 'oauth_payload' for data received from oauth authentication request' (!10) from bugfix/fix-conflicting-session-keys into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #10
2023-04-08 21:25:57 +02:00
bence 367e78cbf8 use name 'oauth_payload' for data received from oauth authentication request
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-08 21:21:30 +02:00
27 changed files with 817 additions and 154 deletions

No files matched your search

@@ -0,0 +1,3 @@
ALTER TABLE `users`
ADD `username` varchar(100) DEFAULT NULL,
ADD UNIQUE `username` (`username`);
@@ -0,0 +1,5 @@
ALTER TABLE `users`
ADD `full_name` varchar(255) NOT NULL DEFAULT '',
ADD `nickname` varchar(255) NOT NULL DEFAULT '',
ADD `phone` varchar(255) NOT NULL DEFAULT '',
ADD `id_number` varchar(255) NOT NULL DEFAULT '';
@@ -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`);
@@ -0,0 +1,10 @@
CREATE TABLE `oauth_clients` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`client_id` varchar(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`client_secret` varchar(40) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`redirect_uris` text NOT NULL,
`preapproved` tinyint(1) NOT NULL DEFAULT 0,
`created` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `client_id` (`client_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
@@ -0,0 +1,2 @@
ALTER TABLE `oauth_tokens`
ADD `audience` varchar(255) NOT NULL DEFAULT '';
+1 -1
View File
@@ -29,7 +29,7 @@ if ($match !== null) {
}
if (!$authorized) {
Container::$request->session()->set('redirect_after_login', $url);
Container::$request->session()->set('redirect_after_login', substr($_SERVER['REQUEST_URI'], strlen('/')));
$response = new Redirect(Container::$routeCollection->getRoute('login')->generateLink(), IRedirect::TEMPORARY);
header('Location: ' . $response->getUrl(), true, $response->getHttpCode());
return;
+12 -7
View File
@@ -158,12 +158,17 @@ var RVR = {
document.getElementById('cover').style.visibility = 'hidden';
},
observeInput: function (input, buttonToToggle) {
if (input.defaultValue !== input.value) {
buttonToToggle.disabled = false;
} else {
buttonToToggle.disabled = true;
observeInput: function (form, observedInputs) {
var anyChanged = false;
for (var i = 0; i < observedInputs.length; i++) {
var input = form.elements[observedInputs[i]];
if (input.defaultValue !== input.value) {
anyChanged = true;
}
}
form.elements.submit.disabled = !anyChanged;
},
observeInputsInForm: function (form, observedInputs) {
@@ -174,12 +179,12 @@ var RVR = {
case 'INPUT':
case 'TEXTAREA':
input.oninput = function () {
RVR.observeInput(this, form.elements.submit);
RVR.observeInput(form, observedInputs);
};
break;
case 'SELECT':
input.onchange = function () {
RVR.observeInput(this, form.elements.submit);
RVR.observeInput(form, observedInputs);
};
break;
}
+3
View File
@@ -9,5 +9,8 @@ $app->add(new RVR\Cli\MigrateDatabaseCommand());
$app->add(new RVR\Cli\AddUserCommand());
$app->add(new RVR\Cli\LinkViewCommand());
$app->add(new RVR\Cli\MaintainDatabaseCommand());
$app->add(new RVR\Cli\AddOAuthClientCommand());
$app->add(new RVR\Cli\AddOAuthRedirectUriCommand());
$app->add(new RVR\Cli\RemoveOAuthRedirectUriCommand());
$app->run();
+53
View File
@@ -0,0 +1,53 @@
<?php namespace RVR\Cli;
use DateTime;
use SokoWeb\PersistentData\PersistentDataManager;
use RVR\PersistentData\Model\OAuthClient;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class AddOAuthClientCommand extends Command
{
public function configure(): void
{
$this->setName('oauth:add-client')
->setDescription('Adding of OAuth client.')
->addArgument('preapproved', InputArgument::OPTIONAL, 'Preapproved');
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$clientId = bin2hex(random_bytes(8));
$clientSecret = bin2hex(random_bytes(20));
$oAuthClient = new OAuthClient();
$oAuthClient->setClientId($clientId);
$oAuthClient->setClientSecret($clientSecret);
$oAuthClient->setCreatedDate(new DateTime());
if ($input->hasArgument('preapproved') && $input->getArgument('preapproved')) {
$oAuthClient->setPreapproved($input->getArgument('preapproved'));
}
try {
$pdm = new PersistentDataManager();
$pdm->saveToDb($oAuthClient);
} catch (\Exception $e) {
$output->writeln('<error>Adding OAuth client failed!</error>');
$output->writeln('');
$output->writeln((string) $e);
$output->writeln('');
return 1;
}
$output->writeln('<info>OAuth client was successfully added!</info>');
$output->writeln('<info>Client ID: ' . $clientId . '</info>');
$output->writeln('<info>Client secret: ' . $clientSecret . '</info>');
return 0;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php namespace RVR\Cli;
use SokoWeb\PersistentData\PersistentDataManager;
use RVR\Repository\OAuthClientRepository;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class AddOAuthRedirectUriCommand extends Command
{
public function configure(): void
{
$this->setName('oauth:add-redirect-uri')
->setDescription('Adding of redirect URI for OAuth client.')
->addArgument('client_id', InputArgument::REQUIRED, 'The OAuth client ID')
->addArgument('redirect_uris', InputArgument::IS_ARRAY, 'Redirect URIs to add');
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$oAuthClientRepository = new OAuthClientRepository();
$oAuthClient = $oAuthClientRepository->getByClientId($input->getArgument('client_id'));
if ($oAuthClient === null) {
$output->writeln('<error>OAuth client does not exist!</error>');
return 1;
}
$redirectUris = array_unique(array_merge($oAuthClient->getRedirectUrisArray(), $input->getArgument('redirect_uris')));
$oAuthClient->setRedirectUrisArray($redirectUris);
try {
$pdm = new PersistentDataManager();
$pdm->saveToDb($oAuthClient);
} catch (\Exception $e) {
$output->writeln('<error>Adding redirect URI failed!</error>');
$output->writeln('');
$output->writeln((string) $e);
$output->writeln('');
return 1;
}
$redirectUrisToPrint = [];
foreach ($redirectUris as $redirectUri) $redirectUrisToPrint[] = '* ' . $redirectUri;
$output->writeln('<info>Redirect URIS were successfully added! Current URIs:' . "\n" . implode("\n", $redirectUrisToPrint) . '</info>');
return 0;
}
}
+5
View File
@@ -21,6 +21,11 @@ class AddUserCommand extends Command
public function execute(InputInterface $input, OutputInterface $output): int
{
if (!filter_var($input->getArgument('email'), FILTER_VALIDATE_EMAIL)) {
$output->writeln('<error>Please provide a valid email address.</error>');
return 1;
}
$user = new User();
$user->setEmail($input->getArgument('email'));
$user->setPlainPassword($input->getArgument('password'));
+54
View File
@@ -0,0 +1,54 @@
<?php namespace RVR\Cli;
use SokoWeb\PersistentData\PersistentDataManager;
use RVR\Repository\OAuthClientRepository;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RemoveOAuthRedirectUriCommand extends Command
{
public function configure(): void
{
$this->setName('oauth:remove-redirect-uri')
->setDescription('Removing of redirect URI for OAuth client.')
->addArgument('client_id', InputArgument::REQUIRED, 'The OAuth client ID')
->addArgument('redirect_uris', InputArgument::IS_ARRAY, 'Redirect URIs to remove');
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$oAuthClientRepository = new OAuthClientRepository();
$oAuthClient = $oAuthClientRepository->getByClientId($input->getArgument('client_id'));
if ($oAuthClient === null) {
$output->writeln('<error>OAuth client does not exist!</error>');
return 1;
}
$redirectUris = array_diff($oAuthClient->getRedirectUrisArray(), $input->getArgument('redirect_uris'));
$oAuthClient->setRedirectUrisArray($redirectUris);
try {
$pdm = new PersistentDataManager();
$pdm->saveToDb($oAuthClient);
} catch (\Exception $e) {
$output->writeln('<error>Removing redirect URI failed!</error>');
$output->writeln('');
$output->writeln((string) $e);
$output->writeln('');
return 1;
}
$redirectUrisToPrint = [];
foreach ($redirectUris as $redirectUri) $redirectUrisToPrint[] = '* ' . $redirectUri;
$output->writeln('<info>Redirect URIS were successfully removed! Current URIs:' . "\n" . implode("\n", $redirectUrisToPrint) . '</info>');
return 0;
}
}
+4 -4
View File
@@ -109,11 +109,11 @@ class LoginController
return new JsonContent(['success' => true]);
}
$user = $this->userRepository->getByEmail($this->request->post('email'));
$user = $this->userRepository->getByEmailOrUsername($this->request->post('email'));
if ($user === null || !$user->checkPassword($this->request->post('password'))) {
return new JsonContent([
'error' => [
'errorText' => 'No user found with the given email address or the given password is wrong. You can <a href="/password/requestReset?email=' .
'errorText' => 'No user found with the given email address / username or the given password is wrong. You can <a href="/password/requestReset?email=' .
urlencode($this->request->post('email')) . '" title="Request password reset">request password reset</a>!'
]
]);
@@ -200,11 +200,11 @@ class LoginController
}
}
$user = $this->userRepository->getByEmail($this->request->post('email'));
$user = $this->userRepository->getByEmailOrUsername($this->request->post('email'));
if ($user === null) {
return new JsonContent([
'error' => [
'errorText' => 'No user found with the given email address.'
'errorText' => 'No user found with the given email address / username.'
]
]);
}
+89
View File
@@ -0,0 +1,89 @@
<?php namespace RVR\Controller;
use DateTime;
use RVR\PersistentData\Model\OAuthToken;
use RVR\PersistentData\Model\User;
use RVR\Repository\OAuthClientRepository;
use SokoWeb\Interfaces\Authorization\ISecured;
use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\Response\Redirect;
use SokoWeb\PersistentData\PersistentDataManager;
use SokoWeb\Response\HtmlContent;
class OAuthAuthController implements ISecured
{
private IRequest $request;
private PersistentDataManager $pdm;
private OAuthClientRepository $oAuthClientRepository;
public function __construct(IRequest $request)
{
$this->request = $request;
$this->pdm = new PersistentDataManager();
$this->oAuthClientRepository = new OAuthClientRepository();
}
public function authorize(): bool
{
return $this->request->user() !== null;
}
public function auth()
{
$redirectUri = $this->request->query('redirect_uri');
$clientId = $this->request->query('client_id');
$scope = $this->request->query('scope') ? $this->request->query('scope'): '';
$state = $this->request->query('state');
$nonce = $this->request->query('nonce') ? $this->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);
$redirectUriHost = $redirectUriParsed['scheme'] . '://' . $redirectUriParsed['host'];
$redirectUriBase = $redirectUriHost . $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 = $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->setAudience($redirectUriHost);
$token->setCreatedDate(new DateTime());
$token->setExpiresDate(new DateTime('+5 minutes'));
$this->pdm->saveToDb($token);
$redirectUriQuery = array_merge($redirectUriQuery, [
'state' => $state,
'code' => $code
]);
$finalRedirectUri = $redirectUriBase . '?' . http_build_query($redirectUriQuery);
return new Redirect($finalRedirectUri, IRedirect::TEMPORARY);
}
}
+203
View File
@@ -0,0 +1,203 @@
<?php namespace RVR\Controller;
use DateTime;
use Firebase\JWT\JWT;
use RVR\Repository\OAuthTokenRepository;
use RVR\Repository\UserRepository;
use RVR\PersistentData\Model\User;
use RVR\Repository\OAuthClientRepository;
use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Response\JsonContent;
class OAuthController
{
private IRequest $request;
private OAuthClientRepository $oAuthClientRepository;
private OAuthTokenRepository $oAuthTokenRepository;
private UserRepository $userRepository;
public function __construct(IRequest $request)
{
$this->request = $request;
$this->oAuthClientRepository = new OAuthClientRepository();
$this->oAuthTokenRepository = new OAuthTokenRepository();
$this->userRepository = new UserRepository();
}
public function getToken(): ?IContent
{
$clientId = $this->request->post('client_id');
$clientSecret = $this->request->post('client_secret');
$code = $this->request->post('code');
if (!$clientId || !$clientSecret || !$code) {
return new JsonContent([
'error' => 'An invalid request was made.'
]);
}
$client = $this->oAuthClientRepository->getByClientId($clientId);
if ($client === null || $client->getClientSecret() !== $clientSecret) {
return new JsonContent([
'error' => 'Client is not authorized.'
]);
}
$token = $this->oAuthTokenRepository->getByCode($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(),
'aud' => $token->getAudience(),
'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'] = (string)$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;
}
}
-124
View File
@@ -1,124 +0,0 @@
<?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]);
}
}
+43 -4
View File
@@ -13,6 +13,7 @@ use SokoWeb\Response\HtmlContent;
use SokoWeb\Response\JsonContent;
use SokoWeb\Response\Redirect;
use SokoWeb\Util\JwtParser;
use RVR\Repository\UserRepository;
class UserController implements ISecured
{
@@ -20,10 +21,13 @@ class UserController implements ISecured
private PersistentDataManager $pdm;
private UserRepository $userRepository;
public function __construct(IRequest $request)
{
$this->request = $request;
$this->pdm = new PersistentDataManager();
$this->userRepository = new UserRepository();
}
public function authorize(): bool
@@ -126,8 +130,39 @@ class UserController implements ISecured
return new JsonContent(['error' => ['errorText' => $error]]);
}
if (strlen($this->request->post('password_new')) > 0) {
if (strlen($this->request->post('password_new')) < 6) {
$newEmail = $this->request->post('email');
if ($newEmail !== $user->getEmail()) {
if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) {
return new JsonContent(['error' => ['errorText' => 'Please provide a valid email address.']]);
}
if ($this->userRepository->getByEmail($newEmail) !== null) {
return new JsonContent(['error' => ['errorText' => 'The given email address belongs to another account.']]);
}
$user->setEmail($newEmail);
}
$newUsername = $this->request->post('username');
if ($newUsername !== $user->getUsername()) {
if (strlen($newUsername) > 0) {
if (filter_var($newUsername, FILTER_VALIDATE_EMAIL)) {
return new JsonContent(['error' => ['errorText' => 'Please select a username that is not a valid email address.']]);
}
if ($this->userRepository->getByUsername($newUsername) !== null) {
return new JsonContent(['error' => ['errorText' => 'The given username is already taken.']]);
}
$user->setUsername($newUsername);
} else {
$user->setUsername(null);
}
}
$newPassword = $this->request->post('password_new');
if (strlen($newPassword) > 0) {
if (strlen($newPassword) < 6) {
return new JsonContent([
'error' => [
'errorText' => 'The given new password is too short. Please choose a password that is at least 6 characters long!'
@@ -135,7 +170,7 @@ class UserController implements ISecured
]);
}
if ($this->request->post('password_new') !== $this->request->post('password_new_confirm')) {
if ($newPassword !== $this->request->post('password_new_confirm')) {
return new JsonContent([
'error' => [
'errorText' => 'The given new passwords do not match.'
@@ -143,9 +178,13 @@ class UserController implements ISecured
]);
}
$user->setPlainPassword($this->request->post('password_new'));
$user->setPlainPassword($newPassword);
}
$user->setNickname($this->request->post('nickname'));
$user->setPhone($this->request->post('phone'));
$user->setIdNumber($this->request->post('id_number'));
$this->pdm->saveToDb($user);
$this->request->session()->delete('authenticated_with_google_until');
+91
View File
@@ -0,0 +1,91 @@
<?php namespace RVR\PersistentData\Model;
use DateTime;
use SokoWeb\PersistentData\Model\Model;
class OAuthClient extends Model
{
protected static string $table = 'oauth_clients';
protected static array $fields = ['client_id', 'client_secret', 'redirect_uris', 'preapproved', 'created'];
private string $clientId = '';
private string $clientSecret = '';
private array $redirectUris = [];
private bool $preapproved = false;
private DateTime $created;
public function setClientId(string $clientId): void
{
$this->clientId = $clientId;
}
public function setClientSecret(string $clientSecret): void
{
$this->clientSecret = $clientSecret;
}
public function setRedirectUrisArray(array $redirectUris): void
{
$this->redirectUris = $redirectUris;
}
public function setRedirectUris(string $redirectUris): void
{
$this->redirectUris = json_decode($redirectUris, true);
}
public function setPreapproved(bool $preapproved): void
{
$this->preapproved = $preapproved;
}
public function setCreatedDate(DateTime $created): void
{
$this->created = $created;
}
public function setCreated(string $created): void
{
$this->created = new DateTime($created);
}
public function getClientId(): string
{
return $this->clientId;
}
public function getClientSecret(): string
{
return $this->clientSecret;
}
public function getRedirectUrisArray(): array
{
return $this->redirectUris;
}
public function getRedirectUris(): string
{
return json_encode($this->redirectUris);
}
public function getPreapproved(): bool
{
return $this->preapproved;
}
public function getCreatedDate(): DateTime
{
return $this->created;
}
public function getCreated(): string
{
return $this->created->format('Y-m-d H:i:s');
}
}
+49 -1
View File
@@ -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', 'audience', '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,24 @@ class OAuthToken extends Model
private string $code = '';
private string $accessToken = '';
private string $audience = '';
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 +61,16 @@ class OAuthToken extends Model
$this->code = $code;
}
public function setAccessToken(string $accessToken): void
{
$this->accessToken = $accessToken;
}
public function setAudience(string $audience): void
{
$this->audience = $audience;
}
public function setCreatedDate(DateTime $created): void
{
$this->created = $created;
@@ -63,6 +91,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 +121,16 @@ class OAuthToken extends Model
return $this->code;
}
public function getAccessToken(): string
{
return $this->accessToken;
}
public function getAudience(): string
{
return $this->audience;
}
public function getCreatedDate(): DateTime
{
return $this->created;
+62 -2
View File
@@ -8,12 +8,14 @@ class User extends Model implements IUser
{
protected static string $table = 'users';
protected static array $fields = ['email', 'password', 'type', 'google_sub', 'created'];
protected static array $fields = ['email', 'username', 'password', 'type', 'google_sub', 'created', 'full_name', 'nickname', 'phone', 'id_number'];
private static array $types = ['user', 'admin'];
private string $email = '';
private ?string $username = null;
private ?string $password = null;
private string $type = 'user';
@@ -22,11 +24,24 @@ class User extends Model implements IUser
private DateTime $created;
private string $fullName = '';
private string $nickname = '';
private string $phone = '';
private string $idNumber = '';
public function setEmail(string $email): void
{
$this->email = $email;
}
public function setUsername(?string $username): void
{
$this->username = $username;
}
public function setPassword(?string $hashedPassword): void
{
$this->password = $hashedPassword;
@@ -59,11 +74,36 @@ class User extends Model implements IUser
$this->created = new DateTime($created);
}
public function setFullName(string $fullName): void
{
$this->fullName = $fullName;
}
public function setNickname(string $nickname): void
{
$this->nickname = $nickname;
}
public function setPhone(string $phone): void
{
$this->phone = $phone;
}
public function setIdNumber(string $idNumber): void
{
$this->idNumber = $idNumber;
}
public function getEmail(): string
{
return $this->email;
}
public function getUsername(): ?string
{
return $this->username;
}
public function getPassword(): ?string
{
return $this->password;
@@ -89,6 +129,26 @@ class User extends Model implements IUser
return $this->created->format('Y-m-d H:i:s');
}
public function getFullName(): string
{
return $this->fullName;
}
public function getNickname(): string
{
return $this->nickname;
}
public function getPhone(): string
{
return $this->phone;
}
public function getIdNumber(): string
{
return $this->idNumber;
}
public function hasPermission(int $permission): bool
{
switch ($permission) {
@@ -108,7 +168,7 @@ class User extends Model implements IUser
public function getDisplayName(): string
{
return $this->email;
return $this->nickname ?: $this->fullName;
}
public function checkPassword(string $password): bool
+28
View File
@@ -0,0 +1,28 @@
<?php namespace RVR\Repository;
use SokoWeb\Database\Query\Select;
use RVR\PersistentData\Model\OAuthClient;
use SokoWeb\PersistentData\PersistentDataManager;
class OAuthClientRepository
{
private PersistentDataManager $pdm;
public function __construct()
{
$this->pdm = new PersistentDataManager();
}
public function getById(int $id): ?OAuthClient
{
return $this->pdm->selectFromDbById($id, OAuthClient::class);
}
public function getByClientId(string $clientId): ?OAuthClient
{
$select = new Select(\Container::$dbConnection);
$select->where('client_id', '=', $clientId);
return $this->pdm->selectFromDb($select, OAuthClient::class);
}
}
+8
View File
@@ -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);
+17
View File
@@ -27,6 +27,23 @@ class UserRepository implements IUserRepository
return $this->pdm->selectFromDb($select, User::class);
}
public function getByUsername(string $username): ?User
{
$select = new Select(\Container::$dbConnection);
$select->where('username', '=', $username);
return $this->pdm->selectFromDb($select, User::class);
}
public function getByEmailOrUsername(string $emailOrUsername): ?User
{
if (filter_var($emailOrUsername, FILTER_VALIDATE_EMAIL)) {
return $this->getByEmail($emailOrUsername);
}
return $this->getByUsername($emailOrUsername);
}
public function getByGoogleSub(string $sub): ?User
{
$select = new Select(\Container::$dbConnection);
+9 -4
View File
@@ -5,11 +5,11 @@
@section(main)
<h2>Account</h2>
<div class="box">
<form id="accountForm" action="/account" method="post" data-observe-inputs="password_new,password_new_confirm">
<form id="accountForm" action="/account" method="post" data-reload-on-success="true" data-observe-inputs="email,username,password_new,password_new_confirm,nickname,phone,id_number">
<?php if ($user['password'] !== null && $user['google_sub'] !== null): ?>
<p class="justify small">Please confirm your identity with your password or with Google to modify your account.</p>
<div class="inputWithButton">
<input type="password" class="text name="password" placeholder="Current password" required minlength="6" autofocus><!--
<input type="password" class="text" name="password" placeholder="Current password" required minlength="6" autofocus><!--
--><button id="authenticateWithGoogleButton" class="yellow" type="button">Google</button>
</div>
<?php elseif ($user['password'] !== null): ?>
@@ -23,10 +23,15 @@
</div>
<?php endif; ?>
<hr>
<?php /* TODO: disabled for the time being, email modification should be implemented */ ?>
<input type="email" class="text big fullWidth" name="email" placeholder="Email address" value="<?= $user['email'] ?>" disabled>
<input type="email" class="text big fullWidth" name="email" placeholder="Email address" value="<?= $user['email'] ?>">
<input type="text" class="text big fullWidth marginTop" name="username" placeholder="Username" value="<?= $user['username'] ?>">
<input type="password" class="text big fullWidth marginTop" name="password_new" placeholder="New password" minlength="6">
<input type="password" class="text big fullWidth marginTop" name="password_new_confirm" placeholder="New password confirmation" minlength="6">
<hr>
<input type="text" class="text big fullWidth marginTop" name="full_name" placeholder="Full name" value="<?= $user['full_name'] ?>" disabled>
<input type="text" class="text big fullWidth marginTop" name="nickname" placeholder="Nickname" value="<?= $user['nickname'] ?>">
<input type="text" class="text big fullWidth marginTop" name="phone" placeholder="Phone" value="<?= $user['phone'] ?>">
<input type="text" class="text big fullWidth marginTop" name="id_number" placeholder="ID number" value="<?= $user['id_number'] ?>">
<p id="accountFormError" class="formError justify marginTop"></p>
<div class="right marginTop">
<button type="submit" name="submit" disabled>Save</button>
+1 -1
View File
@@ -4,7 +4,7 @@
<h2>Login</h2>
<div class="box">
<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="text" class="text big fullWidth" name="email" placeholder="Email address / Username" 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>
<div class="right marginTop">
+2 -2
View File
@@ -25,8 +25,8 @@
@yields('main')
</main>
<footer>
<p><?= round($__debug_runtime, 0) ?> ms</p>
<p><span class="bold"><?= $_ENV['APP_NAME'] ?></span> <?= str_replace('Release_', '', VERSION) ?></p><!--
<p><?= round($__debug_runtime, 0) ?> ms</p><!--
--><p><span class="bold"><?= $_ENV['APP_NAME'] ?></span> <?= str_replace('Release_', '', VERSION) ?></p><!--
--><p>&copy; The RVR Contributors <?= (new DateTime(REVISION_DATE))->format('Y') ?></p>
</footer>
@endsection
+5 -4
View File
@@ -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']);