Rewrote tracker authentication to use OpenID Connect.

This commit is contained in:
flash 2025-03-25 19:52:28 +00:00
parent 98cf112002
commit da83e85735
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
9 changed files with 862 additions and 110 deletions

View file

@ -1,9 +1,9 @@
{
"require": {
"flashwave/index": "^0.2503",
"flashii/apii": "^0.5",
"erusev/parsedown": "^1.7",
"sentry/sdk": "^4.0"
"sentry/sdk": "^4.0",
"guzzlehttp/guzzle": "~7.9"
},
"autoload": {
"classmap": [

47
composer.lock generated
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": "7a7507e696bc63c5c529fcd1db2542d2",
"content-hash": "551e2a9180a3d744f8feb737da6afb74",
"packages": [
{
"name": "erusev/parsedown",
@ -56,52 +56,13 @@
},
"time": "2019-12-30T22:54:17+00:00"
},
{
"name": "flashii/apii",
"version": "v0.5.1",
"source": {
"type": "git",
"url": "https://patchii.net/flashii/apii-php.git",
"reference": "69c0a1fa2780f4dda72e1eb4c50599abab9230f8"
},
"require": {
"guzzlehttp/guzzle": "~7.9",
"php": ">=8.1",
"psr/http-client": "^1.0"
},
"require-dev": {
"phpstan/phpstan": "~2.1",
"phpunit/phpunit": "~10.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Flashii\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"bsd-3-clause-clear"
],
"authors": [
{
"name": "flashwave",
"email": "packagist@flash.moe",
"homepage": "https://flash.moe",
"role": "mom"
}
],
"description": "Client library for the Flashii.net API.",
"homepage": "https://api.flashii.net",
"time": "2025-03-25T11:42:32+00:00"
},
{
"name": "flashwave/index",
"version": "v0.2503.230355",
"version": "v0.2503.251852",
"source": {
"type": "git",
"url": "https://patchii.net/flash/index.git",
"reference": "2372a113d26380176994f64ab99c42aaf2e9d98e"
"reference": "60c21301824719551c995e004288f3bfcd1a2509"
},
"require": {
"ext-mbstring": "*",
@ -150,7 +111,7 @@
],
"description": "Composer package for the common library for my projects.",
"homepage": "https://railgun.sh/index",
"time": "2025-03-23T03:45:47+00:00"
"time": "2025-03-25T18:53:17+00:00"
},
{
"name": "guzzlehttp/guzzle",

View file

@ -1,32 +1,11 @@
<?php
namespace Seria;
use RuntimeException;
use Flashii\{FlashiiClient,FlashiiUrls};
use Flashii\Credentials\MisuzuCredentials;
use Seria\Users\UserInfo;
require_once __DIR__ . '/../seria.php';
$authToken = (string)filter_input(INPUT_COOKIE, 'msz_auth');
$flashii = new FlashiiClient('Seria', new MisuzuCredentials($authToken), new FlashiiUrls(
$cfg->getString('apii:url', FlashiiUrls::PROD_URL),
));
try {
$authInfo = $flashii->v1()->me();
if($authInfo !== null) {
$seria->usersCtx->users->syncApiUser($authInfo);
$sUserInfo = $seria->usersCtx->users->getUser($authInfo->getId(), 'id');
$seria->authInfo->userInfo = $sUserInfo;
}
} catch(RuntimeException $ex) {
$authInfo = $sUserInfo = null;
}
$seria->initCsrfToken(
$cfg->getString('csrfp:secret', 'mewow'),
$authInfo === null ? (string)filter_input(INPUT_SERVER, 'REMOTE_ADDR') : $authToken
(string)filter_input(INPUT_SERVER, 'REMOTE_ADDR')
);
$seria->startTemplating();

View file

@ -1,6 +1,7 @@
<?php
namespace Seria;
use Index\Cache\CacheBackends;
use Index\Config\Fs\FsConfig;
use Index\Db\DbBackends;
@ -35,4 +36,6 @@ if($cfg->hasValues('sentry:dsn'))
$db = DbBackends::create($cfg->getString('database:dsn', 'null:'));
$db->execute('SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\';');
$seria = new SeriaContext($db, $cfg);
$cache = CacheBackends::create($cfg->getString('cache:dsn', 'null:'));
$seria = new SeriaContext($db, $cache, $cfg);

785
src/Auth/AuthRoutes.php Normal file
View file

@ -0,0 +1,785 @@
<?php
namespace Seria\Auth;
use RuntimeException;
use UnexpectedValueException;
use GuzzleHttp\Client as GuzzleHttpClient;
use Index\UriBase64;
use Index\Cache\CacheProvider;
use Index\Config\Config;
use Index\Http\{HttpResponseBuilder,HttpRequest,HttpUri};
use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
use Index\Http\Routing\Filters\PrefixFilter;
use Index\Http\Routing\Routes\ExactRoute;
use Seria\GitInfo;
use Seria\Users\UsersContext;
class AuthRoutes implements RouteHandler {
use RouteHandlerCommon;
private GuzzleHttpClient $client;
public function __construct(
private CacheProvider $cache,
private Config $config,
private UsersContext $usersCtx,
private AuthInfo $authInfo,
) {
$this->client = new GuzzleHttpClient([
'allow_redirects' => true,
'timeout' => 10,
'headers' => [
'User-Agent' => sprintf('Seria/%s', GitInfo::version()),
],
]);
}
/**
* @template T of object
* @param callable(): T $constructor
* @return T
*/
private function cached(string $key, callable $constructor, int $lifetime = 300): object {
$value = $this->cache->get($key);
if(is_object($value))
return $value;
$value = $constructor();
$this->cache->set($key, $value, $lifetime);
return $value;
}
/**
* @param object{
* access_token: string,
* expires_in?: int,
* refresh_token?: string,
* } $tokenInfo
*/
private function applyCookies(HttpResponseBuilder $response, HttpRequest $request, object $tokenInfo): void {
$response->addCookie(
'seria_access',
$tokenInfo->access_token,
expires: time() + ($tokenInfo->expires_in ?? 3600),
path: '/',
secure: $request->secure,
httpOnly: true,
sameSiteStrict: true,
);
if(!empty($tokenInfo->refresh_token))
$response->addCookie(
'seria_refresh',
$tokenInfo->refresh_token,
expires: strtotime('1 year'),
path: '/',
secure: $request->secure,
httpOnly: true,
sameSiteStrict: true,
);
}
#[PrefixFilter('/')]
public function filterAuth(HttpResponseBuilder $response, HttpRequest $request): void {
$resourceInfo = $openIdConfig = $userInfo = null;
if($request->hasCookie('seria_access')) {
try {
$resourceInfo ??= $this->getOAuth2ProtectedResource();
$openIdConfig ??= $this->getOpenIdConfiguration($resourceInfo->authorization_servers[array_rand($resourceInfo->authorization_servers)]);
$userInfo = $this->getOpenIdUserInfo(
$openIdConfig->userinfo_endpoint,
(string)$request->getCookie('seria_access')
);
} catch(RuntimeException|UnexpectedValueException $ex) {
$userInfo = null;
}
}
if($userInfo === null && $request->hasCookie('seria_refresh')) {
try {
$resourceInfo ??= $this->getOAuth2ProtectedResource();
$openIdConfig ??= $this->getOpenIdConfiguration($resourceInfo->authorization_servers[array_rand($resourceInfo->authorization_servers)]);
$tokenInfo = $this->postTokenWithRefreshToken(
$openIdConfig->token_endpoint,
(string)$request->getCookie('seria_refresh'),
in_array('client_secret_basic', $openIdConfig->token_endpoint_auth_methods_supported),
);
$this->applyCookies($response, $request, $tokenInfo);
$userInfo = $this->getOpenIdUserInfo(
$openIdConfig->userinfo_endpoint,
$tokenInfo->access_token
);
} catch(RuntimeException|UnexpectedValueException $ex) {
$userInfo = null;
}
}
if($userInfo !== null) {
$this->usersCtx->users->syncApiUser($userInfo);
$this->authInfo->userInfo = $this->usersCtx->users->getUser($userInfo->sub, 'id');
}
}
/**
* @throws RuntimeException
* @throws UnexpectedValueException
* @return object{
* sub: string.
* name: string,
* nickname: string,
* preferred_username: string,
* profile: string,
* picture: string,
* zoneinfo: string,
* birthdate?: string,
* website?: string,
* 'http://railgun.sh/country_code': string,
* 'http://railgun.sh/colour_raw': ?int,
* 'http://railgun.sh/colour_css': string,
* 'http://railgun.sh/rank': int,
* 'http://railgun.sh/roles'?: string[],
* 'http://railgun.sh/banned'?: bool,
* 'http://railgun.sh/is_super'?: bool,
* }
*/
public function getOpenIdUserInfo(string $userInfoEndpoint, string $accessToken): object {
return $this->cached(
sprintf('seria:getOpenIdUserInfo:%s#%s', $userInfoEndpoint, $accessToken),
function() use ($userInfoEndpoint, $accessToken) {
$payload = json_decode((string)$this->client->get($userInfoEndpoint, [
'headers' => [
'Authorization' => sprintf('Bearer %s', $accessToken),
],
])->getBody());
if(!is_object($payload))
throw new UnexpectedValueException('payload was not a json object');
if(property_exists($payload, 'error')) {
$error = is_string($payload->error) ? $payload->error : 'unknown';
if(property_exists($payload, 'error_description')
&& is_string($payload->error_description))
$error = sprintf('[%s] %s', $error, $payload->error_description);
throw new RuntimeException($error);
}
if(!property_exists($payload, 'sub')
|| !is_string($payload->sub))
throw new UnexpectedValueException('payload->sub must be a string');
if(!property_exists($payload, 'name')
|| !is_string($payload->name))
throw new UnexpectedValueException('payload->name must be a string');
if(!property_exists($payload, 'nickname')
|| !is_string($payload->nickname))
throw new UnexpectedValueException('payload->nickname must be a string');
if(!property_exists($payload, 'preferred_username')
|| !is_string($payload->preferred_username))
throw new UnexpectedValueException('payload->preferred_username must be a string');
if(!property_exists($payload, 'profile')
|| !is_string($payload->profile))
throw new UnexpectedValueException('payload->profile must be a string');
if(!property_exists($payload, 'picture')
|| !is_string($payload->picture))
throw new UnexpectedValueException('payload->picture must be a string');
if(!property_exists($payload, 'zoneinfo')
|| !is_string($payload->zoneinfo))
throw new UnexpectedValueException('payload->zoneinfo must be a string');
if(property_exists($payload, 'birthdate')
&& !is_string($payload->birthdate))
throw new UnexpectedValueException('payload->birthdate must be a string, if present');
if(property_exists($payload, 'website')
&& !is_string($payload->website))
throw new UnexpectedValueException('payload->website must be a string, if present');
if(!property_exists($payload, 'http://railgun.sh/country_code')
|| !is_string($payload->{'http://railgun.sh/country_code'}))
throw new UnexpectedValueException('payload->http://railgun.sh/country_code must be a string');
if(!property_exists($payload, 'http://railgun.sh/colour_raw')
|| ($payload->{'http://railgun.sh/colour_raw'} !== null && !is_int($payload->{'http://railgun.sh/colour_raw'})))
throw new UnexpectedValueException('payload->http://railgun.sh/colour_raw must be an integer or null');
if(!property_exists($payload, 'http://railgun.sh/colour_css')
|| !is_string($payload->{'http://railgun.sh/colour_css'}))
throw new UnexpectedValueException('payload->http://railgun.sh/colour_css must be a string');
if(!property_exists($payload, 'http://railgun.sh/rank')
|| !is_int($payload->{'http://railgun.sh/rank'}))
throw new UnexpectedValueException('payload->http://railgun.sh/rank must be an integer');
if(property_exists($payload, 'http://railgun.sh/roles')
&& (
!is_array($payload->{'http://railgun.sh/roles'})
|| !array_all($payload->{'http://railgun.sh/roles'}, fn($value) => is_string($value))
))
throw new UnexpectedValueException('payload->http://railgun.sh/roles must be an array or strings');
if(property_exists($payload, 'http://railgun.sh/banned')
&& !is_bool($payload->{'http://railgun.sh/banned'}))
throw new UnexpectedValueException('payload->http://railgun.sh/banned must be a boolean, if present');
if(property_exists($payload, 'http://railgun.sh/is_super')
&& !is_bool($payload->{'http://railgun.sh/is_super'}))
throw new UnexpectedValueException('payload->http://railgun.sh/is_super must be a boolean, if present');
return $payload;
},
lifetime: 30
);
}
/**
* @throws UnexpectedValueException
* @throws RuntimeException
* @return object{
* access_token: string,
* token_type?: string,
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* id_token?: string,
* }
*/
public function verifyTokenResponse(mixed $payload): object {
if(!is_object($payload))
throw new UnexpectedValueException('payload was not a json object');
if(property_exists($payload, 'error')) {
$error = is_string($payload->error) ? $payload->error : 'unknown';
if(property_exists($payload, 'error_description')
&& is_string($payload->error_description))
$error = sprintf('[%s] %s', $error, $payload->error_description);
throw new RuntimeException($error);
}
if(!property_exists($payload, 'access_token')
|| !is_string($payload->access_token))
throw new UnexpectedValueException('payload->access_token must be a string');
if(property_exists($payload, 'token_type')
&& (!is_string($payload->token_type) || strcasecmp($payload->token_type, 'bearer') !== 0))
throw new UnexpectedValueException('payload->token_type must be equals to "Bearer", if present');
if(property_exists($payload, 'expires_in')
&& !is_int($payload->expires_in))
throw new UnexpectedValueException('payload->expires_in must be an integer, if present');
if(property_exists($payload, 'scope')
&& !is_string($payload->scope))
throw new UnexpectedValueException('payload->scope must be a string, if present');
if(property_exists($payload, 'refresh_token')
&& !is_string($payload->refresh_token))
throw new UnexpectedValueException('payload->refresh_token must be a string, if present');
if(property_exists($payload, 'id_token')
&& !is_string($payload->id_token))
throw new UnexpectedValueException('payload->id_token must be a string, if present');
return $payload;
}
/**
* @throws RuntimeException
* @throws UnexpectedValueException
* @return object{
* access_token: string,
* token_type?: string,
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* id_token?: string,
* }
*/
public function postTokenWithAuthorizationCode(string $tokenEndpoint, string $code, string $codeVerifier, bool $useBasicAuth): object {
$headers = [
'Content-Type' => 'application/x-www-form-urlencoded',
];
$body = [
'grant_type' => 'authorization_code',
'code' => $code,
'code_verifier' => $codeVerifier,
];
if($useBasicAuth) {
$headers['Authorization'] = sprintf('Basic %s', base64_encode(sprintf(
'%s:%s',
$this->config->getString('client_id'),
$this->config->getString('client_secret'),
)));
} else {
$body['client_id'] = $this->config->getString('client_id');
$body['client_secret'] = $this->config->getString('client_secret');
}
return $this->verifyTokenResponse(json_decode((string)$this->client->post($tokenEndpoint, [
'headers' => $headers,
'body' => HttpUri::buildQueryString($body),
])->getBody()));
}
/**
* @throws RuntimeException
* @throws UnexpectedValueException
* @return object{
* access_token: string,
* token_type?: string,
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* id_token?: string,
* }
*/
public function postTokenWithRefreshToken(string $tokenEndpoint, string $refreshToken, bool $useBasicAuth): object {
$headers = [
'Content-Type' => 'application/x-www-form-urlencoded',
];
$body = [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
];
if($useBasicAuth) {
$headers['Authorization'] = sprintf('Basic %s', base64_encode(sprintf(
'%s:%s',
$this->config->getString('client_id'),
$this->config->getString('client_secret'),
)));
} else {
$body['client_id'] = $this->config->getString('client_id');
$body['client_secret'] = $this->config->getString('client_secret');
}
return $this->verifyTokenResponse(json_decode((string)$this->client->post($tokenEndpoint, [
'headers' => $headers,
'body' => HttpUri::buildQueryString($body),
])->getBody()));
}
/**
* @throws UnexpectedValueException
* @return object{
* resource: string,
* authorization_servers: string[],
* scopes_supported: string[],
* bearer_methods_supported: string[],
* }
*/
public function getOAuth2ProtectedResource(): object {
return $this->cached(
sprintf('seria:getOAuth2ProtectedResource:%s', $this->config->getString('base_url')),
function() {
$payload = json_decode((string)$this->client->get($this->config->getString('base_url') . '/.well-known/oauth-protected-resource')->getBody());
if(!is_object($payload))
throw new UnexpectedValueException('payload was not a json object');
if(!property_exists($payload, 'resource')
|| !is_string($payload->resource))
throw new UnexpectedValueException('payload->resource was is string');
if(!property_exists($payload, 'authorization_servers')
|| !is_array($payload->authorization_servers)
|| empty($payload->authorization_servers)
|| !array_all($payload->authorization_servers, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->authorization_servers must be a non-empty array of strings');
if(!property_exists($payload, 'scopes_supported')
|| !is_array($payload->scopes_supported)
|| !array_all($payload->scopes_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->scopes_supported must be an array of strings');
if(!property_exists($payload, 'bearer_methods_supported')
|| !is_array($payload->bearer_methods_supported)
|| !array_all($payload->bearer_methods_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->bearer_methods_supported must be an array of strings');
return $payload;
},
);
}
/**
* @return object{
* issuer: string,
* authorization_endpoint: string,
* token_endpoint: string,
* jwks_uri: string,
* protected_resources: string[],
* scopes_supported: string[],
* response_types_supported: string[],
* response_modes_supported: string[],
* grant_types_supported: string[],
* token_endpoint_auth_methods_supported: string[],
* code_challenge_methods_supported: string[],
* }
*/
public function getOAuth2AuthorizationServer(string $server): object {
return $this->cached(
sprintf('seria:getOAuth2AuthorizationServer:%s', $server),
function() use ($server) {
$payload = json_decode((string)$this->client->get($server . '/.well-known/oauth-authorization-server')->getBody());
if(!is_object($payload))
throw new UnexpectedValueException('payload was not a json object');
if(!property_exists($payload, 'issuer')
|| !is_string($payload->issuer))
throw new UnexpectedValueException('payload->issuer must be a string');
if(!property_exists($payload, 'authorization_endpoint')
|| !is_string($payload->authorization_endpoint))
throw new UnexpectedValueException('payload->authorization_endpoint must be a string');
if(!property_exists($payload, 'token_endpoint')
|| !is_string($payload->token_endpoint))
throw new UnexpectedValueException('payload->token_endpoint must be a string');
if(!property_exists($payload, 'jwks_uri')
|| !is_string($payload->jwks_uri))
throw new UnexpectedValueException('payload->jwks_uri must be a string');
if(!property_exists($payload, 'protected_resources')
|| !is_array($payload->protected_resources)
|| !array_all($payload->protected_resources, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->protected_resources must be an array of strings');
if(!property_exists($payload, 'scopes_supported')
|| !is_array($payload->scopes_supported)
|| !array_all($payload->scopes_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->scopes_supported must be an array of strings');
if(!property_exists($payload, 'response_types_supported')
|| !is_array($payload->response_types_supported)
|| !array_all($payload->response_types_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->response_types_supported must be an array of strings');
if(!property_exists($payload, 'response_modes_supported')
|| !is_array($payload->response_modes_supported)
|| !array_all($payload->response_modes_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->response_modes_supported must be an array of strings');
if(!property_exists($payload, 'grant_types_supported')
|| !is_array($payload->grant_types_supported)
|| !array_all($payload->grant_types_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->grant_types_supported must be an array of strings');
if(!property_exists($payload, 'token_endpoint_auth_methods_supported')
|| !is_array($payload->token_endpoint_auth_methods_supported)
|| !array_all($payload->token_endpoint_auth_methods_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->token_endpoint_auth_methods_supported must be an array of strings');
if(!property_exists($payload, 'code_challenge_methods_supported')
|| !is_array($payload->code_challenge_methods_supported)
|| !array_all($payload->code_challenge_methods_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->code_challenge_methods_supported must be an array of strings');
return $payload;
},
);
}
/**
* @return object{
* issuer: string,
* authorization_endpoint: string,
* token_endpoint: string,
* userinfo_endpoint: string,
* jwks_uri: string,
* response_types_supported: string[],
* response_modes_supported: string[],
* grant_types_supported: string[],
* subject_types_supported: string[],
* id_token_signing_alg_values_supported: string[],
* token_endpoint_auth_methods_supported: string[],
* }
*/
public function getOpenIdConfiguration(string $server): object {
return $this->cached(
sprintf('seria:getOpenIdConfiguration:%s', $server),
function() use ($server) {
$payload = json_decode((string)$this->client->get($server . '/.well-known/openid-configuration')->getBody());
if(!is_object($payload))
throw new UnexpectedValueException('payload was not a json object');
if(!property_exists($payload, 'issuer')
|| !is_string($payload->issuer))
throw new UnexpectedValueException('payload->issuer must be a string');
if(!property_exists($payload, 'authorization_endpoint')
|| !is_string($payload->authorization_endpoint))
throw new UnexpectedValueException('payload->authorization_endpoint must be a string');
if(!property_exists($payload, 'token_endpoint')
|| !is_string($payload->token_endpoint))
throw new UnexpectedValueException('payload->token_endpoint must be a string');
if(!property_exists($payload, 'userinfo_endpoint')
|| !is_string($payload->userinfo_endpoint))
throw new UnexpectedValueException('payload->userinfo_endpoint must be a string');
if(!property_exists($payload, 'jwks_uri')
|| !is_string($payload->jwks_uri))
throw new UnexpectedValueException('payload->jwks_uri must be a string');
if(!property_exists($payload, 'response_types_supported')
|| !is_array($payload->response_types_supported)
|| !array_all($payload->response_types_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->response_types_supported must be an array of strings');
if(!property_exists($payload, 'response_modes_supported')
|| !is_array($payload->response_modes_supported)
|| !array_all($payload->response_modes_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->response_modes_supported must be an array of strings');
if(!property_exists($payload, 'grant_types_supported')
|| !is_array($payload->grant_types_supported)
|| !array_all($payload->grant_types_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->grant_types_supported must be an array of strings');
if(!property_exists($payload, 'subject_types_supported')
|| !is_array($payload->subject_types_supported)
|| !array_all($payload->subject_types_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->subject_types_supported must be an array of strings');
if(!property_exists($payload, 'id_token_signing_alg_values_supported')
|| !is_array($payload->id_token_signing_alg_values_supported)
|| !array_all($payload->id_token_signing_alg_values_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->id_token_signing_alg_values_supported must be an array of strings');
if(!property_exists($payload, 'token_endpoint_auth_methods_supported')
|| !is_array($payload->token_endpoint_auth_methods_supported)
|| !array_all($payload->token_endpoint_auth_methods_supported, fn($value) => is_string($value)))
throw new UnexpectedValueException('payload->token_endpoint_auth_methods_supported must be an array of strings');
return $payload;
},
);
}
#[ExactRoute('GET', '/auth/authorize')]
public function getAuthorize(HttpResponseBuilder $response): int {
try {
$resourceInfo = $this->getOAuth2ProtectedResource();
$authzServer = $this->getOAuth2AuthorizationServer($resourceInfo->authorization_servers[array_rand($resourceInfo->authorization_servers)]);
} catch(RuntimeException|UnexpectedValueException $ex) {
return 500;
}
if(!in_array('authorization_code', $authzServer->grant_types_supported))
return 500;
if(!in_array('query', $authzServer->response_modes_supported))
return 500;
if(!in_array('openid', $authzServer->scopes_supported)
|| !in_array('profile', $authzServer->scopes_supported))
return 500;
if(!in_array('client_secret_basic', $authzServer->token_endpoint_auth_methods_supported)
|| !in_array('client_secret_post', $authzServer->token_endpoint_auth_methods_supported))
return 500;
$scope = 'openid profile';
if(in_array('offline_access', $authzServer->scopes_supported)
&& in_array('refresh_token', $authzServer->grant_types_supported))
$scope .= ' offline_access';
$verifier = random_bytes(20);
$time = pack('J', time());
$state = $time . $verifier;
$state = hash_hmac('sha256', $state, $this->config->getString('state_secret'), true) . $state;
$params = [
'redirect_uri' => ($this->config->getString('redirect_base_url') . '/auth/callback'),
'client_id' => $this->config->getString('client_id'),
'scope' => $scope,
'state' => UriBase64::encode($state),
];
if(in_array('code id_token', $authzServer->response_types_supported))
$params['response_type'] = 'code id_token';
else return 500;
if(in_array('S256', $authzServer->code_challenge_methods_supported)) {
$params['code_challenge_method'] = 'S256';
$verifier = hash('sha256', $verifier, true);
} elseif(in_array('plain', $authzServer->code_challenge_methods_supported)) {
$params['code_challenge_method'] = 'plain';
} else return 500;
$params['code_challenge'] = UriBase64::encode($verifier);
$response->redirect($authzServer->authorization_endpoint . '?' . HttpUri::buildQueryString($params));
return 302;
}
#[ExactRoute('GET', '/auth/callback')]
public function getCallback(HttpResponseBuilder $response, HttpRequest $request) {
$state = $request->getParam('state');
if(empty($state))
return 400;
$state = UriBase64::decode($state);
if(strlen($state) < 60)
return 400;
$signature = hash_hmac('sha256', substr($state, 32), $this->config->getString('state_secret'), true);
if(!hash_equals($signature, substr($state, 0, 32)))
return 403;
$state = unpack('a32hash/Jtime/a20verifier', $state);
if(empty($state))
return 500;
if($state['time'] < strtotime('-15 minutes') || $state['time'] > strtotime('+15 minutes'))
return 403;
$error = $request->getParam('error');
if(!empty($error)) {
$text = $request->getParam('error_description');
if(empty($text))
$text = match($error) {
'access_denied' => 'You cancelled the authorization request.',
'invalid_request' => 'The authorization server did not understand the request, please report this!',
'invalid_scope' => 'The authorization server did not understand the requested scope, please report this!',
'server_error' => 'Something went wrong on the authorization server, please try again later.',
default => sprintf('An unexpected error occurred: %s', $error),
};
return $text;
}
$code = $request->getParam('code');
$idToken = $request->getParam('id_token');
if(empty($code) || empty($idToken))
return 400;
try {
$resourceInfo = $this->getOAuth2ProtectedResource();
} catch(RuntimeException|UnexpectedValueException $ex) {
return 500;
}
// todo: verify id_token signature, need to turn the JWT code in Misuzu into a library
$idToken = explode('.', $idToken, 3);
if(count($idToken) < 3)
return 400;
$idTokenHead = json_decode(UriBase64::decode($idToken[0]));
if(!property_exists($idTokenHead, 'typ')
|| !is_string($idTokenHead->typ)
|| $idTokenHead->typ !== 'JWT')
return 400;
if(!property_exists($idTokenHead, 'alg')
|| !is_string($idTokenHead->alg))
return 400;
if(property_exists($idTokenHead, 'kid')
&& !is_string($idTokenHead->kid))
return 400;
$idTokenPayload = json_decode(UriBase64::decode($idToken[1]));
if(!property_exists($idTokenPayload, 'iss')
|| !is_string($idTokenPayload->iss)
|| !in_array($idTokenPayload->iss, $resourceInfo->authorization_servers))
return 400;
if(!property_exists($idTokenPayload, 'aud')
|| !is_string($idTokenPayload->aud)
|| $idTokenPayload->aud !== $this->config->getString('client_id'))
return 400;
if(!property_exists($idTokenPayload, 'iat')
|| !is_int($idTokenPayload->iat)
|| $idTokenPayload->iat >= time())
return 400;
if(!property_exists($idTokenPayload, 'exp')
|| !is_int($idTokenPayload->exp)
|| $idTokenPayload->exp < time())
return 403;
try {
$authzServer = $this->getOAuth2AuthorizationServer($idTokenPayload->iss);
} catch(RuntimeException|UnexpectedValueException $ex) {
return 500;
}
if(!in_array($resourceInfo->resource, $authzServer->protected_resources))
return 500;
if(!in_array('authorization_code', $authzServer->grant_types_supported))
return 500;
if(!in_array('client_secret_basic', $authzServer->token_endpoint_auth_methods_supported)
|| !in_array('client_secret_post', $authzServer->token_endpoint_auth_methods_supported))
return 500;
try {
$openIdConfig = $this->getOpenIdConfiguration($idTokenPayload->iss);
} catch(RuntimeException|UnexpectedValueException $ex) {
return 500;
}
if(!in_array('public', $openIdConfig->subject_types_supported))
return 500;
if(!in_array($idTokenHead->alg, $openIdConfig->id_token_signing_alg_values_supported))
return 400;
try {
$tokenInfo = $this->postTokenWithAuthorizationCode(
$authzServer->token_endpoint,
$code,
$state['verifier'],
in_array('client_secret_basic', $authzServer->token_endpoint_auth_methods_supported),
);
} catch(UnexpectedValueException $ex) {
return 500;
} catch(RuntimeException $ex) {
return 400;
}
$this->applyCookies($response, $request, $tokenInfo);
$response->redirect('/');
return 302;
}
#[ExactRoute('GET', '/auth/terminate')]
public function getTerminate(HttpResponseBuilder $response, HttpRequest $request): int {
$response->removeCookie(
'seria_access',
path: '/',
secure: $request->secure,
httpOnly: true,
sameSiteStrict: true,
);
$response->removeCookie(
'seria_refresh',
path: '/',
secure: $request->secure,
httpOnly: true,
sameSiteStrict: true,
);
$response->redirect('/');
return 302;
}
}

View file

@ -2,7 +2,8 @@
namespace Seria;
use InvalidArgumentException;
use Index\CsrfToken;
use Index\{CsrfToken,Dependencies};
use Index\Cache\CacheProvider;
use Index\Config\Config;
use Index\Db\DbConnection;
use Index\Db\Migration\{DbMigrationManager,DbMigrationRepo,FsDbMigrationRepo};
@ -10,29 +11,37 @@ use Index\Http\Content\FormContent;
use Index\Http\Routing\HandlerContext;
use Index\Http\Routing\Processors\ProcessorInfo;
use Index\Templating\TplEnvironment;
use Seria\Auth\AuthInfo;
use Seria\Torrents\TorrentsContext;
use Seria\Users\UsersContext;
final class SeriaContext {
public private(set) AuthInfo $authInfo;
public private(set) Dependencies $deps;
public private(set) SiteInfo $siteInfo;
public private(set) Auth\AuthInfo $authInfo;
public private(set) CsrfToken $csrf;
public private(set) ?TplEnvironment $templating = null;
public private(set) TorrentsContext $torrentsCtx;
public private(set) UsersContext $usersCtx;
public private(set) Torrents\TorrentsContext $torrentsCtx;
public private(set) Users\UsersContext $usersCtx;
public function __construct(
private DbConnection $dbConn,
private Config $config
public private(set) DbConnection $dbConn,
public private(set) CacheProvider $cache,
public private(set) Config $config,
) {
$this->authInfo = new AuthInfo;
$this->siteInfo = new SiteInfo($config->scopeTo('site'));
$this->deps = new Dependencies;
$this->deps->register($this);
$this->deps->register($this->deps);
$this->torrentsCtx = new TorrentsContext($dbConn);
$this->usersCtx = new UsersContext($dbConn);
$this->deps->register($this->dbConn);
$this->deps->register($this->cache);
$this->deps->register($this->config);
$this->deps->register($this->siteInfo = $this->deps->constructLazy(SiteInfo::class, config: $config->scopeTo('site')));
$this->deps->register($this->authInfo = $this->deps->constructLazy(Auth\AuthInfo::class));
$this->deps->register($this->torrentsCtx = $this->deps->constructLazy(Torrents\TorrentsContext::class));
$this->deps->register($this->usersCtx = $this->deps->constructLazy(Users\UsersContext::class));
}
public function getDbQueryCount(): int {
@ -66,6 +75,7 @@ final class SeriaContext {
);
$this->templating->addExtension(new TemplatingExtension($this, $this->siteInfo));
$this->templating->addGlobal('globals', $globals);
$this->deps->register($this->templating);
}
public function createRouting(): RoutingContext {
@ -101,19 +111,20 @@ final class SeriaContext {
}
}));
$routing->register(new HomeRoutes($this->templating));
$routing->register(new Users\ProfileRoutes($this->authInfo, $this->torrentsCtx, $this->usersCtx, $this->templating));
$routing->register(new Users\SettingsRoutes($this->authInfo, $this->usersCtx, $this->templating));
$routing->register(new Torrents\AnnounceRoutes($this->torrentsCtx, $this->usersCtx));
$routing->register(new Torrents\TorrentCreateRoutes($this->dbConn, $this->authInfo, $this->torrentsCtx, $this->templating));
$routing->register(new Torrents\TorrentInfoRoutes(
$this->config->scopeTo('announce'),
$this->authInfo,
$this->torrentsCtx,
$this->usersCtx,
$this->templating
$routing->register($this->deps->constructLazy(HomeRoutes::class));
$routing->register($this->deps->constructLazy(
Auth\AuthRoutes::class,
config: $this->config->scopeTo('oidc'),
));
$routing->register(new Torrents\TorrentListRoutes($this->authInfo, $this->torrentsCtx, $this->usersCtx, $this->templating));
$routing->register($this->deps->constructLazy(Users\ProfileRoutes::class));
$routing->register($this->deps->constructLazy(Users\SettingsRoutes::class));
$routing->register($this->deps->constructLazy(Torrents\AnnounceRoutes::class));
$routing->register($this->deps->constructLazy(Torrents\TorrentCreateRoutes::class));
$routing->register($this->deps->constructLazy(
Torrents\TorrentInfoRoutes::class,
config: $this->config->scopeTo('announce'),
));
$routing->register($this->deps->constructLazy(Torrents\TorrentListRoutes::class));
return $routing;
}

View file

@ -19,10 +19,6 @@ class SiteInfo {
get => $this->config->getString('parent');
}
public string $loginUrl {
get => $this->config->getString('login');
}
public function getProfileUrl(UserInfo|string $userInfo): string {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->id;

View file

@ -30,15 +30,19 @@ final class TemplatingExtension extends AbstractExtension {
public function getHeaderMenu(): array {
$menu = [];
if($this->ctx->authInfo->loggedIn)
if($this->ctx->authInfo->loggedIn) {
$menu[] = [
'text' => 'Log out',
'url' => '/auth/terminate',
];
$menu[] = [
'text' => 'Settings',
'url' => '/settings',
];
else
} else
$menu[] = [
'text' => 'Log in',
'url' => $this->siteInfo->loginUrl,
'url' => '/auth/authorize',
];
$menu[] = [

View file

@ -3,7 +3,6 @@ namespace Seria\Users;
use InvalidArgumentException;
use RuntimeException;
use Flashii\V1\Users\V1User;
use Index\XString;
use Index\Colour\Colour;
use Index\Db\{DbConnection,DbStatementCache};
@ -19,15 +18,29 @@ class Users {
return XString::random(48);
}
public function syncApiUser(V1User $authInfo): void {
$stmt = $this->cache->get('INSERT INTO ser_users (user_id, user_name, user_colour, user_rank, user_permissions) VALUES (?, ?, ?, ?, 0) ON DUPLICATE KEY UPDATE user_name = ?, user_colour = ?, user_rank = ?');
$stmt->nextParameter($authInfo->getId());
$stmt->nextParameter($authInfo->getName());
$stmt->nextParameter($authInfo->hasColourRaw() ? $authInfo->getColourRaw() : null);
$stmt->nextParameter($authInfo->getRank());
$stmt->nextParameter($authInfo->getName());
$stmt->nextParameter($authInfo->hasColourRaw() ? $authInfo->getColourRaw() : null);
$stmt->nextParameter($authInfo->getRank());
/**
* @param object{
* sub: string,
* preferred_username: string,
* 'http://railgun.sh/colour_raw': ?int,
* 'http://railgun.sh/rank': int,
* } $authInfo
*/
public function syncApiUser(object $authInfo): void {
$stmt = $this->cache->get(<<<SQL
INSERT INTO ser_users (
user_id, user_name, user_colour, user_rank, user_permissions
) VALUES (?, ?, ?, ?, 0)
ON DUPLICATE KEY
UPDATE user_name = ?, user_colour = ?, user_rank = ?
SQL);
$stmt->nextParameter($authInfo->sub);
$stmt->nextParameter($authInfo->preferred_username);
$stmt->nextParameter($authInfo->{'http://railgun.sh/colour_raw'});
$stmt->nextParameter($authInfo->{'http://railgun.sh/rank'});
$stmt->nextParameter($authInfo->preferred_username);
$stmt->nextParameter($authInfo->{'http://railgun.sh/colour_raw'});
$stmt->nextParameter($authInfo->{'http://railgun.sh/rank'});
$stmt->execute();
}