aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorwinter2024-12-16 20:27:01 +0000
committerwinter2024-12-16 20:41:33 +0000
commit68c7e68b3f90fd980661774fdb2bb2d6140a34d0 (patch)
tree7993de39c7c637369bdbfb22e265319b43fcbf33
parent2f4c1de3509141880df019ef1a6cc65981b3f6ea (diff)
implement the other side of auth (hopefully?)
also mostly untested for the same reason as last time
-rw-r--r--Digitigrade/HttpResponseStatus/Forbidden.php21
-rw-r--r--Digitigrade/HttpResponseStatus/Unauthorized.php21
-rw-r--r--Digitigrade/Job/ExpireInboundAuthToken.php34
-rw-r--r--Digitigrade/Job/RefreshOutboundAuthToken.php1
-rw-r--r--Digitigrade/Job/RequestOutboundAuthToken.php2
-rw-r--r--Digitigrade/Model/Instance.php14
-rw-r--r--Digitigrade/Model/InstanceAuth.php1
-rw-r--r--migrations/20241216_172853_add_instance_auth_secret.php12
-rw-r--r--misc/random_printable_bytes.php10
-rw-r--r--routes/auth.php73
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