Compare commits

..
Author SHA1 Message Date
bence 6e1ee839ba Merge pull request 'modernize-oauth' (!76) from modernize-oauth into master
rvr-nextgen/pipeline/tag This commit looks good
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #76
2024-11-26 21:32:41 +01:00
bence cac57d7f71 decease session expiration time
rvr-nextgen/pipeline/pr-master This commit looks good
2024-11-26 21:11:27 +01:00
bence cde14ee779 modernize oauth token handling 2024-11-26 21:11:27 +01:00
bence dfcdd8dca7 update yarn.lock 2024-11-26 21:07:04 +01:00
bence a5286bf62f add .well-known/openid-configuration to the root as well 2024-11-26 21:07:04 +01:00
bence ec4d3806ce Merge pull request 'do not recreate docker runner group and user' (!75) from do-no-recreate-group-and-user into master
rvr-nextgen/pipeline/tag This commit looks good
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #75
2024-03-11 00:15:07 +01:00
bence 0c2334502d do not recreate docker runner group and user
rvr-nextgen/pipeline/pr-master This commit looks good
2024-03-10 23:53:11 +01:00
bence 6341072b0b Merge pull request 'fix regex for tagging' (!74) from bugfix/fix-regex into master
rvr-nextgen/pipeline/head This commit looks good
rvr-nextgen/pipeline/tag This commit looks good
Reviewed-on: #74
2023-10-01 00:15:15 +02:00
bence 1dbd813bef fix regex for tagging
rvr-nextgen/pipeline/pr-master This commit looks good
2023-10-01 00:13:27 +02:00
bence 14d83d24b4 Merge pull request 'push fixed version images' (!73) from feature/auto-release-fixed-tags into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #73
2023-10-01 00:07:52 +02:00
bence ba02b6d8cb push fixed version images
rvr-nextgen/pipeline/pr-master This commit looks good
2023-10-01 00:06:38 +02:00
bence 9b8fcaad9a Merge pull request 'error reporting should always be E_ALL' (!72) from bugfix/fix-error-reporting into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #72
2023-09-30 23:37:24 +02:00
bence 6bd6ede442 error reporting should always be E_ALL
rvr-nextgen/pipeline/pr-master This commit looks good
2023-09-30 23:18:42 +02:00
bence 65dac4640a Merge pull request 'define forwarded_scheme before use' (!71) from bugfix/pass-scheme-to-fastcgi into master
rvr-nextgen/pipeline/head This commit looks good
rvr-nextgen/pipeline/tag This commit looks good
Reviewed-on: #71
2023-09-28 15:00:21 +02:00
bence 7bd12050f6 define forwarded_scheme before use
rvr-nextgen/pipeline/pr-master Build queued...
2023-09-28 14:59:55 +02:00
bence 5041258de0 Merge pull request 'pass scheme to fastcgi' (!70) from bugfix/pass-scheme-to-fastcgi into master
rvr-nextgen/pipeline/head This commit looks good
rvr-nextgen/pipeline/tag This commit looks good
Reviewed-on: #70
2023-09-28 14:55:37 +02:00
bence 47928d2d2b pass scheme to fastcgi
rvr-nextgen/pipeline/pr-master This commit looks good
2023-09-28 14:43:30 +02:00
bence 55cf2afde3 Merge pull request 'feature/update-to-ubuntu-2204' (!69) from feature/update-to-ubuntu-2204 into master
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #69
2023-09-28 14:42:43 +02:00
bence ed2b1c23ae update soko-web
rvr-nextgen/pipeline/pr-master This commit looks good
2023-09-28 14:30:59 +02:00
bence 6ef45b8b6a generate composer.lock 2023-09-28 14:30:58 +02:00
bence b4c359e81d update phpunit 2023-09-28 14:30:58 +02:00
bence 495a2fe910 update ubuntu to 22.04 and php to 8.1 2023-09-28 14:30:58 +02:00
19 changed files with 1055 additions and 797 deletions

No files matched your search

