Added interop endpoints for Hanyuu.
This commit is contained in:
parent
01c60e3027
commit
400253e04b
3 changed files with 232 additions and 1 deletions
12
src/CSRF.php
12
src/CSRF.php
|
@ -5,9 +5,19 @@ use Index\Security\CSRFP;
|
|||
|
||||
final class CSRF {
|
||||
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 {
|
||||
self::$instance = new CSRFP($secretKey, $identity);
|
||||
self::$instance = self::create($identity, $secretKey);
|
||||
}
|
||||
|
||||
public static function validate(string $token, int $tolerance = -1): bool {
|
||||
|
|
212
src/Hanyuu/HanyuuRoutes.php
Normal file
212
src/Hanyuu/HanyuuRoutes.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -278,6 +278,15 @@ class MisuzuContext {
|
|||
$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));
|
||||
|
||||
return $routingCtx;
|
||||
|
|
Loading…
Reference in a new issue