Added interop endpoints for Hanyuu.

This commit is contained in:
flash 2024-07-20 19:35:50 +00:00
parent 01c60e3027
commit 400253e04b
3 changed files with 232 additions and 1 deletions

View file

@ -5,9 +5,19 @@ use Index\Security\CSRFP;
final class CSRF { final class CSRF {
private static CSRFP $instance; private static CSRFP $instance;
private static string $secretKey = '';
public static function create(string $identity, ?string $secretKey = null): CSRFP {
if($secretKey === null)
$secretKey = self::$secretKey;
else
self::$secretKey = $secretKey;
return new CSRFP($secretKey, $identity);
}
public static function init(string $secretKey, string $identity): void { public static function init(string $secretKey, string $identity): void {
self::$instance = new CSRFP($secretKey, $identity); self::$instance = self::create($identity, $secretKey);
} }
public static function validate(string $token, int $tolerance = -1): bool { public static function validate(string $token, int $tolerance = -1): bool {

212
src/Hanyuu/HanyuuRoutes.php Normal file
View file

@ -0,0 +1,212 @@
<?php
namespace Misuzu\Hanyuu;
use stdClass;
use RuntimeException;
use Index\Colour\Colour;
use Index\Http\Routing\{HttpGet,HttpMiddleware,HttpPost,RouteHandler};
use Index\Serialisation\UriBase64;
use Syokuhou\IConfig;
use Misuzu\CSRF;
use Misuzu\Auth\{AuthContext,AuthInfo,Sessions};
use Misuzu\URLs\URLRegistry;
use Misuzu\Users\{UsersContext,UserInfo};
final class HanyuuRoutes extends RouteHandler {
public function __construct(
private IConfig $config,
private IConfig $impersonateConfig, // this sucks lol
private URLRegistry $urls,
private UsersContext $usersCtx,
private AuthContext $authCtx,
private AuthInfo $authInfo
) {}
private function getEndpoint(): string {
return $this->config->getString('endpoint');
}
private function getSecret(): string {
return $this->config->getString('secret');
}
private static function createPayload(string $name, array $attrs = []): object {
$payload = new stdClass;
$payload->name = $name;
$payload->attrs = [];
foreach($attrs as $name => $value) {
if($value === null)
continue;
$payload->attrs[(string)$name] = $value;
}
return $payload;
}
private static function createErrorPayload(string $code, ?string $text = null): object {
$attrs = ['code' => $code];
if($text !== null && $text !== '')
$attrs['text'] = $text;
return self::createPayload('error', $attrs);
}
#[HttpMiddleware('/_hanyuu/(.*)')]
public function verifyRequest($response, $request, string $action) {
$userTime = (int)$request->getHeaderLine('X-Hanyuu-Timestamp');
$userHash = UriBase64::decode((string)$request->getHeaderLine('X-Hanyuu-Signature'));
$currentTime = time();
if(empty($userHash) || $userTime < $currentTime - 60 || $userTime > $currentTime + 60)
return self::createErrorPayload('verification', 'Request verification failed.');
$url = sprintf('%s/_hanyuu/%s', $this->getEndpoint(), $action);
$params = $request->getParamString();
if($params !== '')
$url .= sprintf('?%s', $params);
if($request->getMethod() === 'POST') {
if(!$request->isFormContent())
return self::createErrorPayload('request', 'Request body is not in expect format.');
$body = $request->getContent()->getParamString();
} elseif($request->getMethod() !== 'GET') {
return self::createErrorPayload('request', 'Only GET and POST methods are allowed.');
} else {
$body = '';
}
$verifyHash = hash_hmac(
'sha256',
sprintf('[%s|%s|%s]', $userTime, $url, $body),
$this->getSecret(),
true
);
if(!hash_equals($verifyHash, $userHash))
return self::createErrorPayload('verification', 'Request verification failed.');
}
private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool {
if($impersonator->isSuperUser())
return true;
return in_array(
$targetId,
$this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->getId())),
true
);
}
#[HttpPost('/_hanyuu/auth-check')]
public function postCheckAuth($response, $request) {
$content = $request->getContent();
$method = (string)$content->getParam('method');
if($method !== 'Misuzu')
return self::createErrorPayload('auth:check:method', 'Requested auth method is not supported.');
$remoteAddr = (string)$content->getParam('remote_addr');
if(filter_var($remoteAddr, FILTER_VALIDATE_IP) === false)
return self::createErrorPayload('auth:check:remote_addr', 'Provided remote address is not in a valid format.');
$avatarResolutions = trim((string)$content->getParam('avatars'));
if($avatarResolutions === '') {
$avatarResolutions = [];
} else {
$avatarResolutions = explode(',', $avatarResolutions);
foreach($avatarResolutions as $key => $avatarRes) {
if(!ctype_digit($avatarRes))
return self::createErrorPayload('auth:check:avatars', 'Avatar resolution set must be a comma separated set of numbers or empty.');
$avatarResolutions[$key] = (int)$avatarRes;
}
$avatarResolutions = array_unique($avatarResolutions);
}
$loginUrl = $this->getEndpoint() . $this->urls->format('auth-login');
$registerUrl = $this->getEndpoint() . $this->urls->format('auth-register');
$tokenPacker = $this->authCtx->createAuthTokenPacker();
$tokenInfo = $tokenPacker->unpack(trim((string)$content->getParam('token')));
if($tokenInfo->isEmpty())
return self::createPayload('auth:check:fail', ['reason' => 'empty', 'login_url' => $loginUrl, 'register_url' => $registerUrl]);
$sessions = $this->authCtx->getSessions();
try {
$sessionInfo = $sessions->getSession(sessionToken: $tokenInfo->getSessionToken());
} catch(RuntimeException $ex) {
return self::createPayload('auth:check:fail', ['reason' => 'session', 'login_url' => $loginUrl, 'register_url' => $registerUrl]);
}
if($sessionInfo->hasExpired()) {
$sessions->deleteSessions(sessionInfos: $sessionInfo);
return self::createPayload('auth:check:fail', ['reason' => 'expired', 'login_url' => $loginUrl, 'register_url' => $registerUrl]);
}
$sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $remoteAddr);
$users = $this->usersCtx->getUsers();
$userInfo = $userInfoReal = $users->getUser($sessionInfo->getUserId(), 'id');
if($tokenInfo->hasImpersonatedUserId() && $this->canImpersonateUserId($userInfo, $tokenInfo->getImpersonatedUserId())) {
try {
$userInfo = $users->getUser($tokenInfo->getImpersonatedUserId(), 'id');
} catch(RuntimeException $ex) {
$userInfo = $userInfoReal;
}
}
$response = [];
$response['session'] = [
'created_at' => $sessionInfo->getCreatedTime(),
'expires_at' => $sessionInfo->getExpiresTime(),
'lifetime_extends' => $sessionInfo->shouldBumpExpires(),
];
$banInfo = $this->usersCtx->tryGetActiveBan($userInfo);
if($banInfo !== null)
$response['ban'] = [
'severity' => $banInfo->getSeverity(),
'reason' => $banInfo->getPublicReason(),
'created_at' => $banInfo->getCreatedTime(),
'is_permanent' => $banInfo->isPermanent(),
'expires_at' => $banInfo->getExpiresTime(),
'duration_str' => $banInfo->getDurationString(),
'remaining_str' => $banInfo->getRemainingString(),
];
$gatherRequestedAvatars = function($userInfo) use ($avatarResolutions) {
$formatAvatarUrl = fn($res = 0) => (
$this->getEndpoint() . $this->urls->format('user-avatar', ['user' => $userInfo->getId(), 'res' => $res])
);
$avatars = ['original' => $formatAvatarUrl()];
foreach($avatarResolutions as $avatarRes)
$avatars[sprintf('x%d', $avatarRes)] = $formatAvatarUrl($avatarRes);
return $avatars;
};
$extractUserInfo = fn($userInfo) => [
'id' => $userInfo->getId(),
'name' => $userInfo->getName(),
'colour' => (string)$users->getUserColour($userInfo),
'rank' => $users->getUserRank($userInfo),
'is_super' => $userInfo->isSuperUser(),
'country_code' => $userInfo->getCountryCode(),
'is_deleted' => $userInfo->isDeleted(),
'has_totp' => $userInfo->hasTOTPKey(),
'profile_url' => $this->getEndpoint() . $this->urls->format('user-profile', ['user' => $userInfo->getId()]),
'avatars' => $gatherRequestedAvatars($userInfo),
];
$response['user'] = $extractUserInfo($userInfo);
if($userInfo !== $userInfoReal) {
$response['guise'] = $extractUserInfo($userInfoReal);
$csrfp = CSRF::create($sessionInfo->getToken());
$response['guise']['revert_url'] = $this->getEndpoint() . $this->urls->format('auth-revert', ['csrf' => $csrfp->createToken()]);
}
return self::createPayload('auth:check:success', $response);
}
}

View file

@ -278,6 +278,15 @@ class MisuzuContext {
$this->profileFields $this->profileFields
)); ));
$routingCtx->register(new \Misuzu\Hanyuu\HanyuuRoutes(
$this->config->scopeTo('hanyuu'),
$this->config->scopeTo('impersonate'),
$this->urls,
$this->usersCtx,
$this->authCtx,
$this->authInfo
));
$routingCtx->register(new LegacyRoutes($this->urls)); $routingCtx->register(new LegacyRoutes($this->urls));
return $routingCtx; return $routingCtx;