+1
View File
@@ -21,3 +21,4 @@ RECAPTCHA_SITEKEY=your_recaptcha_sitekey
RECAPTCHA_SECRET=your_recaptcha_secret
JWT_RSA_PRIVATE_KEY=jwt-rsa256-private.pem
JWT_RSA_PUBLIC_KEY=jwt-rsa256-public.pem
JWT_KEY_KID=1
Vendored
+11
View File
@@ -98,6 +98,17 @@ pipeline {
--push \
.""",
label: 'Build Docker image'
if (env.BRANCH_NAME == 'master') {
if (env.VERSION ==~ '.*-\\d+-g[a-f0-9]{7}') {
env.FIXED_VERSION = 'dev'
} else {
env.FIXED_VERSION = 'stable'
}
sh script: """docker buildx imagetools create \
-t git.esoko.eu/esoko/rvr:${env.FIXED_VERSION} \
git.esoko.eu/esoko/rvr:${env.VERSION}"""
}
}
}
}
+2 -2
View File
@@ -10,11 +10,11 @@
}
],
"require": {
"esoko/soko-web": "0.14.1",
"esoko/soko-web": "0.15",
"firebase/php-jwt": "^6.4"
},
"require-dev": {
"phpunit/phpunit": "^9.6",
"phpunit/phpunit": "^10.3",
"phpstan/phpstan": "^1.10"
},
"autoload": {
Generated
+380 -582
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,26 @@
CREATE TABLE `oauth_sessions` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`client_id` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`scope` varchar(255) NOT NULL DEFAULT '',
`nonce` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`user_id` int(10) unsigned DEFAULT NULL,
`code` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`code_challenge` varchar(128) NULL,
`code_challenge_method` enum('plain', 'S256') NULL,
`token_claimed` tinyint(1) NOT NULL DEFAULT 0,
`created` timestamp NOT NULL DEFAULT current_timestamp(),
`expires` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
ALTER TABLE `oauth_tokens`
ADD `session_id` int(10) unsigned NULL,
ADD CONSTRAINT `oauth_tokens_session_id` FOREIGN KEY (`session_id`) REFERENCES `oauth_sessions` (`id`),
DROP INDEX `code`,
DROP INDEX `access_token`,
DROP `scope`,
DROP `nonce`,
DROP `user_id`,
DROP `code`,
DROP `access_token`;
+5 -5
View File
@@ -1,9 +1,9 @@
FROM ubuntu:focal AS rvr_base
FROM ubuntu:22.04 AS rvr_base
ENV DEBIAN_FRONTEND noninteractive
RUN apt update --fix-missing && apt install -y sudo curl git unzip mariadb-client nginx \
php-apcu php7.4-cli php7.4-curl php7.4-fpm php7.4-mbstring php7.4-mysql php7.4-zip php7.4-xml
php-apcu php8.1-cli php8.1-curl php8.1-fpm php8.1-mbstring php8.1-mysql php8.1-zip php8.1-xml
RUN mkdir -p /run/php
COPY docker/configs/nginx.conf /etc/nginx/sites-available/default
@@ -20,9 +20,9 @@ FROM rvr_base AS rvr_dev
RUN apt update --fix-missing && apt install -y php-xdebug
RUN echo "xdebug.remote_enable = 1" >> /etc/php/7.4/mods-available/xdebug.ini &&\
echo "xdebug.remote_autostart = 1" >> /etc/php/7.4/mods-available/xdebug.ini &&\
echo "xdebug.remote_connect_back = 1" >> /etc/php/7.4/mods-available/xdebug.ini
RUN echo "xdebug.remote_enable = 1" >> /etc/php/8.1/mods-available/xdebug.ini &&\
echo "xdebug.remote_autostart = 1" >> /etc/php/8.1/mods-available/xdebug.ini &&\
echo "xdebug.remote_connect_back = 1" >> /etc/php/8.1/mods-available/xdebug.ini
EXPOSE 80
EXPOSE 5000
+8 -1
View File
@@ -1,3 +1,9 @@
map $http_x_forwarded_proto $forwarded_scheme {
default $scheme;
http http;
https https;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
@@ -14,7 +20,8 @@ server {
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param REQUEST_SCHEME $forwarded_scheme;
}
location ~ /\.ht {
+10 -6
View File
@@ -16,15 +16,19 @@ echo "Migrating DB..."
./rvr db:migrate
echo "Set runner user based on owner of .env..."
USER_UID=$(stat -c "%u" .env)
USER_GID=$(stat -c "%g" .env)
groupadd --gid $USER_GID rvr
useradd --uid $USER_UID --gid $USER_GID rvr
sed -i -e "s/^user = .*$/user = rvr/g" -e "s/^group = .*$/group = rvr/g" /etc/php/7.4/fpm/pool.d/www.conf
if ! getent group rvr; then
USER_GID=$(stat -c "%g" .env)
groupadd --gid $USER_GID rvr
fi
if ! id -u rvr; then
USER_UID=$(stat -c "%u" .env)
useradd --uid $USER_UID --gid $USER_GID rvr
fi
sed -i -e "s/^user = .*$/user = rvr/g" -e "s/^group = .*$/group = rvr/g" /etc/php/8.1/fpm/pool.d/www.conf
set +e
/usr/sbin/php-fpm7.4 -F &
/usr/sbin/php-fpm8.1 -F &
/usr/sbin/nginx -g 'daemon off;' &
wait -n
+10 -6
View File
@@ -9,17 +9,21 @@ echo "Installing crontab..."
/usr/bin/crontab docker/scripts/cron
echo "Set runner user based on owner of .env..."
USER_UID=$(stat -c "%u" .env)
USER_GID=$(stat -c "%g" .env)
groupadd --gid $USER_GID rvr
useradd --uid $USER_UID --gid $USER_GID rvr
if ! getent group rvr; then
USER_GID=$(stat -c "%g" .env)
groupadd --gid $USER_GID rvr
fi
if ! id -u rvr; then
USER_UID=$(stat -c "%u" .env)
useradd --uid $USER_UID --gid $USER_GID rvr
fi
chown -R rvr:rvr cache
sed -i -e "s/^user = .*$/user = rvr/g" -e "s/^group = .*$/group = rvr/g" /etc/php/7.4/fpm/pool.d/www.conf
sed -i -e "s/^user = .*$/user = rvr/g" -e "s/^group = .*$/group = rvr/g" /etc/php/8.1/fpm/pool.d/www.conf
set +e
/usr/sbin/cron -f &
/usr/sbin/php-fpm7.4 -F &
/usr/sbin/php-fpm8.1 -F &
/usr/sbin/nginx -g 'daemon off;' &
wait -n
+25
View File
@@ -2,6 +2,23 @@
# yarn lockfile v1
"@fortawesome/fontawesome-free@^6.4.0":
version "6.7.1"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.1.tgz#160a48730d533ec77578ed0141661a8f0150a71d"
integrity sha512-ALIk/MOh5gYe1TG/ieS5mVUsk7VUIJTJKPMK9rFFqOgfp0Q3d5QiBXbcOMwUvs37fyZVCz46YjOE6IFeOAXCHA==
"@orchidjs/sifter@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@orchidjs/sifter/-/sifter-1.1.0.tgz#b36154ad0cda4898305d1ac44f318b41048a0438"
integrity sha512-mYwHCfr736cIWWdhhSZvDbf90AKt2xyrJspKFC3qyIJG1LtrJeJunYEqCGG4Aq2ijENbc4WkOjszcvNaIAS/pQ==
dependencies:
"@orchidjs/unicode-variants" "^1.1.2"
"@orchidjs/unicode-variants@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.1.2.tgz#1fd71791a67fdd1591ebe0dcaadd3964537a824e"
integrity sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA==
leaflet.markercluster@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/leaflet.markercluster/-/leaflet.markercluster-1.4.1.tgz#b53f2c4f2ca7306ddab1dbb6f1861d5e8aa6c5e5"
@@ -11,3 +28,11 @@ leaflet@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.6.0.tgz#aecbb044b949ec29469eeb31c77a88e2f448f308"
integrity sha512-CPkhyqWUKZKFJ6K8umN5/D2wrJ2+/8UIpXppY7QDnUZW5bZL5+SEI2J7GBpwh4LIupOKqbNSQXgqmrEJopHVNQ==
tom-select@^2.2.2:
version "2.4.1"
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.1.tgz#6a0b6df8af3df7b09b22dd965eb75ce4d1c547bc"
integrity sha512-adI8H8+wk8RRzHYLQ3bXSk2Q+FAq/kzAATrcWlJ2fbIrEzb0VkwaXzKHTAlBwSJrhqbPJvhV/0eypFkED/nAug==
dependencies:
"@orchidjs/sifter" "^1.1.0"
"@orchidjs/unicode-variants" "^1.1.2"
+28 -1
View File
@@ -5,6 +5,8 @@ use SokoWeb\Database\Query\Modify;
use SokoWeb\Database\Query\Select;
use SokoWeb\Interfaces\Database\IResultSet;
use RVR\Repository\UserPasswordResetterRepository;
use RVR\Repository\OAuthTokenRepository;
use RVR\Repository\OAuthSessionRepository;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -13,11 +15,17 @@ class MaintainDatabaseCommand extends Command
{
private UserPasswordResetterRepository $userPasswordResetterRepository;
private OAuthTokenRepository $oauthTokenRepository;
private OAuthSessionRepository $oauthSessionRepository;
public function __construct()
{
parent::__construct();
$this->userPasswordResetterRepository = new UserPasswordResetterRepository();
$this->oauthTokenRepository = new OAuthTokenRepository();
$this->oauthSessionRepository = new OAuthSessionRepository();
}
public function configure(): void
@@ -31,6 +39,8 @@ class MaintainDatabaseCommand extends Command
try {
$this->deleteExpiredPasswordResetters();
$this->deleteExpiredSessions();
$this->deleteExpiredOauthTokens();
$this->deleteExpiredOauthSessions();
} catch (\Exception $e) {
$output->writeln('<error>Maintenance failed!</error>');
$output->writeln('');
@@ -59,7 +69,7 @@ class MaintainDatabaseCommand extends Command
//TODO: model may be used for sessions too
$select = new Select(\Container::$dbConnection, 'sessions');
$select->columns(['id']);
$select->where('updated', '<', (new DateTime('-7 days'))->format('Y-m-d H:i:s'));
$select->where('updated', '<', (new DateTime('-1 days'))->format('Y-m-d H:i:s'));
$result = $select->execute();
@@ -69,4 +79,21 @@ class MaintainDatabaseCommand extends Command
$modify->delete();
}
}
private function deleteExpiredOauthTokens(): void
{
foreach ($this->oauthTokenRepository->getAllExpired() as $oauthToken) {
\Container::$persistentDataManager->deleteFromDb($oauthToken);
}
}
private function deleteExpiredOauthSessions(): void
{
foreach ($this->oauthSessionRepository->getAllExpired() as $oauthSession) {
if ($this->oauthTokenRepository->countAllBySession($oauthSession) > 0) {
continue;
}
\Container::$persistentDataManager->deleteFromDb($oauthSession);
}
}
}
-79
View File
@@ -1,79 +0,0 @@
<?php namespace RVR\Controller;
use DateTime;
use RVR\PersistentData\Model\OAuthToken;
use RVR\PersistentData\Model\User;
use RVR\Repository\OAuthClientRepository;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\Response\Redirect;
use SokoWeb\Response\HtmlContent;
class OAuthAuthController implements IAuthenticationRequired
{
private OAuthClientRepository $oAuthClientRepository;
public function __construct()
{
$this->oAuthClientRepository = new OAuthClientRepository();
}
public function isAuthenticationRequired(): bool
{
return true;
}
public function auth()
{
$redirectUri = \Container::$request->query('redirect_uri');
$clientId = \Container::$request->query('client_id');
$scope = \Container::$request->query('scope') ? \Container::$request->query('scope'): '';
$state = \Container::$request->query('state');
$nonce = \Container::$request->query('nonce') ? \Container::$request->query('nonce'): '';
if (!$clientId || !$redirectUri || !$state) {
return new HtmlContent('oauth/oauth_error', ['error' => 'An invalid request was made. Please start authentication again.']);
}
$client = $this->oAuthClientRepository->getByClientId($clientId);
if ($client === null) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Client is not authorized.']);
}
$redirectUriParsed = parse_url($redirectUri);
$redirectUriBase = $redirectUriParsed['scheme'] . '://' . $redirectUriParsed['host'] . $redirectUriParsed['path'];
$redirectUriQuery = [];
if (isset($redirectUriParsed['query'])) {
parse_str($redirectUriParsed['query'], $redirectUriQuery);
}
if (!in_array($redirectUriBase, $client->getRedirectUrisArray())) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Redirect URI \'' . $redirectUriBase .'\' is not allowed for this client.']);
}
/**
* @var ?User $user
*/
$user = \Container::$request->user();
$code = bin2hex(random_bytes(16));
$accessToken = bin2hex(random_bytes(16));
$token = new OAuthToken();
$token->setNonce($nonce);
$token->setScope($scope);
$token->setUser($user);
$token->setCode($code);
$token->setAccessToken($accessToken);
$token->setCreatedDate(new DateTime());
$token->setExpiresDate(new DateTime('+5 minutes'));
\Container::$persistentDataManager->saveToDb($token);
$redirectUriQuery = array_merge($redirectUriQuery, [
'state' => $state,
'code' => $code
]);
$finalRedirectUri = $redirectUriBase . '?' . http_build_query($redirectUriQuery);
return new Redirect($finalRedirectUri, IRedirect::TEMPORARY);
}
}
+214 -28
View File
@@ -2,9 +2,16 @@
use DateTime;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use Firebase\JWT\ExpiredException;
use RVR\Repository\OAuthSessionRepository;
use RVR\Repository\OAuthTokenRepository;
use RVR\Repository\UserRepository;
use RVR\PersistentData\Model\User;
use RVR\PersistentData\Model\OAuthSession;
use RVR\PersistentData\Model\OAuthToken;
use RVR\Repository\OAuthClientRepository;
use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Response\JsonContent;
@@ -13,6 +20,8 @@ class OAuthController
{
private OAuthClientRepository $oAuthClientRepository;
private OAuthSessionRepository $oAuthSessionRepository;
private OAuthTokenRepository $oAuthTokenRepository;
private UserRepository $userRepository;
@@ -20,60 +29,181 @@ class OAuthController
public function __construct()
{
$this->oAuthClientRepository = new OAuthClientRepository();
$this->oAuthSessionRepository = new OAuthSessionRepository();
$this->oAuthTokenRepository = new OAuthTokenRepository();
$this->userRepository = new UserRepository();
}
public function getToken(): ?IContent
public function generateToken(): ?IContent
{
$clientId = \Container::$request->post('client_id');
$clientSecret = \Container::$request->post('client_secret');
$credentials = $this->getClientCredentials();
$code = \Container::$request->post('code');
$redirectUri = \Container::$request->post('redirect_uri');
if (!$clientId || !$clientSecret || !$code) {
if (!$credentials['clientId'] || !$code || !$redirectUri) {
return new JsonContent([
'error' => 'An invalid request was made.'
]);
}
$client = $this->oAuthClientRepository->getByClientId($clientId);
if ($client === null || $client->getClientSecret() !== $clientSecret) {
$client = $this->oAuthClientRepository->getByClientId($credentials['clientId']);
if ($client === null) {
return new JsonContent([
'error' => 'Client is not authorized.'
'error' => 'Client is not found.'
]);
}
$token = $this->oAuthTokenRepository->getByCode($code);
if ($token === null || $token->getExpiresDate() < new DateTime()) {
$redirectUriBase = explode('?', $redirectUri)[0];
if (!in_array($redirectUriBase, $client->getRedirectUrisArray())) {
return new JsonContent([
'error' => 'Redirect URI \'' . $redirectUriBase .'\' is not allowed for this client.'
]);
}
$session = $this->oAuthSessionRepository->getByCode($code);
if ($session === null || $session->getTokenClaimed() || $session->getExpiresDate() < new DateTime()) {
return new JsonContent([
'error' => 'The provided code is invalid.'
]);
}
$payload = array_merge([
$codeChallenge = $session->getCodeChallenge();
if ($codeChallenge === null && $client->getClientSecret() !== $credentials['clientSecret']) {
return new JsonContent([
'error' => 'Client is not authorized.'
]);
}
if ($session->getClientId() !== $credentials['clientId']) {
return new JsonContent([
'error' => 'This code cannot be used by this client!'
]);
}
if ($codeChallenge !== null) {
$codeVerifier = \Container::$request->post('code_verifier') ?: '';
if ($session->getCodeChallengeMethod() === 'S256') {
$hash = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
} else {
$hash = $codeVerifier;
}
if ($codeChallenge !== $hash) {
return new JsonContent([
'error' => 'Code challenge failed!'
]);
}
}
$session->setTokenClaimed(true);
\Container::$persistentDataManager->saveToDb($session);
$token = new OAuthToken();
$token->setSession($session);
$token->setCreatedDate(new DateTime());
$token->setExpiresDate(new DateTime('+1 hours'));
\Container::$persistentDataManager->saveToDb($token);
$commonPayload = [
'iss' => $_ENV['APP_URL'],
'iat' => (int)$token->getCreatedDate()->getTimestamp(),
'nbf' => (int)$token->getCreatedDate()->getTimestamp(),
'exp' => (int)$token->getExpiresDate()->getTimestamp(),
'aud' => $clientId,
'nonce' => $token->getNonce()
], $this->getUserInfoInternal(
$this->userRepository->getById($token->getUserId()),
$token->getScopeArray())
'iat' => $token->getCreatedDate()->getTimestamp(),
'nbf' => $session->getCreatedDate()->getTimestamp(),
'exp' => $token->getExpiresDate()->getTimestamp(),
'aud' => $session->getClientId(),
'nonce' => $session->getNonce()
];
$idTokenPayload = array_merge($commonPayload, $this->getUserInfoInternal(
$this->userRepository->getById($session->getUserId()),
$session->getScopeArray())
);
$accessTokenPayload = array_merge($commonPayload, [
'jti' => $token->getId(),
]);
$privateKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PRIVATE_KEY']);
$jwt = JWT::encode($payload, $privateKey, 'RS256');
$idToken = JWT::encode($idTokenPayload, $privateKey, 'RS256', $_ENV['JWT_KEY_KID']);
$accessToken = JWT::encode($accessTokenPayload, $privateKey, 'RS256', $_ENV['JWT_KEY_KID']);
return new JsonContent([
'access_token' => $token->getAccessToken(),
'access_token' => $accessToken,
'expires_in' => $token->getExpiresDate()->getTimestamp() - (new DateTime())->getTimestamp(),
'scope' => $token->getScope(),
'id_token' => $jwt,
'scope' => $session->getScope(),
'id_token' => $idToken,
'token_type' => 'Bearer'
]);
}
public function introspectToken(): ?IContent
{
$credentials = $this->getClientCredentials();
$accessToken = \Container::$request->post('token');
if (!$credentials['clientId'] || !$credentials['clientSecret'] || !$accessToken) {
return new JsonContent([
'error' => 'An invalid request was made.'
]);
}
$client = $this->oAuthClientRepository->getByClientId($credentials['clientId']);
if ($client === null || $client->getClientSecret() !== $credentials['clientSecret']) {
return new JsonContent([
'error' => 'Client is not authorized.'
]);
}
$tokenValidated = $this->validateTokenAndSession($accessToken, $token, $session);
if (!$tokenValidated) {
return new JsonContent([
'active' => false
]);
}
if ($session->getClientId() !== $credentials['clientId']) {
return new JsonContent([
'active' => false
]);
}
return new JsonContent([
'active' => true,
'scope' => $session->getScope(),
'client_id' => $session->getClientId(),
'exp' => $token->getExpiresDate()->getTimestamp(),
]);
}
public function revokeToken(): ?IContent
{
$credentials = $this->getClientCredentials();
$accessToken = \Container::$request->post('token');
if (!$credentials['clientId'] || !$credentials['clientSecret'] || !$accessToken) {
return new JsonContent([
'error' => 'An invalid request was made.'
]);
}
$client = $this->oAuthClientRepository->getByClientId($credentials['clientId']);
if ($client === null || $client->getClientSecret() !== $credentials['clientSecret']) {
return new JsonContent([
'error' => 'Client is not authorized.'
]);
}
$tokenValidated = $this->validateTokenAndSession($accessToken, $token, $session);
if (!$tokenValidated) {
return new JsonContent([]);
}
$session = $this->oAuthSessionRepository->getById($token->getSessionId());
if ($session->getClientId() !== $credentials['clientId']) {
return new JsonContent([]);
}
\Container::$persistentDataManager->deleteFromDb($token);
return new JsonContent([]);
}
public function getUserInfo() : IContent
{
$authorization = \Container::$request->header('Authorization');
@@ -84,9 +214,8 @@ class OAuthController
}
$accessToken = substr($authorization, strlen('Bearer '));
$token = $this->oAuthTokenRepository->getByAccessToken($accessToken);
if ($token === null || $token->getExpiresDate() < new DateTime()) {
$tokenValidated = $this->validateTokenAndSession($accessToken, $token, $session);
if (!$tokenValidated) {
return new JsonContent([
'error' => 'The provided access token is invalid.'
]);
@@ -94,8 +223,8 @@ class OAuthController
return new JsonContent(
$this->getUserInfoInternal(
$this->userRepository->getById($token->getUserId()),
$token->getScopeArray()
$this->userRepository->getById($session->getUserId()),
$session->getScopeArray()
)
);
}
@@ -106,7 +235,10 @@ class OAuthController
'issuer' => $_ENV['APP_URL'],
'authorization_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.auth')->generateLink(),
'token_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.token')->generateLink(),
'introspection_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.token.introspect')->generateLink(),
'revocation_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.token.revoke')->generateLink(),
'userinfo_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.userinfo')->generateLink(),
'end_session_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('logout')->generateLink(),
'jwks_uri' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.certs')->generateLink(),
'response_types_supported' =>
[
@@ -128,6 +260,7 @@ class OAuthController
],
'token_endpoint_auth_methods_supported' =>
[
'client_secret_basic',
'client_secret_post',
],
'claims_supported' =>
@@ -167,13 +300,66 @@ class OAuthController
'kty' => 'RSA',
'alg' => 'RS256',
'use' => 'sig',
'kid' => '1',
'kid' => $_ENV['JWT_KEY_KID'],
'n' => str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['n'])),
'e' => str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['e'])),
]
]]);
}
private function getClientCredentials(): array
{
$authorization = \Container::$request->header('Authorization');
if ($authorization !== null) {
$basicAuthEncoded = substr($authorization, strlen('Basic '));
$basicAuth = explode(':', base64_decode($basicAuthEncoded));
if (count($basicAuth) === 2) {
$clientId = rawurldecode($basicAuth[0]);
$clientSecret = rawurldecode($basicAuth[1]);
} else {
$clientId = null;
$clientSecret = null;
}
} else {
$clientId = \Container::$request->post('client_id');
$clientSecret = \Container::$request->post('client_secret');
}
return ['clientId' => $clientId, 'clientSecret' => $clientSecret];
}
private function validateTokenAndSession(
string $accessToken,
?OAuthToken &$token,
?OAuthSession &$session): bool
{
$publicKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PUBLIC_KEY']);
try {
$payload = JWT::decode($accessToken, new Key($publicKey, 'RS256'));
$token = $this->oAuthTokenRepository->getById($payload->jti);
} catch (SignatureInvalidException | BeforeValidException | ExpiredException) {
$token = null;
} catch (\UnexpectedValueException $e) {
error_log($e->getMessage() . ' Token was: ' . $accessToken);
$token = null;
}
if ($token === null || $token->getExpiresDate() < new DateTime()) {
return false;
}
$session = $this->oAuthSessionRepository->getById($token->getSessionId());
if ($session === null) {
return false;
}
return true;
}
/**
* @param User $user
* @param string[] $scope
* @return array<string, string>
*/
private function getUserInfoInternal(User $user, array $scope): array
{
$userInfo = [];
+90
View File
@@ -0,0 +1,90 @@
<?php namespace RVR\Controller;
use DateTime;
use RVR\PersistentData\Model\OAuthSession;
use RVR\PersistentData\Model\User;
use RVR\Repository\OAuthClientRepository;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\Response\Redirect;
use SokoWeb\Response\HtmlContent;
class OAuthSessionController implements IAuthenticationRequired
{
private OAuthClientRepository $oAuthClientRepository;
public function __construct()
{
$this->oAuthClientRepository = new OAuthClientRepository();
}
public function isAuthenticationRequired(): bool
{
return true;
}
public function auth(): HtmlContent|Redirect
{
$redirectUri = \Container::$request->query('redirect_uri');
$clientId = \Container::$request->query('client_id');
$scope = \Container::$request->query('scope') ? \Container::$request->query('scope'): '';
$state = \Container::$request->query('state');
$nonce = \Container::$request->query('nonce') ? \Container::$request->query('nonce'): '';
$codeChallenge = \Container::$request->query('code_challenge') ?: null;
$codeChallengeMethod = \Container::$request->query('code_challenge_method') ?: null;
if (!$clientId || !$redirectUri || !$state || (!$codeChallenge && $codeChallengeMethod)) {
return new HtmlContent('oauth/oauth_error', ['error' => 'An invalid request was made. Please start authentication again.']);
}
if ($codeChallenge && (strlen($codeChallenge) < 43 || strlen($codeChallenge) > 128)) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Code challenge should be one between 43 and 128 characters long.']);
}
$possibleCodeChallengeMethods = ['plain', 'S256'];
if ($codeChallenge && !in_array($codeChallengeMethod, $possibleCodeChallengeMethods)) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Code challenge method should be one of the following: ' . implode(',', $possibleCodeChallengeMethods)]);
}
$client = $this->oAuthClientRepository->getByClientId($clientId);
if ($client === null) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Client is not found.']);
}
$redirectUriQueryParsed = [];
if (str_contains('?', $redirectUri)) {
[$redirectUriBase, $redirectUriQuery] = explode('?', $redirectUri, 2);
parse_str($redirectUriQuery, $redirectUriQueryParsed);
} else {
$redirectUriBase = $redirectUri;
}
if (!in_array($redirectUriBase, $client->getRedirectUrisArray())) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Redirect URI \'' . $redirectUriBase .'\' is not allowed for this client.']);
}
/**
* @var ?User $user
*/
$user = \Container::$request->user();
$code = bin2hex(random_bytes(16));
$session = new OAuthSession();
$session->setClientId($clientId);
$session->setNonce($nonce);
$session->setScope($scope);
$session->setCodeChallenge($codeChallenge);
$session->setCodeChallengeMethod($codeChallengeMethod);
$session->setUser($user);
$session->setCode($code);
$session->setCreatedDate(new DateTime());
$session->setExpiresDate(new DateTime('+5 minutes'));
\Container::$persistentDataManager->saveToDb($session);
$redirectUriQueryParsed = array_merge($redirectUriQueryParsed, [
'state' => $state,
'code' => $code
]);
$finalRedirectUri = $redirectUriBase . '?' . http_build_query($redirectUriQueryParsed);
return new Redirect($finalRedirectUri, IRedirect::TEMPORARY);
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php namespace RVR\PersistentData\Model;
use DateTime;
use SokoWeb\PersistentData\Model\Model;
class OAuthSession extends Model
{
protected static string $table = 'oauth_sessions';
protected static array $fields = ['client_id', 'scope', 'nonce', 'code_challenge', 'code_challenge_method', 'user_id', 'code', 'created', 'expires', 'token_claimed'];
protected static array $relations = ['user' => User::class];
private static array $possibleScopeValues = ['openid', 'email', 'profile', 'union_profile'];
private static array $possibleCodeChallengeMethodValues = ['plain', 'S256'];
private string $clientId = '';
private array $scope = [];
private string $nonce = '';
private ?string $codeChallenge = null;
private ?string $codeChallengeMethod = null;
private ?User $user = null;
private ?int $userId = null;
private string $code = '';
private DateTime $created;
private DateTime $expires;
private bool $tokenClaimed = false;
public function setScopeArray(array $scope): void
{
$this->scope = array_intersect($scope, self::$possibleScopeValues);
}
public function setClientId(string $clientId): void
{
$this->clientId = $clientId;
}
public function setScope(string $scope): void
{
$this->setScopeArray(explode(' ', $scope));
}
public function setNonce(string $nonce): void
{
$this->nonce = $nonce;
}
public function setCodeChallenge(?string $codeChallenge): void
{
$this->codeChallenge = $codeChallenge;
}
public function setCodeChallengeMethod(?string $codeChallengeMethod): void
{
if ($codeChallengeMethod !== null && !in_array($codeChallengeMethod, self::$possibleCodeChallengeMethodValues)) {
throw new \UnexpectedValueException($codeChallengeMethod . ' is not possible for challengeMethod!');
}
$this->codeChallengeMethod = $codeChallengeMethod;
}
public function setUser(User $user): void
{
$this->user = $user;
}
public function setUserId(int $userId): void
{
$this->userId = $userId;
}
public function setCode(string $code): void
{
$this->code = $code;
}
public function setCreatedDate(DateTime $created): void
{
$this->created = $created;
}
public function setExpiresDate(DateTime $expires): void
{
$this->expires = $expires;
}
public function setCreated(string $created): void
{
$this->created = new DateTime($created);
}
public function setExpires(string $expires): void
{
$this->expires = new DateTime($expires);
}
public function setTokenClaimed(bool $tokenClaimed): void
{
$this->tokenClaimed = $tokenClaimed;
}
public function getClientId(): string
{
return $this->clientId;
}
public function getScope(): string
{
return implode(' ', $this->scope);
}
public function getScopeArray(): array
{
return $this->scope;
}
public function getNonce(): string
{
return $this->nonce;
}
public function getCodeChallenge(): ?string
{
return $this->codeChallenge;
}
public function getCodeChallengeMethod(): ?string
{
return $this->codeChallengeMethod;
}
public function getUser(): ?User
{
return $this->user;
}
public function getUserId(): ?int
{
return $this->userId;
}
public function getCode(): string
{
return $this->code;
}
public function getCreatedDate(): DateTime
{
return $this->created;
}
public function getCreated(): string
{
return $this->created->format('Y-m-d H:i:s');
}
public function getExpiresDate(): DateTime
{
return $this->expires;
}
public function getExpires(): string
{
return $this->expires->format('Y-m-d H:i:s');
}
public function getTokenClaimed(): bool
{
return $this->tokenClaimed;
}
}
+12 -72
View File
@@ -7,61 +7,26 @@ class OAuthToken extends Model
{
protected static string $table = 'oauth_tokens';
protected static array $fields = ['scope', 'nonce', 'user_id', 'code', 'access_token', 'created', 'expires'];
protected static array $fields = ['session_id', 'created', 'expires'];
protected static array $relations = ['user' => User::class];
protected static array $relations = ['session' => OAuthSession::class];
private static array $possibleScopeValues = ['openid', 'email', 'profile'];
private ?OAuthSession $session = null;
private array $scope = [];
private string $nonce = '';
private ?User $user = null;
private ?int $userId = null;
private string $code = '';
private string $accessToken = '';
private ?int $sessionId = null;
private DateTime $created;
private DateTime $expires;
public function setScopeArray(array $scope): void
public function setSession(OAuthSession $session): void
{
$this->scope = array_intersect($scope, self::$possibleScopeValues);
$this->session = $session;
}
public function setScope(string $scope): void
public function setSessionId(int $sessionId): void
{
$this->setScopeArray(explode(' ', $scope));
}
public function setNonce(string $nonce): void
{
$this->nonce = $nonce;
}
public function setUser(User $user): void
{
$this->user = $user;
}
public function setUserId(int $userId): void
{
$this->userId = $userId;
}
public function setCode(string $code): void
{
$this->code = $code;
}
public function setAccessToken(string $accessToken): void
{
$this->accessToken = $accessToken;
$this->sessionId = $sessionId;
}
public function setCreatedDate(DateTime $created): void
@@ -84,39 +49,14 @@ class OAuthToken extends Model
$this->expires = new DateTime($expires);
}
public function getScope(): string
public function getSession(): ?OAuthSession
{
return implode(' ', $this->scope);
return $this->session;
}
public function getScopeArray(): array
public function getSessionId(): ?int
{
return $this->scope;
}
public function getNonce(): string
{
return $this->nonce;
}
public function getUser(): ?User
{
return $this->user;
}
public function getUserId(): ?int
{
return $this->userId;
}
public function getCode(): string
{
return $this->code;
}
public function getAccessToken(): string
{
return $this->accessToken;
return $this->sessionId;
}
public function getCreatedDate(): DateTime
+30
View File
@@ -0,0 +1,30 @@
<?php namespace RVR\Repository;
use DateTime;
use Generator;
use SokoWeb\Database\Query\Select;
use RVR\PersistentData\Model\OAuthSession;
class OAuthSessionRepository
{
public function getById(int $id): ?OAuthSession
{
return \Container::$persistentDataManager->selectFromDbById($id, OAuthSession::class);
}
public function getByCode(string $code): ?OAuthSession
{
$select = new Select(\Container::$dbConnection);
$select->where('code', '=', $code);
return \Container::$persistentDataManager->selectFromDb($select, OAuthSession::class);
}
public function getAllExpired(): Generator
{
$select = new Select(\Container::$dbConnection);
$select->where('expires', '<', (new DateTime())->format('Y-m-d H:i:s'));
yield from \Container::$persistentDataManager->selectMultipleFromDb($select, OAuthSession::class);
}
}
+13 -9
View File
@@ -4,6 +4,7 @@ use DateTime;
use Generator;
use SokoWeb\Database\Query\Select;
use RVR\PersistentData\Model\OAuthToken;
use RVR\PersistentData\Model\OAuthSession;
class OAuthTokenRepository
{
@@ -12,20 +13,16 @@ class OAuthTokenRepository
return \Container::$persistentDataManager->selectFromDbById($id, OAuthToken::class);
}
public function getByCode(string $code): ?OAuthToken
public function getAllBySession(OAuthSession $session, bool $useRelations = false, array $withRelations = []): Generator
{
$select = new Select(\Container::$dbConnection);
$select->where('code', '=', $code);
$select = $this->selectAllBySession($session);
return \Container::$persistentDataManager->selectFromDb($select, OAuthToken::class);
yield from \Container::$persistentDataManager->selectMultipleFromDb($select, OAuthToken::class, $useRelations, $withRelations);
}
public function getByAccessToken(string $accessToken): ?OAuthToken
public function countAllBySession(OAuthSession $session): int
{
$select = new Select(\Container::$dbConnection);
$select->where('access_token', '=', $accessToken);
return \Container::$persistentDataManager->selectFromDb($select, OAuthToken::class);
return $this->selectAllBySession($session)->count();
}
public function getAllExpired(): Generator
@@ -35,4 +32,11 @@ class OAuthTokenRepository
yield from \Container::$persistentDataManager->selectMultipleFromDb($select, OAuthToken::class);
}
private function selectAllBySession(OAuthSession $session): Select
{
$select = new Select(\Container::$dbConnection, OAuthToken::getTable());
$select->where('session_id', '=', $session->getId());
return $select;
}
}
+8 -6
View File
@@ -7,7 +7,7 @@ use SokoWeb\Request\Request;
use SokoWeb\Request\Session;
use RVR\Controller\HomeController;
use RVR\Controller\LoginController;
use RVR\Controller\OAuthAuthController;
use RVR\Controller\OAuthSessionController;
use RVR\Controller\OAuthController;
use RVR\Controller\UserController;
use RVR\Controller\UserSearchController;
@@ -19,9 +19,8 @@ use RVR\Repository\UserRepository;
require 'app.php';
error_reporting(E_ALL);
if (!empty($_ENV['DEV'])) {
error_reporting(E_ALL);
ini_set('display_errors', '1');
} else {
ini_set('display_errors', '0');
@@ -30,6 +29,7 @@ if (!empty($_ENV['DEV'])) {
Container::$routeCollection = new RouteCollection();
Container::$routeCollection->get('home', '', [HomeController::class, 'getHome']);
Container::$routeCollection->get('oauth-config-root', '.well-known/openid-configuration', [OAuthController::class, 'getConfig']);
Container::$routeCollection->group('login', function (RouteCollection $routeCollection) {
$routeCollection->get('login', '', [LoginController::class, 'getLoginForm']);
$routeCollection->post('login-action', '', [LoginController::class, 'login']);
@@ -37,8 +37,10 @@ Container::$routeCollection->group('login', function (RouteCollection $routeColl
$routeCollection->get('login.google-action', 'google/code', [LoginController::class, 'loginWithGoogle']);
});
Container::$routeCollection->group('oauth', function (RouteCollection $routeCollection) {
$routeCollection->get('oauth.auth', 'auth', [OAuthAuthController::class, 'auth']);
$routeCollection->post('oauth.token', 'token', [OAuthController::class, 'getToken']);
$routeCollection->get('oauth.auth', 'auth', [OAuthSessionController::class, 'auth']);
$routeCollection->post('oauth.token', 'token', [OAuthController::class, 'generateToken']);
$routeCollection->post('oauth.token.introspect', 'token/introspect', [OAuthController::class, 'introspectToken']);
$routeCollection->post('oauth.token.revoke', 'token/revoke', [OAuthController::class, 'revokeToken']);
$routeCollection->get('oauth.userinfo', 'userinfo', [OAuthController::class, 'getUserInfo']);
$routeCollection->get('oauth.config', '.well-known/openid-configuration', [OAuthController::class, 'getConfig']);
$routeCollection->get('oauth.certs', 'certs', [OAuthController::class, 'getCerts']);
@@ -116,7 +118,7 @@ Container::$routeCollection->group('communities', function (RouteCollection $rou
Container::$sessionHandler = new DatabaseSessionHandler(
Container::$dbConnection,
'sessions',
new DateTime('-7 days')
new DateTime('-1 days')
);
session_set_save_handler(Container::$sessionHandler, true);