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 {
|
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
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
|
$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;
|
||||||
|
|
Loading…
Reference in a new issue