diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php index 96cbe8f4..ad278b7a 100644 --- a/public-legacy/auth/login.php +++ b/public-legacy/auth/login.php @@ -68,7 +68,7 @@ if($siteIsPrivate) { } while($_SERVER['REQUEST_METHOD'] === 'POST') { - if(!CSRF::validateRequest()) { + if(!$msz->csrfCtx->verifyLegacy()) { $notices[] = 'Was unable to verify the request, please try again!'; break; } diff --git a/public-legacy/auth/logout.php b/public-legacy/auth/logout.php index 8df0ad2c..5995bc34 100644 --- a/public-legacy/auth/logout.php +++ b/public-legacy/auth/logout.php @@ -7,7 +7,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext)) die('Script must be called through the Misuzu route dispatcher.'); if($msz->authInfo->loggedIn) { - if(!CSRF::validateRequest()) { + if(!$msz->csrfCtx->verifyLegacy()) { Template::render('auth.logout'); return; } diff --git a/public-legacy/auth/password.php b/public-legacy/auth/password.php index 7dc85aa8..2787c640 100644 --- a/public-legacy/auth/password.php +++ b/public-legacy/auth/password.php @@ -33,7 +33,7 @@ $remainingAttempts = $msz->authCtx->loginAttempts->countRemainingAttempts($ipAdd while($canResetPassword) { if(!empty($_POST['verification']) && is_scalar($_POST['verification']) && !empty($userInfo)) { - if(!CSRF::validateRequest()) { + if(!$msz->csrfCtx->verifyLegacy()) { $notices[] = 'Was unable to verify the request, please try again!'; break; } @@ -80,7 +80,7 @@ while($canResetPassword) { } if(!empty($_POST['email']) && is_scalar($_POST['email'])) { - if(!CSRF::validateRequest()) { + if(!$msz->csrfCtx->verifyLegacy()) { $notices[] = 'Was unable to verify the request, please try again!'; break; } diff --git a/public-legacy/auth/register.php b/public-legacy/auth/register.php index 44ce50ee..286ae01b 100644 --- a/public-legacy/auth/register.php +++ b/public-legacy/auth/register.php @@ -19,7 +19,7 @@ $countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX'; $remainingAttempts = $msz->authCtx->loginAttempts->countRemainingAttempts($ipAddress); while($_SERVER['REQUEST_METHOD'] === 'POST') { - if(!CSRF::validateRequest()) { + if(!$msz->csrfCtx->verifyLegacy()) { $notices[] = 'Was unable to verify the request, please try again!'; break; } diff --git a/public-legacy/auth/revert.php b/public-legacy/auth/revert.php index 6a4f01ec..a1a0f79c 100644 --- a/public-legacy/auth/revert.php +++ b/public-legacy/auth/revert.php @@ -6,7 +6,7 @@ use Misuzu\Auth\AuthTokenCookie; if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext)) die('Script must be called through the Misuzu route dispatcher.'); -if(CSRF::validateRequest()) { +if($msz->csrfCtx->verifyLegacy()) { $tokenInfo = $msz->authInfo->tokenInfo; if($tokenInfo->hasImpersonatedUserId) { diff --git a/public-legacy/auth/twofactor.php b/public-legacy/auth/twofactor.php index 84083c89..a396e82c 100644 --- a/public-legacy/auth/twofactor.php +++ b/public-legacy/auth/twofactor.php @@ -37,7 +37,7 @@ if($totpInfo === null) { } while($_SERVER['REQUEST_METHOD'] === 'POST') { - if(!CSRF::validateRequest()) { + if(!$msz->csrfCtx->verifyLegacy()) { $notices[] = 'Was unable to verify the request, please try again!'; break; } diff --git a/public-legacy/forum/posting.php b/public-legacy/forum/posting.php index 601c2f0a..5c679b24 100644 --- a/public-legacy/forum/posting.php +++ b/public-legacy/forum/posting.php @@ -147,7 +147,7 @@ if(!empty($_POST)) { $topicType = isset($_POST['type']) ? $_POST['type'] : null; $postSignature = isset($_POST['signature']); - if(!CSRF::validateRequest()) { + if(!$msz->csrfCtx->verifyLegacy()) { $notices[] = 'Could not verify request.'; } else { $isEditingTopic = empty($topicInfo) || ($mode === 'edit' && $originalPostInfo->id == $postInfo->id); diff --git a/public-legacy/manage/changelog/change.php b/public-legacy/manage/changelog/change.php index 2b5e8313..7d8eb00f 100644 --- a/public-legacy/manage/changelog/change.php +++ b/public-legacy/manage/changelog/change.php @@ -34,7 +34,7 @@ else } if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { - if(!CSRF::validateRequest()) + if(!$msz->csrfCtx->verifyLegacy()) Template::throwError(403); $msz->changelog->deleteChange($changeInfo); @@ -44,7 +44,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { } // make errors not echos lol -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $action = !empty($_POST['cl_action']) && is_scalar($_POST['cl_action']) ? trim((string)$_POST['cl_action']) : ''; $summary = !empty($_POST['cl_summary']) && is_scalar($_POST['cl_summary']) ? trim((string)$_POST['cl_summary']) : ''; $body = !empty($_POST['cl_body']) && is_scalar($_POST['cl_body']) ? trim((string)$_POST['cl_body']) : ''; diff --git a/public-legacy/manage/changelog/tag.php b/public-legacy/manage/changelog/tag.php index 75c96574..d0699d9d 100644 --- a/public-legacy/manage/changelog/tag.php +++ b/public-legacy/manage/changelog/tag.php @@ -23,7 +23,7 @@ else } if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { - if(!CSRF::validateRequest()) + if(!$msz->csrfCtx->verifyLegacy()) Template::throwError(403); $msz->changelog->deleteTag($tagInfo); @@ -32,7 +32,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { return; } -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $name = !empty($_POST['ct_name']) && is_scalar($_POST['ct_name']) ? trim((string)$_POST['ct_name']) : ''; $description = !empty($_POST['ct_desc']) && is_scalar($_POST['ct_desc']) ? trim((string)$_POST['ct_desc']) : ''; $archive = !empty($_POST['ct_archive']); diff --git a/public-legacy/manage/forum/redirs.php b/public-legacy/manage/forum/redirs.php index c0d380cd..d55ac1fa 100644 --- a/public-legacy/manage/forum/redirs.php +++ b/public-legacy/manage/forum/redirs.php @@ -8,7 +8,7 @@ if(!$msz->authInfo->getPerms('global')->check(Perm::G_FORUM_TOPIC_REDIRS_MANAGE) Template::throwError(403); if($_SERVER['REQUEST_METHOD'] === 'POST') { - if(!CSRF::validateRequest()) + if(!$msz->csrfCtx->verifyLegacy()) throw new \Exception("Request verification failed."); $rTopicId = !empty($_POST['topic_redir_id']) && is_scalar($_POST['topic_redir_id']) ? trim((string)$_POST['topic_redir_id']) : ''; @@ -21,7 +21,7 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') { } if(!empty($_GET['m']) && $_GET['m'] === 'explode') { - if(!CSRF::validateRequest()) + if(!$msz->csrfCtx->verifyLegacy()) throw new \Exception("Request verification failed."); $rTopicId = !empty($_GET['t']) && is_scalar($_GET['t']) ? (string)$_GET['t'] : ''; diff --git a/public-legacy/manage/general/emoticon.php b/public-legacy/manage/general/emoticon.php index 16e8e540..f11bf509 100644 --- a/public-legacy/manage/general/emoticon.php +++ b/public-legacy/manage/general/emoticon.php @@ -26,7 +26,7 @@ else } // make errors not echos lol -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $order = !empty($_POST['em_order']) && is_scalar($_POST['em_order']) ? (int)$_POST['em_order'] : ''; $minRank = !empty($_POST['em_minrank']) && is_scalar($_POST['em_minrank']) ? (int)$_POST['em_minrank'] : ''; $url = !empty($_POST['em_url']) && is_scalar($_POST['em_url']) ? trim((string)$_POST['em_url']) : ''; diff --git a/public-legacy/manage/general/emoticons.php b/public-legacy/manage/general/emoticons.php index d670c583..a0824d58 100644 --- a/public-legacy/manage/general/emoticons.php +++ b/public-legacy/manage/general/emoticons.php @@ -9,7 +9,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext)) if(!$msz->authInfo->getPerms('global')->check(Perm::G_EMOTES_MANAGE)) Template::throwError(403); -if(CSRF::validateRequest() && !empty($_GET['emote'])) { +if($msz->csrfCtx->verifyLegacy() && !empty($_GET['emote'])) { $emoteId = !empty($_GET['emote']) && is_scalar($_GET['emote']) ? (string)$_GET['emote'] : ''; try { diff --git a/public-legacy/manage/general/setting-delete.php b/public-legacy/manage/general/setting-delete.php index 796e046f..4aaafce9 100644 --- a/public-legacy/manage/general/setting-delete.php +++ b/public-legacy/manage/general/setting-delete.php @@ -11,7 +11,7 @@ $valueInfo = $msz->config->getValueInfo(!empty($_GET['name']) && is_scalar($_GET if($valueInfo === null) Template::throwError(404); -if($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +if($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $msz->logsCtx->createAuthedLog('CONFIG_DELETE', [$valueInfo->name]); $msz->config->removeValues($valueInfo->name); Tools::redirect($msz->urls->format('manage-general-settings')); diff --git a/public-legacy/manage/general/setting.php b/public-legacy/manage/general/setting.php index 116c9464..2f797d2e 100644 --- a/public-legacy/manage/general/setting.php +++ b/public-legacy/manage/general/setting.php @@ -25,7 +25,7 @@ if(!empty($sName)) { } } -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { if($isNew) { $sName = !empty($_POST['conf_name']) && is_scalar($_POST['conf_name']) ? trim((string)$_POST['conf_name']) : ''; if(!DbConfig::validateName($sName)) { diff --git a/public-legacy/manage/news/category.php b/public-legacy/manage/news/category.php index 4a848eea..fa697df2 100644 --- a/public-legacy/manage/news/category.php +++ b/public-legacy/manage/news/category.php @@ -23,7 +23,7 @@ else } if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { - if(!CSRF::validateRequest()) + if(!$msz->csrfCtx->verifyLegacy()) Template::throwError(403); $msz->news->deleteCategory($categoryInfo); @@ -32,7 +32,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { return; } -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $name = !empty($_POST['nc_name']) && is_scalar($_POST['nc_name']) ? trim((string)$_POST['nc_name']) : ''; $description = !empty($_POST['nc_desc']) && is_scalar($_POST['nc_desc']) ? trim((string)$_POST['nc_desc']) : ''; $hidden = !empty($_POST['nc_hidden']); diff --git a/public-legacy/manage/news/post.php b/public-legacy/manage/news/post.php index 783ce36f..f0969b8c 100644 --- a/public-legacy/manage/news/post.php +++ b/public-legacy/manage/news/post.php @@ -24,7 +24,7 @@ else } if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { - if(!CSRF::validateRequest()) + if(!$msz->csrfCtx->verifyLegacy()) Template::throwError(403); $msz->news->deletePost($postInfo); @@ -33,7 +33,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { return; } -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $title = !empty($_POST['np_title']) && is_scalar($_POST['np_title']) ? trim((string)$_POST['np_title']) : ''; $category = !empty($_POST['np_category']) && is_scalar($_POST['np_category']) ? trim((string)$_POST['np_category']) : ''; $featured = !empty($_POST['np_featured']); diff --git a/public-legacy/manage/users/ban.php b/public-legacy/manage/users/ban.php index 5a4e4675..cb322d7f 100644 --- a/public-legacy/manage/users/ban.php +++ b/public-legacy/manage/users/ban.php @@ -12,7 +12,7 @@ if(!$msz->authInfo->getPerms('user')->check(Perm::U_BANS_MANAGE)) Template::throwError(403); if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { - if(!CSRF::validateRequest()) + if(!$msz->csrfCtx->verifyLegacy()) Template::throwError(403); try { @@ -35,7 +35,7 @@ try { $modInfo = $msz->authInfo->userInfo; -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $expires = !empty($_POST['ub_expires']) && is_scalar($_POST['ub_expires']) ? (int)$_POST['ub_expires'] : 0; $expiresCustom = !empty($_POST['ub_expires_custom']) && is_scalar($_POST['ub_expires_custom']) ? trim((string)$_POST['ub_expires_custom']) : ''; $publicReason = !empty($_POST['ub_reason_pub']) && is_scalar($_POST['ub_reason_pub']) ? trim((string)$_POST['ub_reason_pub']) : ''; diff --git a/public-legacy/manage/users/note.php b/public-legacy/manage/users/note.php index d72d4afd..066cbec2 100644 --- a/public-legacy/manage/users/note.php +++ b/public-legacy/manage/users/note.php @@ -35,7 +35,7 @@ if($hasUserId) { } if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { - if(!CSRF::validateRequest()) + if(!$msz->csrfCtx->verifyLegacy()) Template::throwError(403); $msz->usersCtx->modNotes->deleteNotes($noteInfo); @@ -48,7 +48,7 @@ if($hasUserId) { $authorInfo = $noteInfo->authorId !== null ? $msz->usersCtx->getUserInfo($noteInfo->authorId) : null; } -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $title = trim((string)($_POST['mn_title'] ?? '')); $body = trim((string)($_POST['mn_body'] ?? '')); diff --git a/public-legacy/manage/users/role.php b/public-legacy/manage/users/role.php index d0abf2cb..dc2ec42f 100644 --- a/public-legacy/manage/users/role.php +++ b/public-legacy/manage/users/role.php @@ -32,7 +32,7 @@ $canEditPerms = $viewerPerms->check(Perm::U_PERMS_MANAGE); $permsInfos = $roleInfo === null ? null : $msz->perms->getPermissionInfo(roleInfo: $roleInfo, categoryNames: Perm::INFO_FOR_ROLE); $permsLists = Perm::createList(Perm::LISTS_FOR_ROLE); -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $userRank = $msz->usersCtx->users->getUserRank($currentUser); if(!$isNew && !$currentUser->super && $roleInfo->rank >= $userRank) { diff --git a/public-legacy/manage/users/user.php b/public-legacy/manage/users/user.php index a6402cdc..1f0e99f5 100644 --- a/public-legacy/manage/users/user.php +++ b/public-legacy/manage/users/user.php @@ -47,7 +47,7 @@ $permsInfos = $msz->perms->getPermissionInfo(userInfo: $userInfo, categoryNames: $permsLists = Perm::createList(Perm::LISTS_FOR_USER); $permsNeedRecalc = false; -if(CSRF::validateRequest() && $canEdit) { +if($msz->csrfCtx->verifyLegacy() && $canEdit) { if(!empty($_POST['impersonate_user'])) { if(!$canImpersonate) { $notices[] = 'You must be a super user to do this.'; diff --git a/public-legacy/manage/users/warning.php b/public-legacy/manage/users/warning.php index 0cfa810e..4d797e02 100644 --- a/public-legacy/manage/users/warning.php +++ b/public-legacy/manage/users/warning.php @@ -10,7 +10,7 @@ if(!$msz->authInfo->getPerms('user')->check(Perm::U_WARNINGS_MANAGE)) Template::throwError(403); if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) { - if(!CSRF::validateRequest()) + if(!$msz->csrfCtx->verifyLegacy()) Template::throwError(403); try { @@ -33,7 +33,7 @@ try { $modInfo = $msz->authInfo->userInfo; -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $body = trim((string)($_POST['uw_body'] ?? '')); Template::set('warn_value_body', $body); diff --git a/public-legacy/profile.php b/public-legacy/profile.php index 25a4ebab..7d0fc2c4 100644 --- a/public-legacy/profile.php +++ b/public-legacy/profile.php @@ -110,7 +110,7 @@ if($isEditing) { ]); if(!empty($_POST)) { - if(!CSRF::validateRequest()) { + if(!$msz->csrfCtx->verifyLegacy()) { $notices[] = "Couldn't verify you, please refresh the page and retry."; } else { if(!$perms->edit_profile) { diff --git a/public-legacy/settings/account.php b/public-legacy/settings/account.php index 74a0472a..bb101cd3 100644 --- a/public-legacy/settings/account.php +++ b/public-legacy/settings/account.php @@ -16,7 +16,7 @@ $errors = []; $userInfo = $msz->authInfo->userInfo; $isRestricted = $msz->usersCtx->hasActiveBan($userInfo); $hasTotp = $msz->usersCtx->totps->hasUserTotp($userInfo); -$isVerifiedRequest = CSRF::validateRequest(); +$isVerifiedRequest = $msz->csrfCtx->verifyLegacy(); if(!$isRestricted && $isVerifiedRequest && !empty($_POST['role'])) { try { diff --git a/public-legacy/settings/sessions.php b/public-legacy/settings/sessions.php index 43a90f26..0adb7a08 100644 --- a/public-legacy/settings/sessions.php +++ b/public-legacy/settings/sessions.php @@ -13,7 +13,7 @@ $errors = []; $currentUser = $msz->authInfo->userInfo; $activeSessionId = $msz->authInfo->sessionId; -while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { +while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) { $sessionId = !empty($_POST['session']) && is_scalar($_POST['session']) ? trim((string)$_POST['session']) : ''; $activeSessionKilled = false; diff --git a/public/index.php b/public/index.php index 289fb614..7a2654ba 100644 --- a/public/index.php +++ b/public/index.php @@ -6,8 +6,8 @@ use Index\MediaType; use Index\Http\Content\MultipartFormContent; use Index\Http\Content\Multipart\ValueMultipartFormData; use Index\Http\Routing\Router; +use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\RouteInfo; -use Misuzu\Auth\{AuthTokenBuilder,AuthTokenCookie,AuthTokenInfo}; require_once __DIR__ . '/../misuzu.php'; @@ -28,90 +28,6 @@ if(is_file($msz->dbCtx->getMigrateLockPath())) { $request = \Index\Http\HttpRequest::fromRequest(); -$tokenPacker = $msz->authCtx->createAuthTokenPacker(); - -if(!empty($_COOKIE['msz_auth']) && is_string($_COOKIE['msz_auth'])) - $tokenInfo = $tokenPacker->unpack($_COOKIE['msz_auth']); -elseif(!empty($_COOKIE['msz_uid']) && !empty($_COOKIE['msz_sid']) && is_string($_COOKIE['msz_uid']) && is_string($_COOKIE['msz_sid'])) { - $tokenBuilder = new AuthTokenBuilder; - $tokenBuilder->setUserId($_COOKIE['msz_uid']); - $tokenBuilder->setSessionToken($_COOKIE['msz_sid']); - $tokenInfo = $tokenBuilder->toInfo(); - $tokenBuilder = null; -} else - $tokenInfo = AuthTokenInfo::empty(); - -$userInfo = null; -$sessionInfo = null; -$userInfoReal = null; -$remoteAddr = $_SERVER['REMOTE_ADDR']; - -if($tokenInfo->hasUserId && $tokenInfo->hasSessionToken) { - $tokenBuilder = new AuthTokenBuilder($tokenInfo); - - try { - $sessionInfo = $msz->authCtx->sessions->getSession(sessionToken: $tokenInfo->sessionToken); - - if($sessionInfo->expired) { - $tokenBuilder->removeUserId(); - $tokenBuilder->removeSessionToken(); - } elseif($sessionInfo->userId === $tokenInfo->userId) { - $userInfo = $msz->usersCtx->users->getUser($tokenInfo->userId, 'id'); - - if($userInfo->deleted) { - $tokenBuilder->removeUserId(); - $tokenBuilder->removeSessionToken(); - } else { - $msz->usersCtx->users->recordUserActivity($userInfo, remoteAddr: $remoteAddr); - $msz->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $remoteAddr); - if($sessionInfo->shouldBumpExpires) - $tokenBuilder->setEdited(); - - if($tokenInfo->hasImpersonatedUserId) { - $allowToImpersonate = $userInfo->super; - $impersonatedUserId = $tokenInfo->impersonatedUserId; - - if(!$allowToImpersonate) { - $allowImpersonateUsers = $msz->config->getArray(sprintf('impersonate.allow.u%s', $userInfo->id)); - $allowToImpersonate = in_array((string)$impersonatedUserId, $allowImpersonateUsers, true); - } - - if($allowToImpersonate) { - $userInfoReal = $userInfo; - - try { - $userInfo = $msz->usersCtx->users->getUser($impersonatedUserId, 'id'); - } catch(RuntimeException $ex) { - $userInfo = $userInfoReal; - $userInfoReal = null; - $tokenBuilder->removeImpersonatedUserId(); - } - } else $tokenBuilder->removeImpersonatedUserId(); - } - } - } - } catch(RuntimeException $ex) { - $tokenBuilder->removeUserId(); - $tokenBuilder->removeSessionToken(); - $tokenBuilder->removeImpersonatedUserId(); - $userInfo = null; - $sessionInfo = null; - $userInfoReal = null; - } - - if($tokenBuilder->isEdited()) { - $tokenInfo = $tokenBuilder->toInfo(); - AuthTokenCookie::apply($tokenPacker->pack($tokenInfo)); - } -} - -$msz->authInfo->setInfo($tokenInfo, $userInfo, $sessionInfo, $userInfoReal); - -CSRF::init( - $msz->config->getString('csrf.secret', 'soup'), - ($msz->authInfo->loggedIn ? $sessionInfo->token : $remoteAddr) -); - // order for these two currently matters i think: it shouldn't. $router = $msz->createRouting($request); $msz->startTemplating(); @@ -125,16 +41,21 @@ if($msz->domainRoles->hasRole($request->getHeaderLine('Host'), 'main')) { $mszLegacyPathReal = realpath($mszLegacyPath); if($mszLegacyPath === $mszLegacyPathReal || $mszLegacyPath === $mszLegacyPathReal . '/') { // this is here so filters can run... - $router->router->route(RouteInfo::exact($request->method, $request->requestTarget, function() {})); + $router->router->route(RouteInfo::exact( + $request->method, + $request->requestTarget, + #[Before('authz:cookie')] + function() use ($msz, $mszRequestPath) { + if(str_starts_with($mszRequestPath, 'manage') && !$msz->hasManageAccess()) + return 403; + }, + )); $response = $router->router->handle($request); if($response->getBody()->getSize() > 0) { Router::output($response); exit; } - if(str_starts_with($mszRequestPath, 'manage') && !$msz->hasManageAccess()) - Template::throwError(403); - if(is_dir($mszLegacyPath)) $mszLegacyPath .= '/index.php'; diff --git a/src/Auth/AuthApiRoutes.php b/src/Auth/AuthApiRoutes.php deleted file mode 100644 index 374fa0ca..00000000 --- a/src/Auth/AuthApiRoutes.php +++ /dev/null @@ -1,160 +0,0 @@ -<?php -namespace Misuzu\Auth; - -use RuntimeException; -use Misuzu\OAuth2\{OAuth2AccessInfoGetField,OAuth2Context}; -use Misuzu\Users\{UsersContext,UserInfo}; -use Index\Config\Config; -use Index\Http\{HttpRequest,HttpResponseBuilder}; -use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; -use Index\Http\Routing\Filters\PrefixFilter; - -final class AuthApiRoutes implements RouteHandler { - use RouteHandlerCommon; - - public function __construct( - private Config $impersonateConfig, - private UsersContext $usersCtx, - private OAuth2Context $oauth2Ctx, - private AuthContext $authCtx, - private AuthInfo $authInfo, - ) {} - - private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool { - if($impersonator->super) - return true; - - $whitelist = $this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->id)); - return in_array($targetId, $whitelist, true); - } - - /** @return void|array{error: string, error_description?: string} */ - #[PrefixFilter('/api/v1')] - #[PrefixFilter('/oauth2')] - #[PrefixFilter('/uploads')] - public function handleAuthorization(HttpResponseBuilder $response, HttpRequest $request) { - if($this->authInfo->loggedIn) - return; - - $authz = explode(' ', $request->getHeaderLine('Authorization'), 2); - if(count($authz) < 2) - return; - - [$method, $token] = $authz; - - if(strcasecmp('misuzu', $method) === 0) { - $tokenInfo = $this->authCtx->createAuthTokenPacker()->unpack($token); - if(!$tokenInfo->isEmpty) - $token = $tokenInfo->sessionToken; - - try { - $sessionInfo = $this->authCtx->sessions->getSession(sessionToken: $token); - } catch(RuntimeException $ex) { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Misuzu error="invalid_token", error_description="Misuzu token has expired."'); - return; - } - - if($sessionInfo->expired) { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Misuzu error="invalid_token", error_description="Misuzu token has expired."'); - $this->authCtx->sessions->deleteSessions(sessionInfos: $sessionInfo); - return; - } - - $this->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $request->remoteAddress); - - $userInfo = $this->usersCtx->users->getUser($sessionInfo->userId, 'id'); - $userInfoReal = null; - if($tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)) { - $userInfoReal = $userInfo; - - try { - $userInfo = $this->usersCtx->users->getUser($tokenInfo->impersonatedUserId, 'id'); - } catch(RuntimeException $ex) { - $userInfo = $userInfoReal; - } - } - - $this->authInfo->setInfo( - tokenInfo: $tokenInfo, - userInfo: $userInfo, - sessionInfo: $sessionInfo, - realUserInfo: $userInfoReal, - ); - return; - } - - if(strcasecmp('basic', $method) === 0) { - $token = base64_decode($token); - if(empty($token)) { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."'); - return; - } - - $authz = explode(':', $token, 2); - if(count($authz) < 2) { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."'); - return; - } - - [$clientId, $clientSecret] = $authz; - - try { - $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false); - } catch(RuntimeException $ex) { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."'); - return; - } - - if($appInfo->confidential) { - // TODO: rate limiting - - if(!$appInfo->verifyClientSecret($clientSecret)) { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."'); - return; - } - } elseif($clientSecret !== '') { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."'); - return; - } - - $this->authInfo->setInfo( - appInfo: $appInfo, - ); - return; - } - - if(strcasecmp('bearer', $method) === 0) { - try { - $accessInfo = $this->oauth2Ctx->tokens->getAccessInfo($token, OAuth2AccessInfoGetField::Token); - } catch(RuntimeException $ex) { - $accessInfo = null; - } - - if($accessInfo?->expired !== false) { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Access token has expired."'); - return [ - 'error' => 'invalid_token', - 'error_description' => 'Access token has expired.', - ]; - } - - $userInfo = null; - if($accessInfo->userId !== null) - $userInfo = $this->usersCtx->users->getUser($accessInfo->userId, 'id'); - - $this->authInfo->setInfo( - userInfo: $userInfo, - accessInfo: $accessInfo, - ); - return; - } - } -} diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php index 6c1de7c9..b5292413 100644 --- a/src/Auth/AuthInfo.php +++ b/src/Auth/AuthInfo.php @@ -46,6 +46,11 @@ class AuthInfo { $this->setInfo(); } + public bool $hasInfo { + get => $this->userInfo !== null + || $this->appInfo !== null; + } + public bool $loggedIn { get => $this->userInfo !== null; } @@ -55,11 +60,13 @@ class AuthInfo { } public bool $loggedInBearer { - get => $this->accessInfo !== null && $this->userInfo !== null; + get => $this->accessInfo !== null + && $this->userInfo !== null; } public bool $loggedInBearerClient { - get => $this->accessInfo !== null && $this->userInfo === null; + get => $this->accessInfo !== null + && $this->userInfo === null; } public ?string $userId { diff --git a/src/Auth/AuthProcessors.php b/src/Auth/AuthProcessors.php new file mode 100644 index 00000000..afc778bb --- /dev/null +++ b/src/Auth/AuthProcessors.php @@ -0,0 +1,468 @@ +<?php +namespace Misuzu\Auth; + +use RuntimeException; +use Carbon\Carbon; +use Index\Config\Config; +use Index\Http\{HttpRequest,HttpResponseBuilder}; +use Index\Http\Content\FormContent; +use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\Processors\Preprocessor; +use Misuzu\{CsrfContext,Misuzu}; +use Misuzu\OAuth2\{OAuth2AccessInfoGetField,OAuth2Context}; +use Misuzu\Users\{BansData,UsersContext,UserInfo}; + +final class AuthProcessors implements RouteHandler { + use RouteHandlerCommon; + + public function __construct( + private Config $impersonateConfig, + private UsersContext $usersCtx, + private OAuth2Context $oauth2Ctx, + private CsrfContext $csrfCtx, + private AuthContext $authCtx, + private AuthInfo $authInfo, + ) {} + + private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool { + if($impersonator->super) + return true; + + $whitelist = $this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->id)); + return in_array($targetId, $whitelist, true); + } + + /** @param array<string, string> $error */ + private static function applyErrorHeader(HttpResponseBuilder $response, string $method, array $error): void { + $parts = []; + foreach($error as $name => $value) + $parts[] = sprintf('%s="%s"', rawurlencode($name), rawurlencode($value)); + + $response->addHeader('WWW-Authenticate', sprintf('%s %s', $method, implode(', ', $parts))); + } + + /** @return void|int|array{error: array{name: string, text: string}} */ + #[Preprocessor('authz:perm')] + public function preAuthzPermissionCheck( + HandlerContext $context, + string $category, + int $perm, + string $type = '', + ) { + if($this->authInfo->loggedIn && $this->authInfo->getPerms($category)->check($perm)) + return; + + if($type === 'json') { + $context->response->statusCode = 403; + return [ + 'error' => [ + 'name' => 'permission', + 'text' => 'You are not allowed to do this.', + ], + ]; + } else return 403; + } + + /** @return void|int|array{error: array{name: string, text: string}} */ + #[Preprocessor('authz:private')] + public function preAuthzPrivate( + HandlerContext $context, + string $type = '', + string $argName = 'impersonator', + bool $allowDebug = true, + ) { + if($type === 'arg' && isset($context->args[$argName])) + return; + + $result = null; + if((!$allowDebug || !Misuzu::debug()) && $this->authInfo->loggedIn && $this->authInfo->impersonating) + $result = $this->authInfo->realUserInfo; + + if($type === 'arg') + $context->setArgument($argName, $result); + + if($result !== null) { + if($type === 'json') { + $context->response->statusCode = 403; + return [ + 'error' => [ + 'name' => 'impersonating', + 'text' => 'You are not allowed to do this while impersonating someone.', + ], + ]; + } else return 403; + } + } + + /** @return void|int|array{error: array{name: string, text: string}} */ + #[Preprocessor('authz:banned')] + public function preAuthzBanned( + HandlerContext $context, + int $minimumSeverity = BansData::SEVERITY_MIN, + string $type = '', + string $argName = 'banned', + ) { + if($type === 'arg' && isset($context->args[$argName])) + return; + + $result = null; + if($this->authInfo->loggedIn) + $result = $this->usersCtx->tryGetActiveBan($this->authInfo->userInfo, $minimumSeverity); + + if($type === 'arg') + $context->setArgument($argName, $result); + + if($result !== null) { + if($type === 'json') { + $context->response->statusCode = 403; + return [ + 'error' => [ + 'name' => 'ban', + 'text' => 'You are banned, check your profile for more information.', + ], + ]; + } else return 403; + } + } + + /** @return void|int|array{error: array{name: string, text: string}} */ + #[Preprocessor('authz:cookie')] + public function preAuthzCookie( + HttpResponseBuilder $response, + HttpRequest $request, + bool $required = false, + string $type = 'html', // should just be replaced with an Accept header read? + ) { + if($this->authInfo->hasInfo) + return; + + $result = (function() use ($response, $request) { + $packer = $this->authCtx->createAuthTokenPacker(); + $builder = $tokenInfo = $userInfo = $sessionInfo = $userInfoReal = null; + $mszAuth = $mszUserId = $mszSessionId = null; + + try { + $mszAuth = trim($request->getCookie('msz_auth')); + if(!empty($mszAuth)) { + $tokenInfo = $packer->unpack($mszAuth); + } else { + $mszUserId = trim($request->getCookie('msz_uid')); + $mszSessionId = trim($request->getCookie('msz_sid')); + + if(!empty($mszUserId) && !empty($mszSessionId)) + $tokenInfo = (function($builder) use ($mszUserId, $mszSessionId) { + $builder->setUserId($mszUserId); + $builder->setSessionToken($mszSessionId); + return $builder->toInfo(); + })(new AuthTokenBuilder); + } + + if(empty($tokenInfo) || $tokenInfo->isEmpty || !$tokenInfo->hasUserId || !$tokenInfo->hasSessionToken) + return false; + + $builder = new AuthTokenBuilder($tokenInfo); + + try { + $sessionInfo = $this->authCtx->sessions->getSession(sessionToken: $tokenInfo->sessionToken); + if($sessionInfo->expired || $sessionInfo->userId !== $tokenInfo->userId) { + $sessionInfo = null; + $builder->removeUserId(); + $builder->removeSessionToken(); + return false; + } + + $userInfo = $this->usersCtx->users->getUser($sessionInfo->userId, 'id'); + if($userInfo->deleted) { + $sessionInfo = $userInfo = null; + $builder->removeUserId(); + $builder->removeSessionToken(); + return false; + } + + $this->usersCtx->users->recordUserActivity($userInfo, remoteAddr: $request->remoteAddress); + $this->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $request->remoteAddress); + if($sessionInfo->shouldBumpExpires) + $builder->setEdited(); + + if($tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)) { + $userInfoReal = $userInfo; + + try { + $userInfo = $this->usersCtx->users->getUser($tokenInfo->impersonatedUserId, 'id'); + } catch(RuntimeException $ex) { + $userInfo = $userInfoReal; + $userInfoReal = null; + $builder->removeImpersonatedUserId(); + } + } + + return true; + } catch(RuntimeException $ex) { + $builder->removeUserId(); + $builder->removeSessionToken(); + $builder->removeImpersonatedUserId(); + $sessionInfo = $userInfo = $userInfoReal = null; + return false; + } + } finally { + $host = $request->getHeaderLine('Host'); + + if($mszUserId !== null) + $response->removeCookie('msz_uid', '/', $host, $request->secure, true); + + if($mszSessionId !== null) + $response->removeCookie('msz_sid', '/', $host, $request->secure, true); + + if($builder?->isEdited() === true) { + $tokenInfo = $builder->toInfo(); + $response->addCookie( + 'msz_auth', + $packer->pack($tokenInfo), + Carbon::now()->addMonths(3), + '/', + $host, + $request->secure, + true, + true, + ); + } + + $this->authInfo->setInfo( + tokenInfo: $tokenInfo, + userInfo: $userInfo, + sessionInfo: $sessionInfo, + realUserInfo: $userInfoReal, + ); + + $this->csrfCtx->setIdentity( + $this->authInfo->loggedIn ? $this->authInfo->sessionInfo->token : $request->remoteAddress + ); + } + })(); + + if($required && !$result) { + if($type === 'json') { + $response->statusCode = 401; + return [ + 'error' => [ + 'name' => 'authz', + 'text' => 'You must be logged in to do that.', + ], + ]; + } + + return 401; + } + } + + /** @return void|int|array{error: string, error_description: string} */ + #[Preprocessor('authz:misuzu')] + public function preAuthzMisuzu( + HttpResponseBuilder $response, + HttpRequest $request, + bool $required = true, + string $type = 'json', + ) { + if($this->authInfo->hasInfo) + return; + + $result = (function() use ($request) { + $authz = explode(' ', $request->getHeaderLine('Authorization'), 2); + if(count($authz) < 2 || strcasecmp('misuzu', $authz[0]) !== 0) + return false; + + $token = $authz[1]; + $tokenInfo = $this->authCtx->createAuthTokenPacker()->unpack($token); + if(!$tokenInfo->isEmpty) + $token = $tokenInfo->sessionToken; + + try { + $sessionInfo = $this->authCtx->sessions->getSession(sessionToken: $token); + } catch(RuntimeException $ex) { + return false; + } + + if($sessionInfo->expired) { + $this->authCtx->sessions->deleteSessions(sessionInfos: $sessionInfo); + return false; + } + + $userInfo = $this->usersCtx->users->getUser($sessionInfo->userId, 'id'); + if($userInfo->deleted) + return false; + + $this->usersCtx->users->recordUserActivity($userInfo, remoteAddr: $request->remoteAddress); + $this->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $request->remoteAddress); + + $userInfoReal = null; + if($tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)) { + $userInfoReal = $userInfo; + + try { + $userInfo = $this->usersCtx->users->getUser($tokenInfo->impersonatedUserId, 'id'); + } catch(RuntimeException $ex) { + $userInfo = $userInfoReal; + } + } + + $this->authInfo->setInfo( + tokenInfo: $tokenInfo, + userInfo: $userInfo, + sessionInfo: $sessionInfo, + realUserInfo: $userInfoReal, + ); + + return true; + })(); + + if($required && !$result) { + $info = [ + 'error' => 'invalid_token', + 'error_description' => 'Misuzu token has expired.', + ]; + + self::applyErrorHeader($response, 'Misuzu', $info); + + if($type === 'json') { + $response->statusCode = 401; + return $info; + } + + return 401; + } + } + + /** @return void|int|array{error: string, error_description: string} */ + #[Preprocessor('authz:basic')] + public function preAuthzBasic( + HttpResponseBuilder $response, + HttpRequest $request, + ?FormContent $content = null, + bool $required = true, + string $type = 'json', + bool $bodyId = true, + bool $bodySecret = false, + ) { + if($this->authInfo->hasInfo) + return; + + $response->setCacheControl('no-store'); + + $result = (function() use ($request, $content, $bodyId, $bodySecret) { + if($request->hasHeader('Authorization')) { + $token = explode(' ', $request->getHeaderLine('Authorization'), 2); + if(count($token) < 2 || strcasecmp('basic', $token[0]) !== 0) + return false; + + $token = base64_decode($token[1]); + if(empty($token)) + return false; + + $token = explode(':', $token, 2); + if(count($token) < 2) + return false; + + [$clientId, $clientSecret] = $token; + } elseif($bodyId && $content instanceof FormContent) { + $clientId = $content->getParam('client_id'); + $clientSecret = $bodySecret ? $content->getParam('client_secret') : ''; + } else return false; + + try { + $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false); + } catch(RuntimeException $ex) { + return false; + } + + if($appInfo->confidential) { + // TODO: rate limiting + + if(!$appInfo->verifyClientSecret($clientSecret)) + return false; + } elseif(trim($clientSecret) !== '') { + return false; + } + + $this->authInfo->setInfo( + appInfo: $appInfo, + ); + + return true; + })(); + + if($required && !$result) { + $info = [ + 'error' => 'invalid_client', + 'error_description' => 'Client authentication failed.', + ]; + + self::applyErrorHeader($response, 'Basic', $info); + + if($type === 'json') { + $response->statusCode = 401; + return $info; + } + + return 401; + } + } + + /** @return void|int|array{error: string, error_description: string} */ + #[Preprocessor('authz:bearer')] + public function preAuthzBearer( + HttpResponseBuilder $response, + HttpRequest $request, + bool $required = true, + string $type = 'json', + ) { + if($this->authInfo->hasInfo) + return; + + $result = (function() use ($request) { + $authz = explode(' ', $request->getHeaderLine('Authorization'), 2); + if(count($authz) < 2 || strcasecmp('basic', $authz[0]) !== 0) + return false; + + try { + $accessInfo = $this->oauth2Ctx->tokens->getAccessInfo($authz[1], OAuth2AccessInfoGetField::Token); + } catch(RuntimeException $ex) { + return false; + } + + if($accessInfo->expired) + return false; + + $userInfo = null; + if($accessInfo->userId !== null) { + $userInfo = $this->usersCtx->users->getUser($accessInfo->userId, 'id'); + if($userInfo->deleted) + return false; + + $this->usersCtx->users->recordUserActivity($userInfo, remoteAddr: $request->remoteAddress); + } + + $this->authInfo->setInfo( + userInfo: $userInfo, + accessInfo: $accessInfo, + ); + + return true; + })(); + + if($required && !$result) { + $info = [ + 'error' => 'invalid_token', + 'error_description' => 'Access token has expired.', + ]; + + self::applyErrorHeader($response, 'Bearer', $info); + + if($type === 'json') { + $response->statusCode = 401; + return $info; + } + + return 401; + } + } +} diff --git a/src/Auth/AuthTokenCookie.php b/src/Auth/AuthTokenCookie.php index dd2ceba0..e2033d7d 100644 --- a/src/Auth/AuthTokenCookie.php +++ b/src/Auth/AuthTokenCookie.php @@ -10,9 +10,6 @@ final class AuthTokenCookie { if(empty($url)) $url = $_SERVER['HTTP_HOST']; - if(!filter_var($url, FILTER_VALIDATE_IP)) - $url = '.' . $url; - return $url; } diff --git a/src/CSRF.php b/src/CSRF.php deleted file mode 100644 index 8a7d76d8..00000000 --- a/src/CSRF.php +++ /dev/null @@ -1,45 +0,0 @@ -<?php -namespace Misuzu; - -use Index\CsrfToken; - -final class CSRF { - private static ?CsrfToken $instance = null; - private static string $secretKey = ''; - - public static function available(): bool { - return self::$instance !== null; - } - - public static function create(string $identity, ?string $secretKey = null): CsrfToken { - if($secretKey === null) - $secretKey = self::$secretKey; - else - self::$secretKey = $secretKey; - - return new CsrfToken($secretKey, $identity); - } - - public static function init(string $secretKey, string $identity): void { - self::$instance = self::create($identity, $secretKey); - } - - public static function validate(string $token, int $tolerance = -1): bool { - return self::$instance?->verifyToken($token, $tolerance) ?? false; - } - - public static function token(): string { - return self::$instance?->createToken() ?? ''; - } - - public static function validateRequest(int $tolerance = -1): bool { - if(self::$instance === null) - return false; - - $token = isset($_POST['_csrf']) && is_string($_POST['_csrf']) ? $_POST['_csrf'] : ''; - if(empty($token)) - $token = isset($_GET['csrf']) && is_string($_GET['csrf']) ? $_GET['csrf'] : ''; - - return self::$instance->verifyToken($token, $tolerance); - } -} diff --git a/src/Changelog/ChangelogRoutes.php b/src/Changelog/ChangelogRoutes.php index b5356f1d..8b91a295 100644 --- a/src/Changelog/ChangelogRoutes.php +++ b/src/Changelog/ChangelogRoutes.php @@ -5,6 +5,7 @@ use ErrorException; use RuntimeException; use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; use Index\Syndication\FeedBuilder; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; @@ -24,6 +25,7 @@ final class ChangelogRoutes implements RouteHandler, UrlSource { ) {} #[ExactRoute('GET', '/changelog')] + #[Before('authz:cookie')] #[UrlFormat('changelog-index', '/changelog', ['date' => '<date>', 'user' => '<user>', 'tags' => '<tags>', 'p' => '<page>'])] public function getIndex(HttpResponseBuilder $response, HttpRequest $request): int|string { $filterDate = (string)$request->getParam('date'); @@ -98,6 +100,7 @@ final class ChangelogRoutes implements RouteHandler, UrlSource { } #[PatternRoute('GET', '/changelog/change/([0-9]+)')] + #[Before('authz:cookie')] #[UrlFormat('changelog-change', '/changelog/change/<change>')] #[UrlFormat('changelog-change-comments', '/changelog/change/<change>', fragment: 'comments')] public function getChange(HttpResponseBuilder $response, HttpRequest $request, string $changeId): int|string { diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php index 791e61cc..18ddd0ca 100644 --- a/src/Comments/CommentsRoutes.php +++ b/src/Comments/CommentsRoutes.php @@ -6,11 +6,10 @@ use Index\XArray; use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder}; use Index\Http\Content\FormContent; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; -use Index\Http\Routing\Filters\PrefixFilter; use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; -use Misuzu\{CSRF,Perm}; +use Misuzu\Perm; use Misuzu\Auth\AuthInfo; use Misuzu\Perms\{PermissionResult,IPermissionResult}; use Misuzu\Users\{UserInfo,UsersContext,UsersData}; @@ -189,21 +188,6 @@ class CommentsRoutes implements RouteHandler, UrlSource { ]; } - /** @return void|array{error: array{name: string, text: string}} */ - #[PrefixFilter('/comments')] - public function checkCsrf(HttpResponseBuilder $response, HttpRequest $request) { - if(in_array($request->method, ['DELETE', 'PATCH', 'POST'])) { - if(!$this->authInfo->loggedIn) - return self::error($response, 401, 'comments:auth', 'You must be logged in to use the comments system.'); - if(!CSRF::validate($request->getHeaderLine('x-csrf-token'))) - return self::error($response, 403, 'comments:csrf', 'Request could not be verified. Please try again.'); - if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) - return self::error($response, 403, 'comments:csrf', 'You are banned, check your profile for more information.'); - } - - $response->setHeader('X-CSRF-Token', CSRF::token()); - } - /** * @return array{ * category: array{ @@ -233,6 +217,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { * }|array{error: array{name: string, text: string}} */ #[PatternRoute('GET', '/comments/categories/([A-Za-z0-9-]+)')] + #[Before('authz:cookie')] public function getCategory(HttpResponseBuilder $response, string $categoryName): array { try { $catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName); @@ -295,6 +280,9 @@ class CommentsRoutes implements RouteHandler, UrlSource { * }|array{error: array{name: string, text: string}} */ #[PatternRoute('POST', '/comments/categories/([A-Za-z0-9-]+)')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[Before('input:urlencoded')] public function patchCategory(HttpResponseBuilder $response, FormContent $content, string $categoryName): array { try { @@ -335,6 +323,9 @@ class CommentsRoutes implements RouteHandler, UrlSource { * @return mixed[]|array{error: array{name: string, text: string}} */ #[ExactRoute('POST', '/comments/posts')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[Before('input:multipart')] public function postPost(HttpResponseBuilder $response, FormContent $content): array { $perms = $this->getGlobalPerms(); @@ -397,6 +388,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { * @return mixed[]|array{error: array{name: string, text: string}} */ #[PatternRoute('GET', '/comments/posts/([0-9]+)')] + #[Before('authz:cookie')] public function getPost(HttpResponseBuilder $response, string $commentId): array { try { $postInfo = $this->commentsCtx->posts->getPost($commentId); @@ -422,6 +414,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { * @return mixed[]|array{error: array{name: string, text: string}} */ #[PatternRoute('GET', '/comments/posts/([0-9]+)/replies')] + #[Before('authz:cookie')] public function getPostReplies(HttpResponseBuilder $response, string $commentId): array { try { $postInfo = $this->commentsCtx->posts->getPost($commentId); @@ -446,6 +439,9 @@ class CommentsRoutes implements RouteHandler, UrlSource { * }|array{error: array{name: string, text: string}} */ #[PatternRoute('PATCH', '/comments/posts/([0-9]+)')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[Before('input:multipart')] public function patchPost(HttpResponseBuilder $response, FormContent $content, string $commentId): array { try { @@ -515,6 +511,9 @@ class CommentsRoutes implements RouteHandler, UrlSource { * @return string|array{error: array{name: string, text: string}} */ #[PatternRoute('DELETE', '/comments/posts/([0-9]+)')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] public function deletePost(HttpResponseBuilder $response, string $commentId): array|string { try { $postInfo = $this->commentsCtx->posts->getPost($commentId); @@ -544,6 +543,9 @@ class CommentsRoutes implements RouteHandler, UrlSource { * @return mixed[]|array{error: array{name: string, text: string}} */ #[PatternRoute('POST', '/comments/posts/([0-9]+)/restore')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] public function postPostRestore(HttpResponseBuilder $response, string $commentId): array { if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY)) return self::error($response, 403, 'comments:restore-not-allowed', 'You are not allowed to restore comments.'); @@ -569,6 +571,9 @@ class CommentsRoutes implements RouteHandler, UrlSource { * @return mixed[]|array{error: array{name: string, text: string}} */ #[PatternRoute('POST' ,'/comments/posts/([0-9]+)/nuke')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] public function postPostNuke(HttpResponseBuilder $response, string $commentId): array { if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY)) return self::error($response, 403, 'comments:nuke-not-allowed', 'You are not allowed to permanently delete comments.'); @@ -598,6 +603,9 @@ class CommentsRoutes implements RouteHandler, UrlSource { * }|array{error: array{name: string, text: string}} */ #[PatternRoute('POST', '/comments/posts/([0-9]+)/vote')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[Before('input:urlencoded')] public function postPostVote(HttpResponseBuilder $response, FormContent $content, string $commentId): array { $vote = (int)$content->getFilteredParam('vote', FILTER_SANITIZE_NUMBER_INT); @@ -644,6 +652,9 @@ class CommentsRoutes implements RouteHandler, UrlSource { * }|array{error: array{name: string, text: string}} */ #[PatternRoute('DELETE', '/comments/posts/([0-9]+)/vote')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array { if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE)) return self::error($response, 403, 'comments:vote-not-allowed', 'You are not allowed to vote on comments.'); diff --git a/src/CsrfContext.php b/src/CsrfContext.php new file mode 100644 index 00000000..cbc1509b --- /dev/null +++ b/src/CsrfContext.php @@ -0,0 +1,129 @@ +<?php +namespace Misuzu; + +use Index\CsrfToken; +use Index\Config\Config; +use Index\Http\Content\FormContent; +use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\Processors\Preprocessor; +use Twig\TwigFunction; +use Twig\Extension\AbstractExtension; + +class CsrfContext extends AbstractExtension implements RouteHandler { + use RouteHandlerCommon; + + private ?CsrfToken $instance = null; + + public function __construct( + private Config $config, + ) {} + + public function createInstance(string $identity): CsrfToken { + return new CsrfToken( + $this->config->getString('secret', 'soup'), + $identity, + ); + } + + public function isAvailable(): bool { + return $this->instance !== null; + } + + public function setIdentity(string $identity): void { + $this->instance = $this->createInstance($identity); + } + + public function createToken(): string { + return $this->instance?->createToken() ?? ''; + } + + public function verifyToken(string $token, int $tolerance = -1): bool { + return $this->instance?->verifyToken($token, $tolerance) ?? false; + } + + public function verifyLegacy(int $tolerance = -1): bool { + if(!$this->isAvailable()) + return false; + + $token = isset($_POST['_csrf']) && is_string($_POST['_csrf']) ? $_POST['_csrf'] : ''; + if(empty($token)) + $token = isset($_GET['csrf']) && is_string($_GET['csrf']) ? $_GET['csrf'] : ''; + + return $this->verifyToken($token, $tolerance); + } + + #[\Override] + public function getFunctions() { + return [ + new TwigFunction('csrf_available', $this->isAvailable(...)), + new TwigFunction('csrf_token', $this->createToken(...)), + ]; + } + + /** @return void|int|array{error: array{name: string, text: string}} */ + #[Preprocessor('csrf:header')] + public function preVerifyHeader( + HandlerContext $context, + string $type = '', + string $argName = 'csrf', + ) { + if($type === 'arg' && isset($context->args[$argName]) && $context->args[$argName] === true) + return; + + $result = $this->isAvailable() + && $context->request->hasHeader('X-CSRF-Token') + && $this->verifyToken($context->request->getHeaderLine('X-CSRF-Token')); + + if($result) { + $context->response->setHeader('X-CSRF-Token', $this->createToken()); + if($type === 'arg') + $context->setArgument($argName, true); + } else { + if($type === 'arg') + $context->setArgument($argName, false); + elseif($type === 'json') { + $context->response->statusCode = 403; + return [ + 'error' => [ + 'name' => 'csrf', + 'text' => 'Request could not be verified. Please try again.', + ], + ]; + } else return 403; + } + } + + /** @return void|int|array{error: array{name: string, text: string}} */ + #[Preprocessor('csrf:form')] + public function preVerifyForm( + HandlerContext $context, + ?FormContent $content, + string $formParam = '_csrf', + string $type = '', + string $argName = 'csrf', + ) { + if($type === 'arg' && isset($context->args[$argName]) && $context->args[$argName] === true) + return; + + $result = $this->isAvailable() + && $content?->hasParam($formParam) === true + && $this->verifyToken((string)$content->getParam($formParam)); + + if($result) { + if($type === 'arg') + $context->setArgument($argName, true); + } else { + if($type === 'arg') { + $context->setArgument($argName, false); + } elseif($type === 'json') { + $context->response->statusCode = 403; + return [ + 'error' => [ + 'name' => 'csrf', + 'text' => 'Request could not be verified. Please try again.', + ], + ]; + } else return 403; + } + } +} diff --git a/src/Forum/ForumCategoriesRoutes.php b/src/Forum/ForumCategoriesRoutes.php index ab7de7c9..a6529546 100644 --- a/src/Forum/ForumCategoriesRoutes.php +++ b/src/Forum/ForumCategoriesRoutes.php @@ -5,9 +5,10 @@ use stdClass; use RuntimeException; use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon}; -use Misuzu\{CSRF,Pagination,Perm,Template}; +use Misuzu\{Pagination,Perm,Template}; use Misuzu\Auth\AuthInfo; use Misuzu\Users\UsersContext; @@ -21,6 +22,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource { ) {} #[ExactRoute('GET', '/forum')] + #[Before('authz:cookie')] #[UrlFormat('forum-index', '/forum')] #[UrlFormat('forum-category-root', '/forum', fragment: '<forum>')] public function getIndex(): string { @@ -172,6 +174,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource { } #[PatternRoute('GET', '/forum/([0-9]+)')] + #[Before('authz:cookie')] #[UrlFormat('forum-category', '/forum/<forum>', ['page' => '<page>'])] public function getCategory(HttpResponseBuilder $response, HttpRequest $request, string $catId): mixed { try { @@ -330,15 +333,10 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource { } #[ExactRoute('POST', '/forum/mark-as-read')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] #[UrlFormat('forum-mark-as-read', '/forum/mark-as-read', ['cat' => '<category>', 'rec' => '<recursive>'])] public function postMarkAsRead(HttpResponseBuilder $response, HttpRequest $request): mixed { - if(!$this->authInfo->loggedIn) - return 401; - - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) - return 403; - $response->setHeader('X-CSRF-Token', CSRF::token()); - $catId = (string)$request->getFilteredParam('cat', FILTER_SANITIZE_NUMBER_INT); $recursive = !empty($request->getParam('rec')); diff --git a/src/Forum/ForumPostsRoutes.php b/src/Forum/ForumPostsRoutes.php index 0080374e..bb436a21 100644 --- a/src/Forum/ForumPostsRoutes.php +++ b/src/Forum/ForumPostsRoutes.php @@ -4,12 +4,12 @@ namespace Misuzu\Forum; use RuntimeException; use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\PatternRoute; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; -use Misuzu\{CSRF,Perm}; +use Misuzu\Perm; use Misuzu\Auth\AuthInfo; use Misuzu\Logs\LogsContext; -use Misuzu\Users\UsersContext; class ForumPostsRoutes implements RouteHandler, UrlSource { use RouteHandlerCommon, UrlSourceCommon; @@ -17,12 +17,12 @@ class ForumPostsRoutes implements RouteHandler, UrlSource { public function __construct( private UrlRegistry $urls, private ForumContext $forumCtx, - private UsersContext $usersCtx, private LogsContext $logsCtx, private AuthInfo $authInfo, ) {} #[PatternRoute('GET', '/forum/posts/([0-9]+)')] + #[Before('authz:cookie')] #[UrlFormat('forum-post', '/forum/posts/<post>')] public function getPost(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed { try { @@ -56,25 +56,11 @@ class ForumPostsRoutes implements RouteHandler, UrlSource { } #[PatternRoute('DELETE', '/forum/posts/([0-9]+)')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[UrlFormat('forum-post-delete', '/forum/posts/<post>')] public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed { - if(!$this->authInfo->loggedIn) - return 401; - - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) - return 403; - $response->setHeader('X-CSRF-Token', CSRF::token()); - - if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) { - $response->statusCode = 403; - return [ - 'error' => [ - 'name' => 'user:banned', - 'text' => "You aren't allowed to do that while banned.", - ], - ]; - } - try { $post = $this->forumCtx->posts->getPost( postId: $postId, @@ -185,25 +171,11 @@ class ForumPostsRoutes implements RouteHandler, UrlSource { } #[PatternRoute('POST', '/forum/posts/([0-9]+)/nuke')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[UrlFormat('forum-post-nuke', '/forum/posts/<post>/nuke')] public function postPostNuke(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed { - if(!$this->authInfo->loggedIn) - return 401; - - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) - return 403; - $response->setHeader('X-CSRF-Token', CSRF::token()); - - if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) { - $response->statusCode = 403; - return [ - 'error' => [ - 'name' => 'user:banned', - 'text' => "You aren't allowed to do that while banned.", - ], - ]; - } - try { $post = $this->forumCtx->posts->getPost( postId: $postId, @@ -259,25 +231,11 @@ class ForumPostsRoutes implements RouteHandler, UrlSource { } #[PatternRoute('POST', '/forum/posts/([0-9]+)/restore')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[UrlFormat('forum-post-restore', '/forum/posts/<post>/restore')] public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed { - if(!$this->authInfo->loggedIn) - return 401; - - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) - return 403; - $response->setHeader('X-CSRF-Token', CSRF::token()); - - if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) { - $response->statusCode = 403; - return [ - 'error' => [ - 'name' => 'user:banned', - 'text' => "You aren't allowed to do that while banned.", - ], - ]; - } - try { $post = $this->forumCtx->posts->getPost( postId: $postId, diff --git a/src/Forum/ForumTopicsRoutes.php b/src/Forum/ForumTopicsRoutes.php index f87ffe56..e173500c 100644 --- a/src/Forum/ForumTopicsRoutes.php +++ b/src/Forum/ForumTopicsRoutes.php @@ -6,9 +6,10 @@ use RuntimeException; use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Content\FormContent; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\PatternRoute; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; -use Misuzu\{CSRF,Pagination,Perm,Template}; +use Misuzu\{Pagination,Perm,Template}; use Misuzu\Auth\AuthInfo; use Misuzu\Logs\LogsContext; use Misuzu\Users\UsersContext; @@ -24,6 +25,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource { ) {} #[PatternRoute('GET', '/forum/topics/([0-9]+)')] + #[Before('authz:cookie')] #[UrlFormat('forum-topic', '/forum/topics/<topic>', ['page' => '<page>'], '<topic_fragment>')] public function getTopic(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed { $isNuked = $deleted = $canDeleteAny = false; @@ -155,25 +157,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource { } #[PatternRoute('DELETE', '/forum/topics/([0-9]+)')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[UrlFormat('forum-topic-delete', '/forum/topics/<topic>')] public function deleteTopic(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed { - if(!$this->authInfo->loggedIn) - return 401; - - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) - return 403; - $response->setHeader('X-CSRF-Token', CSRF::token()); - - if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) { - $response->statusCode = 403; - return [ - 'error' => [ - 'name' => 'user:banned', - 'text' => "You aren't allowed to do that while banned.", - ], - ]; - } - try { $topic = $this->forumCtx->topics->getTopic( topicId: $topicId, @@ -269,25 +257,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource { } #[PatternRoute('POST', '/forum/topics/([0-9]+)/restore')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[UrlFormat('forum-topic-restore', '/forum/topics/<topic>/restore')] public function postTopicRestore(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed { - if(!$this->authInfo->loggedIn) - return 401; - - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) - return 403; - $response->setHeader('X-CSRF-Token', CSRF::token()); - - if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) { - $response->statusCode = 403; - return [ - 'error' => [ - 'name' => 'user:banned', - 'text' => "You aren't allowed to do that while banned.", - ], - ]; - } - try { $topic = $this->forumCtx->topics->getTopic( topicId: $topicId, @@ -343,25 +317,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource { } #[PatternRoute('POST', '/forum/topics/([0-9]+)/nuke')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[UrlFormat('forum-topic-nuke', '/forum/topics/<topic>/nuke')] public function postTopicNuke(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed { - if(!$this->authInfo->loggedIn) - return 401; - - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) - return 403; - $response->setHeader('X-CSRF-Token', CSRF::token()); - - if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) { - $response->statusCode = 403; - return [ - 'error' => [ - 'name' => 'user:banned', - 'text' => "You aren't allowed to do that while banned.", - ], - ]; - } - try { $topic = $this->forumCtx->topics->getTopic( topicId: $topicId, @@ -417,25 +377,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource { } #[PatternRoute('POST', '/forum/topics/([0-9]+)/bump')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[UrlFormat('forum-topic-bump', '/forum/topics/<topic>/bump')] public function postTopicBump(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed { - if(!$this->authInfo->loggedIn) - return 401; - - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) - return 403; - $response->setHeader('X-CSRF-Token', CSRF::token()); - - if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) { - $response->statusCode = 403; - return [ - 'error' => [ - 'name' => 'user:banned', - 'text' => "You aren't allowed to do that while banned.", - ], - ]; - } - try { $topic = $this->forumCtx->topics->getTopic( topicId: $topicId, @@ -491,25 +437,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource { } #[PatternRoute('POST', '/forum/topics/([0-9]+)/lock')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[UrlFormat('forum-topic-lock', '/forum/topics/<topic>/lock')] public function postTopicLock(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed { - if(!$this->authInfo->loggedIn) - return 401; - - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) - return 403; - $response->setHeader('X-CSRF-Token', CSRF::token()); - - if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) { - $response->statusCode = 403; - return [ - 'error' => [ - 'name' => 'user:banned', - 'text' => "You aren't allowed to do that while banned.", - ], - ]; - } - try { $topic = $this->forumCtx->topics->getTopic( topicId: $topicId, @@ -575,25 +507,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource { } #[PatternRoute('POST', '/forum/topics/([0-9]+)/unlock')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('csrf:header', type: 'json')] + #[Before('authz:banned', type: 'json')] #[UrlFormat('forum-topic-unlock', '/forum/topics/<topic>/unlock')] public function postTopicUnlock(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed { - if(!$this->authInfo->loggedIn) - return 401; - - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) - return 403; - $response->setHeader('X-CSRF-Token', CSRF::token()); - - if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) { - $response->statusCode = 403; - return [ - 'error' => [ - 'name' => 'user:banned', - 'text' => "You aren't allowed to do that while banned.", - ], - ]; - } - try { $topic = $this->forumCtx->topics->getTopic( topicId: $topicId, diff --git a/src/Home/HomeRoutes.php b/src/Home/HomeRoutes.php index df374802..96c3d55e 100644 --- a/src/Home/HomeRoutes.php +++ b/src/Home/HomeRoutes.php @@ -8,6 +8,7 @@ use Index\Colour\Colour; use Index\Db\{DbConnection,DbTools}; use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\ExactRoute; use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon}; use Misuzu\{Pagination,SiteInfo,Template}; @@ -200,6 +201,7 @@ class HomeRoutes implements RouteHandler, UrlSource { } #[ExactRoute('GET', '/')] + #[Before('authz:cookie')] #[UrlFormat('index', '/')] public function getIndex(): string { return $this->authInfo->loggedIn ? $this->getHome() : $this->getLanding(); diff --git a/src/Info/InfoRoutes.php b/src/Info/InfoRoutes.php index 2493d726..d6c108ab 100644 --- a/src/Info/InfoRoutes.php +++ b/src/Info/InfoRoutes.php @@ -4,6 +4,7 @@ namespace Misuzu\Info; use Index\Index; use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon}; use Misuzu\{Misuzu,Template}; @@ -22,12 +23,14 @@ class InfoRoutes implements RouteHandler, UrlSource { ]; #[ExactRoute('GET', '/info')] + #[Before('authz:cookie')] #[UrlFormat('info-index', '/info')] public function getIndex(): string { return Template::renderRaw('info.index'); } #[PatternRoute('GET', '/info/([A-Za-z0-9_]+)')] + #[Before('authz:cookie')] #[UrlFormat('info', '/info/<title>')] #[UrlFormat('info-doc', '/info/<title>')] public function getDocsPage(HttpResponseBuilder $response, HttpRequest $request, string $name): string { @@ -75,6 +78,7 @@ class InfoRoutes implements RouteHandler, UrlSource { } #[PatternRoute('GET', '/info/([A-Za-z0-9_]+)/([A-Za-z0-9_]+)')] + #[Before('authz:cookie')] #[UrlFormat('info-project-doc', '/info/<project>/<title>')] public function getProjectPage(HttpResponseBuilder $response, HttpRequest $request, string $project, string $name): int|string { if(!array_key_exists($project, self::PROJECT_PATHS)) diff --git a/src/Messages/MessagesRoutes.php b/src/Messages/MessagesRoutes.php index 6adc5d60..34c36056 100644 --- a/src/Messages/MessagesRoutes.php +++ b/src/Messages/MessagesRoutes.php @@ -10,11 +10,10 @@ use Index\Colour\Colour; use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder}; use Index\Http\Content\FormContent; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; -use Index\Http\Routing\Filters\PrefixFilter; use Index\Http\Routing\Processors\{After,Before}; use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; -use Misuzu\{CSRF,Misuzu,Pagination,Perm,Template}; +use Misuzu\{Misuzu,Pagination,Perm,Template}; use Misuzu\Auth\AuthInfo; use Misuzu\Parsers\TextFormat; use Misuzu\Perms\PermissionsData; @@ -36,39 +35,13 @@ class MessagesRoutes implements RouteHandler, UrlSource { private AuthInfo $authInfo, private MessagesContext $msgsCtx, private UsersContext $usersCtx, - private PermissionsData $perms + private PermissionsData $perms, ) {} - private bool $canSendMessages; - - /** @return void|int|array{error: array{name: string, text: string}} */ - #[PrefixFilter('/messages')] - public function checkAccess(HttpResponseBuilder $response, HttpRequest $request) { - // should probably be a permission or something too - if(!$this->authInfo->loggedIn) - return 401; - - // do not allow access to PMs when impersonating in production mode - if(!Misuzu::debug() && $this->authInfo->impersonating) - return 403; - - $globalPerms = $this->authInfo->getPerms('global'); - if(!$globalPerms->check(Perm::G_MESSAGES_VIEW)) - return 403; - - $this->canSendMessages = $globalPerms->check(Perm::G_MESSAGES_SEND) - && !$this->usersCtx->hasActiveBan($this->authInfo->userInfo); - - if($request->method === 'POST') { - if(!CSRF::validate($request->getHeaderLine('x-csrf-token'))) - return [ - 'error' => [ - 'name' => 'msgs:verify', - 'text' => 'Request verification failed! Refresh the page and try again.', - ], - ]; - - $response->setHeader('X-CSRF-Token', CSRF::token()); + private bool $canSendMessages { + get { + return $this->authInfo->getPerms('global')->check(Perm::G_MESSAGES_SEND) + && !$this->usersCtx->hasActiveBan($this->authInfo->userInfo); } } @@ -94,6 +67,9 @@ class MessagesRoutes implements RouteHandler, UrlSource { } #[ExactRoute('GET', '/messages')] + #[Before('authz:cookie', required: true)] + #[Before('authz:private')] + #[Before('authz:perm', category: 'global', perm: Perm::G_MESSAGES_VIEW)] #[UrlFormat('messages-index', '/messages', ['folder' => '<folder>', 'page' => '<page>'])] public function getIndex(HttpRequest $request, string $folderName = ''): int|string { $folderName = (string)$request->getParam('folder'); @@ -148,6 +124,9 @@ class MessagesRoutes implements RouteHandler, UrlSource { /** @return array{unread: int} */ #[ExactRoute('GET', '/messages/stats')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('authz:private', type: 'json')] + #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)] #[UrlFormat('messages-stats', '/messages/stats')] public function getStats(): array { $selfInfo = $this->authInfo->userInfo; @@ -173,6 +152,10 @@ class MessagesRoutes implements RouteHandler, UrlSource { * } */ #[ExactRoute('POST', '/messages/recipient')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('authz:private', type: 'json')] + #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)] + #[Before('csrf:header', type: 'json')] #[Before('input:urlencoded')] #[UrlFormat('messages-recipient', '/messages/recipient')] public function postRecipient(FormContent $content): int|array { @@ -211,6 +194,9 @@ class MessagesRoutes implements RouteHandler, UrlSource { } #[ExactRoute('GET', '/messages/compose')] + #[Before('authz:cookie', required: true)] + #[Before('authz:private')] + #[Before('authz:perm', category: 'global', perm: Perm::G_MESSAGES_VIEW)] #[UrlFormat('messages-compose', '/messages/compose', ['recipient' => '<recipient>'])] public function getEditor(HttpRequest $request): int|string { if(!$this->canSendMessages) @@ -222,6 +208,9 @@ class MessagesRoutes implements RouteHandler, UrlSource { } #[PatternRoute('GET', '/messages/([A-Za-z0-9]+)')] + #[Before('authz:cookie', required: true)] + #[Before('authz:private')] + #[Before('authz:perm', category: 'global', perm: Perm::G_MESSAGES_VIEW)] #[UrlFormat('messages-view', '/messages/<message>')] public function getView(string $messageId): int|string { if(strlen($messageId) !== 8) @@ -355,6 +344,10 @@ class MessagesRoutes implements RouteHandler, UrlSource { /** @return int|array{error: array{name: string, text: string}}|array{id: string, url: string} */ #[ExactRoute('POST', '/messages/create')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('authz:private', type: 'json')] + #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)] + #[Before('csrf:header', type: 'json')] #[Before('input:multipart')] #[UrlFormat('messages-create', '/messages/create')] public function postCreate(FormContent $content): int|array { @@ -457,6 +450,10 @@ class MessagesRoutes implements RouteHandler, UrlSource { /** @return int|array{error: array{name: string, text: string}}|array{id: string, url: string} */ #[PatternRoute('PATCH', '/messages/([A-Za-z0-9]+)')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('authz:private', type: 'json')] + #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)] + #[Before('csrf:header', type: 'json')] #[Before('input:multipart')] #[UrlFormat('messages-update', '/messages/<message>')] public function patchUpdate(FormContent $content, string $messageId): int|array { @@ -547,6 +544,10 @@ class MessagesRoutes implements RouteHandler, UrlSource { /** @return int|array{error: array{name: string, text: string}}|scalar[] */ #[ExactRoute('POST', '/messages/mark')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('authz:private', type: 'json')] + #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)] + #[Before('csrf:header', type: 'json')] #[Before('input:urlencoded')] #[UrlFormat('messages-mark', '/messages/mark')] public function postMark(FormContent $content): int|array { @@ -576,6 +577,10 @@ class MessagesRoutes implements RouteHandler, UrlSource { /** @return int|array{error: array{name: string, text: string}}|scalar[] */ #[ExactRoute('POST', '/messages/delete')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('authz:private', type: 'json')] + #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)] + #[Before('csrf:header', type: 'json')] #[Before('input:urlencoded')] #[UrlFormat('messages-delete', '/messages/delete')] public function postDelete(FormContent $content): int|array { @@ -600,6 +605,10 @@ class MessagesRoutes implements RouteHandler, UrlSource { /** @return int|array{error: array{name: string, text: string}}|scalar[] */ #[ExactRoute('POST', '/messages/restore')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('authz:private', type: 'json')] + #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)] + #[Before('csrf:header', type: 'json')] #[Before('input:urlencoded')] #[UrlFormat('messages-restore', '/messages/restore')] public function postRestore(FormContent $content) { @@ -624,6 +633,10 @@ class MessagesRoutes implements RouteHandler, UrlSource { /** @return int|array{error: array{name: string, text: string}}|scalar[] */ #[ExactRoute('POST', '/messages/nuke')] + #[Before('authz:cookie', type: 'json', required: true)] + #[Before('authz:private', type: 'json')] + #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)] + #[Before('csrf:header', type: 'json')] #[Before('input:urlencoded')] #[UrlFormat('messages-nuke', '/messages/nuke')] public function postNuke(FormContent $content) { diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 6b1e3d58..a6946bed 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -7,11 +7,13 @@ use Index\Config\Db\DbConfig; use Index\Db\DbConnection; use Index\Db\Migration\{DbMigrationManager,DbMigrationRepo,FsDbMigrationRepo}; use Index\Http\HttpRequest; +use Index\Http\Routing\RouteHandler; use Index\Snowflake\{BinarySnowflake,RandomSnowflake,SnowflakeGenerator}; use Index\Templating\TplEnvironment; use Index\Urls\UrlRegistry; use Misuzu\Routing\RoutingContext; use Misuzu\Users\UserInfo; +use Twig\Extension\ExtensionInterface; class MisuzuContext { public private(set) Dependencies $deps; @@ -28,6 +30,7 @@ class MisuzuContext { public private(set) Counters\CountersData $counters; public private(set) News\NewsData $news; + public private(set) CsrfContext $csrfCtx; public private(set) DatabaseContext $dbCtx; public private(set) Apps\AppsContext $appsCtx; public private(set) Auth\AuthContext $authCtx; @@ -95,6 +98,10 @@ class MisuzuContext { )); $this->deps->register($this->coloursCtx = $this->deps->constructLazy(Colours\ColoursContext::class)); $this->deps->register($this->commentsCtx = $this->deps->constructLazy(Comments\CommentsContext::class)); + $this->deps->register($this->csrfCtx = $this->deps->constructLazy( + CsrfContext::class, + config: $this->config->scopeTo('csrf'), + )); $this->deps->register($this->emotesCtx = $this->deps->constructLazy(Emoticons\EmotesContext::class)); $this->deps->register($this->forumCtx = $this->deps->constructLazy( Forum\ForumContext::class, @@ -163,6 +170,11 @@ class MisuzuContext { cache: $isDebug || !$cache ? null : ['Misuzu', GitInfo::hash(true)], debug: $isDebug ); + + $exts = $this->deps->all(ExtensionInterface::class); + foreach($exts as $ext) + $this->templating->addExtension($ext); + $this->templating->addExtension($this->deps->construct(TemplatingExtension::class)); $this->templating->addGlobal('globals', $globals); @@ -175,7 +187,10 @@ class MisuzuContext { $routingCtx = $this->deps->construct(RoutingContext::class); $this->deps->register($this->urls = $routingCtx->urls); - $routingCtx->register($this->logsCtx); + + $handlers = $this->deps->all(RouteHandler::class); + foreach($handlers as $handler) + $routingCtx->register($handler); if(in_array('main', $roles)) $this->registerMainRoutes($routingCtx); @@ -200,7 +215,7 @@ class MisuzuContext { $routingCtx->register($this->deps->constructLazy(WebFinger\WebFingerRoutes::class)); $routingCtx->register($this->deps->constructLazy( - Auth\AuthApiRoutes::class, + Auth\AuthProcessors::class, impersonateConfig: $this->config->scopeTo('impersonate') )); $routingCtx->register($this->deps->constructLazy(Colours\ColoursApiRoutes::class)); diff --git a/src/News/NewsRoutes.php b/src/News/NewsRoutes.php index f4805bee..bf1a4837 100644 --- a/src/News/NewsRoutes.php +++ b/src/News/NewsRoutes.php @@ -5,6 +5,7 @@ use RuntimeException; use Index\Colour\Colour; use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; use Index\Syndication\FeedBuilder; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; @@ -100,6 +101,7 @@ class NewsRoutes implements RouteHandler, UrlSource { } #[ExactRoute('GET', '/news')] + #[Before('authz:cookie')] #[UrlFormat('news-index', '/news', ['p' => '<page>'])] public function getIndex(HttpResponseBuilder $response, HttpRequest $request): int|string { $categories = $this->news->getCategories(hidden: false); @@ -143,6 +145,7 @@ class NewsRoutes implements RouteHandler, UrlSource { } #[PatternRoute('GET', '/news/post/([0-9]+)')] + #[Before('authz:cookie')] #[UrlFormat('news-post', '/news/post/<post>')] #[UrlFormat('news-post-comments', '/news/post/<post>', fragment: 'comments')] public function getPost(HttpResponseBuilder $response, HttpRequest $request, string $postId): int|string { diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php index da32e3a6..ecac8aad 100644 --- a/src/OAuth2/OAuth2ApiRoutes.php +++ b/src/OAuth2/OAuth2ApiRoutes.php @@ -193,51 +193,17 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { */ #[PatternRoute('POST', '/oauth2/request-authori[sz]e')] #[Before('input:urlencoded', required: false)] + #[Before('authz:basic')] #[UrlFormat('oauth2-request-authorise', '/oauth2/request-authorize')] public function postRequestAuthorise(HttpResponseBuilder $response, HttpRequest $request, ?FormContent $content): array { - $response->setHeader('Cache-Control', 'no-store'); - if($content === null) return self::filter($response, $request, [ 'error' => 'invalid_request', 'error_description' => 'Your request must use content type application/x-www-form-urlencoded.', ]); - $authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization')); - if(strcasecmp($authzHeader[0], 'Basic') === 0) { - $authzHeader = explode(':', base64_decode($authzHeader[1] ?? '')); - $clientId = $authzHeader[0]; - $clientSecret = $authzHeader[1] ?? ''; - } elseif($authzHeader[0] !== '') { - return self::filter($response, $request, [ - 'error' => 'invalid_client', - 'error_description' => 'You must use the Basic method for Authorization parameters.', - ], authzHeader: true); - } else { - $clientId = (string)$content->getParam('client_id'); - $clientSecret = ''; - } - - try { - $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false); - } catch(RuntimeException $ex) { - return self::filter($response, $request, [ - 'error' => 'invalid_client', - 'error_description' => 'No application has been registered with this client ID.', - ], authzHeader: $authzHeader[0] !== ''); - } - - if($clientSecret !== '') { - // TODO: rate limiting - if(!$appInfo->verifyClientSecret($clientSecret)) - return self::filter($response, $request, [ - 'error' => 'invalid_client', - 'error_description' => 'Provided client secret is not correct for this application.', - ], authzHeader: true); - } - return self::filter($response, $request, $this->oauth2Ctx->createDeviceAuthorisationRequest( - $appInfo, + $this->authInfo->appInfo, $content->hasParam('scope') ? (string)$content->getParam('scope') : null )); } @@ -254,81 +220,40 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { #[AccessControl(allowHeaders: ['Authorization'], exposeHeaders: ['WWW-Authenticate'])] #[ExactRoute('POST', '/oauth2/token')] #[Before('input:urlencoded', required: false)] + #[Before('authz:basic', bodySecret: true)] #[UrlFormat('oauth2-token', '/oauth2/token')] public function postToken(HttpResponseBuilder $response, HttpRequest $request, ?FormContent $content): array { - $response->setHeader('Cache-Control', 'no-store'); - if($content === null) return self::filter($response, $request, [ 'error' => 'invalid_request', 'error_description' => 'Your request must use content type application/x-www-form-urlencoded.', ]); - // authz header should be the preferred method - $authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization')); - if(strcasecmp($authzHeader[0], 'Basic') === 0) { - $authzHeader = explode(':', base64_decode($authzHeader[1] ?? '')); - $clientId = $authzHeader[0]; - $clientSecret = $authzHeader[1] ?? ''; - } elseif($authzHeader[0] !== '') { - return self::filter($response, $request, [ - 'error' => 'invalid_client', - 'error_description' => 'You must either use the Basic method for Authorization or use the client_id and client_secret parameters.', - ], authzHeader: true); - } else { - $clientId = (string)$content->getParam('client_id'); - $clientSecret = (string)$content->getParam('client_secret'); - } - - try { - $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false); - } catch(RuntimeException $ex) { - return self::filter($response, $request, [ - 'error' => 'invalid_client', - 'error_description' => 'No application has been registered with this client ID.', - ], authzHeader: $authzHeader[0] !== ''); - } - - $isAuthed = false; - if($clientSecret !== '') { - // TODO: rate limiting - $isAuthed = $appInfo->verifyClientSecret($clientSecret); - if(!$isAuthed) - return self::filter($response, $request, [ - 'error' => 'invalid_client', - 'error_description' => 'Provided client secret is not correct for this application.', - ], authzHeader: $authzHeader[0] !== ''); - } - $type = (string)$content->getParam('grant_type'); if($type === 'authorization_code') return self::filter($response, $request, $this->oauth2Ctx->redeemAuthorisationCode( - $appInfo, - $isAuthed, + $this->authInfo->appInfo, (string)$content->getParam('code'), (string)$content->getParam('code_verifier') )); if($type === 'refresh_token') return self::filter($response, $request, $this->oauth2Ctx->redeemRefreshToken( - $appInfo, - $isAuthed, + $this->authInfo->appInfo, (string)$content->getParam('refresh_token'), $content->hasParam('scope') ? (string)$content->getParam('scope') : null )); if($type === 'client_credentials') return self::filter($response, $request, $this->oauth2Ctx->redeemClientCredentials( - $appInfo, - $isAuthed, + $this->authInfo->appInfo, $content->hasParam('scope') ? (string)$content->getParam('scope') : null )); if($type === 'urn:ietf:params:oauth:grant-type:device_code' || $type === 'device_code') return self::filter($response, $request, $this->oauth2Ctx->redeemDeviceCode( - $appInfo, - $isAuthed, + $this->authInfo->appInfo, (string)$content->getParam('device_code') )); @@ -362,17 +287,9 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { */ #[AccessControl(allowHeaders: ['Authorization'], exposeHeaders: ['WWW-Authenticate'])] #[ExactRoute('GET', '/oauth2/userinfo')] + #[Before('authz:bearer')] #[UrlFormat('oauth2-openid-userinfo', '/oauth2/userinfo')] public function getUserInfo(HttpResponseBuilder $response, HttpRequest $request): array { - if(!$this->authInfo->loggedInBearer) { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Bearer authentication must be used."'); - return [ - 'error' => 'invalid_token', - 'error_description' => 'Bearer authentication must be used.', - ]; - } - if(!$this->authInfo->hasScope('openid')) { $response->statusCode = 403; $response->setHeader('WWW-Authenticate', 'Bearer error="insufficient_scope", error_description="openid scope is required for this endpoint."'); diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php index daedfec5..9441b721 100644 --- a/src/OAuth2/OAuth2Context.php +++ b/src/OAuth2/OAuth2Context.php @@ -241,7 +241,7 @@ class OAuth2Context { * refresh_token?: string, * }|array{ error: string, error_description: string } */ - public function redeemAuthorisationCode(AppInfo $appInfo, bool $isAuthed, string $code, string $codeVerifier): array { + public function redeemAuthorisationCode(AppInfo $appInfo, string $code, string $codeVerifier): array { try { $authsInfo = $this->authorisations->getAuthorisationInfo( appInfo: $appInfo, @@ -294,7 +294,7 @@ class OAuth2Context { * refresh_token?: string, * }|array{ error: string, error_description: string } */ - public function redeemRefreshToken(AppInfo $appInfo, bool $isAuthed, string $refreshToken, ?string $scope = null): array { + public function redeemRefreshToken(AppInfo $appInfo, string $refreshToken, ?string $scope = null): array { try { $refreshInfo = $this->tokens->getRefreshInfo($refreshToken, OAuth2RefreshInfoGetField::Token); } catch(RuntimeException $ex) { @@ -355,17 +355,12 @@ class OAuth2Context { * refresh_token?: string, * }|array{ error: string, error_description: string } */ - public function redeemClientCredentials(AppInfo $appInfo, bool $isAuthed, ?string $scope = null): array { + public function redeemClientCredentials(AppInfo $appInfo, ?string $scope = null): array { if(!$appInfo->confidential) return [ 'error' => 'unauthorized_client', 'error_description' => 'This application is not allowed to use this grant type.', ]; - if(!$isAuthed) - return [ - 'error' => 'invalid_client', - 'error_description' => 'Application must authenticate with client secret in order to use this grant type.', - ]; $requestedScope = $scope; $scope = $this->checkAndBuildScopeString($appInfo, $requestedScope, true); @@ -392,7 +387,7 @@ class OAuth2Context { * refresh_token?: string, * }|array{ error: string, error_description: string } */ - public function redeemDeviceCode(AppInfo $appInfo, bool $isAuthed, string $deviceCode): array { + public function redeemDeviceCode(AppInfo $appInfo, string $deviceCode): array { try { $deviceInfo = $this->devices->getDeviceInfo( appInfo: $appInfo, diff --git a/src/OAuth2/OAuth2WebRoutes.php b/src/OAuth2/OAuth2WebRoutes.php index 5033b24c..6e944bc9 100644 --- a/src/OAuth2/OAuth2WebRoutes.php +++ b/src/OAuth2/OAuth2WebRoutes.php @@ -10,7 +10,7 @@ use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; -use Misuzu\{CSRF,Template}; +use Misuzu\{CsrfContext,Template}; use Misuzu\Auth\AuthInfo; use Misuzu\Users\UsersContext; @@ -24,6 +24,7 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource { private OAuth2Context $oauth2Ctx, private UsersContext $usersCtx, private UrlRegistry $urls, + private CsrfContext $csrfCtx, private AuthInfo $authInfo, ) {} @@ -48,10 +49,10 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource { public function postAuthorise(HttpResponseBuilder $response, HttpRequest $request, FormContent $content): array { // TODO: RATE LIMITING - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) + if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token'))) return ['error' => 'csrf']; - $response->setHeader('X-CSRF-Token', CSRF::token()); + $response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken()); if(!$this->authInfo->loggedIn) return ['error' => 'auth']; @@ -174,10 +175,10 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource { public function getResolveAuthorise(HttpResponseBuilder $response, HttpRequest $request): array { // TODO: RATE LIMITING - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) + if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token'))) return ['error' => 'csrf']; - $response->setHeader('X-CSRF-Token', CSRF::token()); + $response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken()); if(!$this->authInfo->loggedIn) return ['error' => 'auth']; @@ -236,7 +237,7 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource { 'name' => $this->authInfo->realUserInfo->name, 'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo), 'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]), - 'revert_uri' => $this->urls->format('auth-revert', ['csrf' => CSRF::token()]), + 'revert_uri' => $this->urls->format('auth-revert', ['csrf' => $this->csrfCtx->createToken()]), 'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]), ]; @@ -263,10 +264,10 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource { public function postVerify(HttpResponseBuilder $response, HttpRequest $request, FormContent $content): array { // TODO: RATE LIMITING - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) + if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token'))) return ['error' => 'csrf']; - $response->setHeader('X-CSRF-Token', CSRF::token()); + $response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken()); if(!$this->authInfo->loggedIn) return ['error' => 'auth']; @@ -360,10 +361,10 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource { public function getResolveVerify(HttpResponseBuilder $response, HttpRequest $request) { // TODO: RATE LIMITING - if(!CSRF::validate($request->getHeaderLine('X-CSRF-token'))) + if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token'))) return ['error' => 'csrf']; - $response->setHeader('X-CSRF-Token', CSRF::token()); + $response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken()); if(!$this->authInfo->loggedIn) return ['error' => 'auth']; @@ -424,7 +425,7 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource { 'name' => $this->authInfo->realUserInfo->name, 'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo), 'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]), - 'revert_uri' => $this->urls->format('auth-revert', ['csrf' => CSRF::token()]), + 'revert_uri' => $this->urls->format('auth-revert', ['csrf' => $this->csrfCtx->createToken()]), 'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]), ]; diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php index 128f13ce..7d6de6df 100644 --- a/src/SharpChat/SharpChatRoutes.php +++ b/src/SharpChat/SharpChatRoutes.php @@ -64,6 +64,7 @@ final class SharpChatRoutes implements RouteHandler { } #[ExactRoute('GET', '/_sockchat/login')] + #[Before('authz:cookie')] public function getLogin(HttpResponseBuilder $response, HttpRequest $request): void { if(!$this->authInfo->loggedIn) { $response->redirect($this->urls->format('auth-login')); @@ -87,6 +88,7 @@ final class SharpChatRoutes implements RouteHandler { /** @return array{ok: false, err: string}|array{ok: true, usr: int, tkn: string} */ #[AccessControl(credentials: true)] #[ExactRoute('GET', '/_sockchat/token')] + #[Before('authz:cookie')] public function getToken(HttpRequest $request): array { $tokenInfo = $this->authInfo->tokenInfo; if(!$tokenInfo->hasSessionToken) diff --git a/src/Storage/Uploads/UploadsLegacyRoutes.php b/src/Storage/Uploads/UploadsLegacyRoutes.php index 62ddf963..952daedc 100644 --- a/src/Storage/Uploads/UploadsLegacyRoutes.php +++ b/src/Storage/Uploads/UploadsLegacyRoutes.php @@ -133,8 +133,11 @@ class UploadsLegacyRoutes implements RouteHandler, UrlSource { /** @return array<string, ?scalar> */ #[AccessControl(credentials: true, allowHeaders: ['Authorization'], exposeHeaders: ['X-EEPROM-Max-Size'])] - #[Before('input:multipart')] #[ExactRoute('POST', '/uploads')] + #[Before('authz:cookie', required: false)] + #[Before('authz:bearer', required: false)] + #[Before('authz:misuzu', required: false)] + #[Before('input:multipart')] public function postUpload(HttpResponseBuilder $response, HttpRequest $request, MultipartFormContent $content) { if(!$this->authInfo->loggedIn) { $response->statusCode = 401; @@ -302,6 +305,9 @@ class UploadsLegacyRoutes implements RouteHandler, UrlSource { /** @return int|array{error: string, english: string} */ #[AccessControl(credentials: true, allowHeaders: ['Authorization'])] #[PatternRoute('DELETE', '/uploads/([A-Za-z0-9]+|[A-Za-z0-9\-_]{32})(?:-([a-z0-9]+))?(?:\.([A-Za-z0-9\-_]+))?')] + #[Before('authz:cookie', required: false)] + #[Before('authz:bearer', required: false)] + #[Before('authz:misuzu', required: false)] public function deleteUpload(HttpResponseBuilder $response, HttpRequest $request, string $uploadId) { if(!$this->authInfo->loggedIn) { $response->statusCode = 401; diff --git a/src/TemplatingExtension.php b/src/TemplatingExtension.php index c320fcac..ef81f226 100644 --- a/src/TemplatingExtension.php +++ b/src/TemplatingExtension.php @@ -26,8 +26,6 @@ final class TemplatingExtension extends AbstractExtension { return [ new TwigFunction('asset', $this->assetInfo->getAssetUrl(...)), new TwigFunction('url', $this->ctx->urls->format(...)), - new TwigFunction('csrf_available', CSRF::available(...)), - new TwigFunction('csrf_token', CSRF::token(...)), new TwigFunction('git_commit_hash', GitInfo::hash(...)), new TwigFunction('git_tag', GitInfo::tag(...)), new TwigFunction('git_branch', GitInfo::branch(...)), @@ -190,7 +188,7 @@ final class TemplatingExtension extends AbstractExtension { $menu[] = [ 'title' => 'Log out', - 'url' => $this->ctx->urls->format('auth-logout', ['csrf' => CSRF::token()]), + 'url' => $this->ctx->urls->format('auth-logout', ['csrf' => $this->ctx->csrfCtx->createToken()]), 'icon' => 'fas fa-sign-out-alt fa-fw', ]; } else { diff --git a/src/Users/Assets/AssetsRoutes.php b/src/Users/Assets/AssetsRoutes.php index b2dc7ff3..393b5c0b 100644 --- a/src/Users/Assets/AssetsRoutes.php +++ b/src/Users/Assets/AssetsRoutes.php @@ -5,6 +5,7 @@ use InvalidArgumentException; use RuntimeException; use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; use Misuzu\{Misuzu,Perm}; @@ -33,6 +34,7 @@ class AssetsRoutes implements RouteHandler, UrlSource { /** @return void */ #[ExactRoute('GET', '/assets/avatar')] #[PatternRoute('GET', '/assets/avatar/([0-9]+)(?:\.[a-z]+)?')] + #[Before('authz:cookie')] #[UrlFormat('user-avatar', '/assets/avatar/<user>', ['res' => '<res>'])] public function getAvatar(HttpResponseBuilder $response, HttpRequest $request, string $userId = '') { $assetInfo = new StaticUserImageAsset(Misuzu::PATH_PUBLIC . '/images/no-avatar.png', Misuzu::PATH_PUBLIC); @@ -56,6 +58,7 @@ class AssetsRoutes implements RouteHandler, UrlSource { /** @return string|void */ #[ExactRoute('GET', '/assets/profile-background')] #[PatternRoute('GET', '/assets/profile-background/([0-9]+)(?:\.[a-z]+)?')] + #[Before('authz:cookie')] #[UrlFormat('user-background', '/assets/profile-background/<user>')] public function getProfileBackground(HttpResponseBuilder $response, HttpRequest $request, string $userId = '') { try { @@ -80,6 +83,7 @@ class AssetsRoutes implements RouteHandler, UrlSource { /** @return string|void */ #[ExactRoute('GET', '/user-assets.php')] + #[Before('authz:cookie')] public function getUserAssets(HttpResponseBuilder $response, HttpRequest $request) { $userId = (string)$request->getFilteredParam('u', FILTER_SANITIZE_NUMBER_INT); $mode = (string)$request->getParam('m'); diff --git a/src/Users/UsersApiRoutes.php b/src/Users/UsersApiRoutes.php index c219e48c..70ba2fa0 100644 --- a/src/Users/UsersApiRoutes.php +++ b/src/Users/UsersApiRoutes.php @@ -7,6 +7,7 @@ use Index\Colour\{Colour,ColourRgb}; use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; use Index\Http\Routing\AccessControl\AccessControl; +use Index\Http\Routing\Processors\Before; use Index\Http\Routing\Routes\ExactRoute; use Index\Urls\UrlRegistry; use Misuzu\{FieldTransformer,SiteInfo}; @@ -126,6 +127,9 @@ final class UsersApiRoutes implements RouteHandler { /** @return int|mixed[] */ #[AccessControl] #[ExactRoute('GET', '/api/v1/me')] + #[Before('authz:cookie', required: false)] + #[Before('authz:bearer', required: false)] + #[Before('authz:misuzu', required: false)] public function getMe(HttpResponseBuilder $response, HttpRequest $request): int|array { $response->setHeader('Cache-Control', 'no-store');