diff options
| author | winter | 2024-12-16 20:27:01 +0000 |
|---|---|---|
| committer | winter | 2024-12-16 20:41:33 +0000 |
| commit | 68c7e68b3f90fd980661774fdb2bb2d6140a34d0 (patch) | |
| tree | 7993de39c7c637369bdbfb22e265319b43fcbf33 | |
| parent | 2f4c1de3509141880df019ef1a6cc65981b3f6ea (diff) | |
implement the other side of auth (hopefully?)
also mostly untested for the same reason as last time
| -rw-r--r-- | Digitigrade/HttpResponseStatus/Forbidden.php | 21 | ||||
| -rw-r--r-- | Digitigrade/HttpResponseStatus/Unauthorized.php | 21 | ||||
| -rw-r--r-- | Digitigrade/Job/ExpireInboundAuthToken.php | 34 | ||||
| -rw-r--r-- | Digitigrade/Job/RefreshOutboundAuthToken.php | 1 | ||||
| -rw-r--r-- | Digitigrade/Job/RequestOutboundAuthToken.php | 2 | ||||
| -rw-r--r-- | Digitigrade/Model/Instance.php | 14 | ||||
| -rw-r--r-- | Digitigrade/Model/InstanceAuth.php | 1 | ||||
| -rw-r--r-- | migrations/20241216_172853_add_instance_auth_secret.php | 12 | ||||
| -rw-r--r-- | misc/random_printable_bytes.php | 10 | ||||
| -rw-r--r-- | routes/auth.php | 73 |
10 files changed, 187 insertions, 2 deletions
diff --git a/Digitigrade/HttpResponseStatus/Forbidden.php b/Digitigrade/HttpResponseStatus/Forbidden.php new file mode 100644 index 0000000..4c3588f --- /dev/null +++ b/Digitigrade/HttpResponseStatus/Forbidden.php @@ -0,0 +1,21 @@ +<?php +namespace Digitigrade\HttpResponseStatus; + +use Digitigrade\HttpResponseStatus; + +class Forbidden extends \Exception implements HttpResponseStatus { + public function __construct(?string $reason) { + $this->message = $reason ?? 'The provided authorization is invalid for this request'; + } + + public function httpCode(): int { + return 403; + } + + public function httpReason(): string { + return "Forbidden"; + } + + public function httpHeaders() { + } +}
\ No newline at end of file diff --git a/Digitigrade/HttpResponseStatus/Unauthorized.php b/Digitigrade/HttpResponseStatus/Unauthorized.php new file mode 100644 index 0000000..ab8b080 --- /dev/null +++ b/Digitigrade/HttpResponseStatus/Unauthorized.php @@ -0,0 +1,21 @@ +<?php +namespace Digitigrade\HttpResponseStatus; + +use Digitigrade\HttpResponseStatus; + +class Unauthorized extends \Exception implements HttpResponseStatus { + public function __construct(?string $reason) { + $this->message = $reason ?? 'This request requires authorization'; + } + + public function httpCode(): int { + return 401; + } + + public function httpReason(): string { + return "Unauthorized"; + } + + public function httpHeaders() { + } +}
\ No newline at end of file diff --git a/Digitigrade/Job/ExpireInboundAuthToken.php b/Digitigrade/Job/ExpireInboundAuthToken.php new file mode 100644 index 0000000..dfb5ae6 --- /dev/null +++ b/Digitigrade/Job/ExpireInboundAuthToken.php @@ -0,0 +1,34 @@ +<?php +namespace Digitigrade\Job; + +use Digitigrade\Job; +use Digitigrade\JobQueue; +use Digitigrade\Logger; +use Digitigrade\Model\Instance; + +class ExpireInboundAuthToken extends Job { + public string $domain; + public string $token; + public \DateTimeInterface $when; + + public function __construct(string $domain, string $token, \DateTimeInterface $when) { + $this->domain = $domain; + $this->token = $token; + $this->when = $when; + $this->remainingTries = 5; + } + + public function run() { + Logger::getInstance()->info("expiring token for $this->domain"); + $instance = Instance::findByDomain($this->domain); + if ($instance->auth->inboundToken != $this->token) { + Logger::getInstance()->info("actually not expiring the token because it's been refreshed"); + return; + } + $instance->auth->inboundToken = null; + } + + public function submit() { + JobQueue::getInstance()->submitDelayedUntil($this, $this->when); + } +}
\ No newline at end of file diff --git a/Digitigrade/Job/RefreshOutboundAuthToken.php b/Digitigrade/Job/RefreshOutboundAuthToken.php index 588617f..72dd466 100644 --- a/Digitigrade/Job/RefreshOutboundAuthToken.php +++ b/Digitigrade/Job/RefreshOutboundAuthToken.php @@ -8,7 +8,6 @@ use Digitigrade\Model\Instance; class RefreshOutboundAuthToken extends Job { public string $domain; - public string $token; public \DateTimeInterface $when; public function __construct(string $domain, \DateTimeInterface $when) { diff --git a/Digitigrade/Job/RequestOutboundAuthToken.php b/Digitigrade/Job/RequestOutboundAuthToken.php index 69430c8..1786e32 100644 --- a/Digitigrade/Job/RequestOutboundAuthToken.php +++ b/Digitigrade/Job/RequestOutboundAuthToken.php @@ -17,7 +17,7 @@ class RequestOutboundAuthToken extends Job { } public function run() { - Logger::getInstance()->info("responding to $this->domain's dialback challenge"); + Logger::getInstance()->info("requesting token from $this->domain in response to their dialback challenge"); $instance = Instance::findByDomain($this->domain); $instance->requestOutboundAuthToken($this->secret); } diff --git a/Digitigrade/Model/Instance.php b/Digitigrade/Model/Instance.php index 5f33e6d..e4b1ef1 100644 --- a/Digitigrade/Model/Instance.php +++ b/Digitigrade/Model/Instance.php @@ -58,6 +58,20 @@ class Instance extends FetchableModel { return self::fetchFromRemote($uri, $autoSave); } + public static function findByInboundAuthToken(string $token): ?self { + $authRecord = InstanceAuth::findWhere('inbound_token = ?', [$token]); + if ($authRecord == null) + return null; + return self::find($authRecord->instanceId); + } + + public static function findByDialbackSecret(string $secret): ?self { + $authRecord = InstanceAuth::findWhere('secret = ?', [$secret]); + if ($authRecord == null) + return null; + return self::find($authRecord->instanceId); + } + public function beginOutboundAuth() { $authEndpoint = $this->endpoints->auth; if (!isset($authEndpoint)) { diff --git a/Digitigrade/Model/InstanceAuth.php b/Digitigrade/Model/InstanceAuth.php index 616d4c5..6f58298 100644 --- a/Digitigrade/Model/InstanceAuth.php +++ b/Digitigrade/Model/InstanceAuth.php @@ -7,6 +7,7 @@ class InstanceAuth extends Model { public ?int $instanceId; public ?string $outboundToken; public ?string $inboundToken; + public ?string $secret; protected function setOwnerId(int $id) { $this->instanceId = $id; diff --git a/migrations/20241216_172853_add_instance_auth_secret.php b/migrations/20241216_172853_add_instance_auth_secret.php new file mode 100644 index 0000000..83438fe --- /dev/null +++ b/migrations/20241216_172853_add_instance_auth_secret.php @@ -0,0 +1,12 @@ +<?php + +use \Digitigrade\Db\Migrator; + +Migrator::getInstance()->register(20241216_172853, function (PDO $db) { + // column for the secret used for dialback for inbound auth + // also making inbound auth column unique to prevent accidentally giving two instances the same token + $db->exec(<<<END + ALTER TABLE instance_auth ADD COLUMN secret text; + ALTER TABLE instance_auth ADD CONSTRAINT inbound_token_unq UNIQUE(inbound_token); + END); +}); diff --git a/misc/random_printable_bytes.php b/misc/random_printable_bytes.php new file mode 100644 index 0000000..d2b8770 --- /dev/null +++ b/misc/random_printable_bytes.php @@ -0,0 +1,10 @@ +<?php + +/** + * Generates a cryptographically secure random printable string + * @return string + */ +function random_printable_bytes(): string { + // i think 128 bytes should be enough .. i hope so anyway + return base64_encode(random_bytes(128)); +}
\ No newline at end of file diff --git a/routes/auth.php b/routes/auth.php index b44d76a..2dbe6f1 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,10 +1,82 @@ <?php +use Digitigrade\GlobalConfig; use Digitigrade\HttpResponseStatus\BadRequest; +use Digitigrade\HttpResponseStatus\Forbidden; +use Digitigrade\Job\ExpireInboundAuthToken; use Digitigrade\Job\RequestOutboundAuthToken; use Digitigrade\Model\Instance; use Digitigrade\Router; +Router::getInstance()->mount('/auth/main', function (array $args) { + if (!isset($_GET['phase'])) { + throw new BadRequest('you need to specify the `phase` GET parameter'); + } + switch ($_GET['phase']) { + + case 'dialback': + // send a secret to the dialback address! + if (!isset($_GET['target'])) { + throw new BadRequest('you need to specify the `target` GET parameter'); + } + $domain = explode('/', $_GET['target'])[2]; + $instance = Instance::findByDomain($domain); + if ($instance == null) { + throw new RuntimeException("i can't find your instance information, sorry"); + } + + $instance->auth->secret = random_printable_bytes(); + $instance->auth->save(); + // TODO: schedule a job to throw away the secret after some time if it's not used? + + $data = http_build_query([ + 'origin' => GlobalConfig::getInstance()->getCanonicalHost(), + 'secret' => $instance->auth->secret + ]); + $context = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => 'Content-Type: application/x-www-form-urlencoded', + 'content' => $data + ]]); + // TODO: (maybe?) put this in a job instead of doing it right away? + file_get_contents($_GET['target'], context: $context); + + http_response_code(202); // "Accepted" + break; + + case 'token': + // verify the secret, if it's good, issue a token + if (!isset($_GET['secret'])) { + throw new BadRequest('you need to specify the `secret` GET parameter'); + } + $instance = Instance::findByDialbackSecret($_GET['secret']); + if ($instance == null) { + throw new Forbidden('the provided secret is invalid!'); + } + + $instance->auth->secret = null; // it's been used now + $instance->auth->inboundToken = random_printable_bytes(); + $instance->auth->save(); + + $expiry = new \DateTimeImmutable('1 day'); // this seems like a reasonable length of time? + (new ExpireInboundAuthToken( + $instance->domain, + $instance->auth->inboundToken, + $expiry + ))->submit(); + + json_response([ + 'token' => $instance->auth->inboundToken, + 'expires' => $expiry->format('c') + ]); + + break; + + default: + throw new BadRequest('invalid phase'); + } +}); + Router::getInstance()->mount('/auth/dialback', function (array $args) { // just respond to the dialback challenge! if (!isset($_POST['origin'], $_POST['secret'])) { @@ -19,4 +91,5 @@ Router::getInstance()->mount('/auth/dialback', function (array $args) { } (new RequestOutboundAuthToken($instance->domain, $_POST['secret']))->submit(); + http_response_code(202); // "Accepted" });
\ No newline at end of file |
