aboutsummaryrefslogtreecommitdiff
path: root/Psso
diff options
context:
space:
mode:
authorwinter Sparkles2026-08-08 23:01:51 +0100
committerwinter Sparkles2026-08-08 23:01:51 +0100
commitb422b6ded608807c7d3bb8b965b3797ca513610b (patch)
tree7925fa972efbfff2a9bf8648334d49e3fd6b5df1 /Psso
parent880a274e1ecbed42c76d3dc4a81161b3ae372726 (diff)
implement a large chunk of the auth system itself :D
Diffstat (limited to 'Psso')
-rw-r--r--Psso/AuthFlow.php9
-rw-r--r--Psso/AuthInterface/Password.php9
-rw-r--r--Psso/AuthInterface/Totp.php9
-rw-r--r--Psso/AuthProvider.php10
-rw-r--r--Psso/AuthProvider/ConfigFile.php16
-rw-r--r--Psso/AuthProvider/Dummy.php20
-rw-r--r--Psso/Challenge.php59
-rw-r--r--Psso/Challenge/Password.php32
-rw-r--r--Psso/Challenge/Totp.php27
-rw-r--r--Psso/Challenge/Username.php16
-rw-r--r--Psso/ChallengeResult.php21
-rw-r--r--Psso/Context.php13
-rw-r--r--Psso/Input.php42
-rw-r--r--Psso/Router.php66
-rw-r--r--Psso/XMLResponse.php44
15 files changed, 393 insertions, 0 deletions
diff --git a/Psso/AuthFlow.php b/Psso/AuthFlow.php
new file mode 100644
index 0000000..c2a68ac
--- /dev/null
+++ b/Psso/AuthFlow.php
@@ -0,0 +1,9 @@
+<?php
+namespace Psso;
+
+abstract class AuthFlow {
+ public static function nextStep(Context $context) {
+ $last = array_last($context->results);
+ return eval(file_get_contents(__DIR__ . '/../auth-flow.conf.php'));
+ }
+}
diff --git a/Psso/AuthInterface/Password.php b/Psso/AuthInterface/Password.php
new file mode 100644
index 0000000..d20d759
--- /dev/null
+++ b/Psso/AuthInterface/Password.php
@@ -0,0 +1,9 @@
+<?php
+namespace Psso\AuthInterface;
+
+interface Password {
+ public function validatePassword(
+ string $username,
+ #[\SensitiveParameter] string $password
+ ): bool;
+}
diff --git a/Psso/AuthInterface/Totp.php b/Psso/AuthInterface/Totp.php
new file mode 100644
index 0000000..5e09117
--- /dev/null
+++ b/Psso/AuthInterface/Totp.php
@@ -0,0 +1,9 @@
+<?php
+namespace Psso\AuthInterface;
+
+interface Totp {
+ public function validateTotp(
+ string $user,
+ #[\SensitiveParameter] string $otp
+ ): bool;
+}
diff --git a/Psso/AuthProvider.php b/Psso/AuthProvider.php
new file mode 100644
index 0000000..c608084
--- /dev/null
+++ b/Psso/AuthProvider.php
@@ -0,0 +1,10 @@
+<?php
+namespace Psso;
+
+abstract class AuthProvider {
+ protected array $config;
+
+ public function __construct(array $config) {
+ $this->config = $config;
+ }
+}
diff --git a/Psso/AuthProvider/ConfigFile.php b/Psso/AuthProvider/ConfigFile.php
new file mode 100644
index 0000000..0d855d0
--- /dev/null
+++ b/Psso/AuthProvider/ConfigFile.php
@@ -0,0 +1,16 @@
+<?php
+namespace Psso\AuthProvider;
+use Psso\{AuthProvider, AuthInterface};
+
+class ConfigFile extends AuthProvider implements AuthInterface\Password {
+ public function validatePassword(
+ string $username,
+ #[\SensitiveParameter] string $password
+ ): bool {
+ $hash = $this->config['users']["$username.password-hash"] ?? null;
+ if (!isset($hash)) {
+ return false;
+ }
+ return password_verify($password, $hash);
+ }
+}
diff --git a/Psso/AuthProvider/Dummy.php b/Psso/AuthProvider/Dummy.php
new file mode 100644
index 0000000..15d44ee
--- /dev/null
+++ b/Psso/AuthProvider/Dummy.php
@@ -0,0 +1,20 @@
+<?php
+namespace Psso\AuthProvider;
+use Psso\{AuthProvider, AuthInterface};
+
+class Dummy extends AuthProvider
+implements AuthInterface\Password, AuthInterface\Totp {
+ public function validatePassword(
+ string $username,
+ #[\SensitiveParameter] string $password
+ ): bool {
+ return $password == 'dummy';
+ }
+
+ public function validateTotp(
+ string $username,
+ #[\SensitiveParameter] string $otp
+ ): bool {
+ return $otp == '123456';
+ }
+}
diff --git a/Psso/Challenge.php b/Psso/Challenge.php
new file mode 100644
index 0000000..72100ce
--- /dev/null
+++ b/Psso/Challenge.php
@@ -0,0 +1,59 @@
+<?php
+namespace Psso;
+
+abstract class Challenge {
+ public protected(set) Context $context;
+ protected array $inputs = [];
+ public protected(set) string $serial;
+
+ protected function __construct(Context $context) {
+ $this->context = $context;
+ $this->serial = base64_encode(random_bytes(9));
+ }
+
+ public static function create(Context $context): ?static {
+ return new static($context);
+ }
+
+ public abstract function getInputs(): array;
+
+ public abstract function validate(
+ AuthProvider $provider, array $inputData
+ ): ChallengeResult;
+
+ public function addAsHtml(\SimpleXMLElement $parent): void {
+ $form = $parent->addChild('form');
+ $form->addAttribute('method', 'post');
+ foreach ($this->getInputs() as $input) {
+ $input->addAsHtml($form);
+ $this->inputs[$input->serial] = $input;
+ }
+ $serial = $form->addChild('input');
+ $serial->addAttribute('type', 'hidden');
+ $serial->addAttribute('name', 'challenge');
+ $serial->addAttribute('value', $this->serial);
+ $submit = $form->addChild('button', L('challenge.continue'));
+ }
+
+ public function findInput(string $serial): ?Input {
+ return $this->inputs[$serial] ?? null;
+ }
+
+ protected static function requireInterface(
+ AuthProvider $provider, string $interface
+ ): void {
+ if (!($provider instanceof $interface)) {
+ throw new \InvalidArgumentException(
+ "Provider must implement $interface"
+ );
+ };
+ }
+
+ protected static function requireKnownUser(Context $context): void {
+ if (!isset($context->user)) {
+ throw new \RuntimeException(
+ 'Unknown user, but known user required for ' . static::class
+ );
+ }
+ }
+}
diff --git a/Psso/Challenge/Password.php b/Psso/Challenge/Password.php
new file mode 100644
index 0000000..c20f5e2
--- /dev/null
+++ b/Psso/Challenge/Password.php
@@ -0,0 +1,32 @@
+<?php
+namespace Psso\Challenge;
+
+use Psso\{Challenge, ChallengeResult, Input, AuthProvider, AuthInterface};
+
+class Password extends Challenge {
+ public function getInputs(): array {
+ if (isset($this->context->user)) {
+ return [
+ new Input('password', Input::SECRET, 'challenge.input.password')
+ ];
+ }
+ return [
+ new Input('username', Input::TEXT, 'challenge.input.username'),
+ new Input('password', Input::SECRET, 'challenge.input.password'),
+ ];
+ }
+
+ public function validate(
+ AuthProvider $provider, array $inputData
+ ): ChallengeResult {
+ self::requireInterface($provider, AuthInterface\Password::class);
+ $successful = $provider->validatePassword(
+ $inputData['username'] ?? $this->context->user,
+ $inputData['password']
+ );
+ return new ChallengeResult(
+ self::class, $successful, $inputData['username'] ?? null,
+ $successful ? null : 'challenge.message.wrong-password'
+ );
+ }
+}
diff --git a/Psso/Challenge/Totp.php b/Psso/Challenge/Totp.php
new file mode 100644
index 0000000..4fd47f4
--- /dev/null
+++ b/Psso/Challenge/Totp.php
@@ -0,0 +1,27 @@
+<?php
+namespace Psso\Challenge;
+use Psso\{Challenge, AuthProvider, ChallengeResult, Context, Input, AuthInterface};
+
+class Totp extends Challenge {
+ public static function create(Context $context): static {
+ self::requireKnownUser($context);
+ return new self($context);
+ }
+
+ public function getInputs(): array {
+ return [new Input('otp', Input::NUMERIC, 'challenge.input.otp')];
+ }
+
+ public function validate(
+ AuthProvider $provider, array $inputData
+ ): ChallengeResult {
+ self::requireInterface($provider, AuthInterface\Totp::class);
+ $success = $provider->validateTotp(
+ $this->context->user, $inputData['otp']
+ );
+ return new ChallengeResult(
+ self::class, $success, null,
+ $success ? null : 'challenge.message.wrong-otp'
+ );
+ }
+}
diff --git a/Psso/Challenge/Username.php b/Psso/Challenge/Username.php
new file mode 100644
index 0000000..43de085
--- /dev/null
+++ b/Psso/Challenge/Username.php
@@ -0,0 +1,16 @@
+<?php
+namespace Psso\Challenge;
+use Psso\{Challenge, AuthProvider, ChallengeResult, Input};
+
+/** Just asks for a username and sets it in the context. Always succeeds. */
+class Username extends Challenge {
+ public function getInputs(): array {
+ return [new Input('user', Input::TEXT, 'challenge.input.username')];
+ }
+
+ public function validate(
+ AuthProvider $provider, array $inputData
+ ): ChallengeResult {
+ return new ChallengeResult(self::class, true, $inputData['user'], null);
+ }
+}
diff --git a/Psso/ChallengeResult.php b/Psso/ChallengeResult.php
new file mode 100644
index 0000000..f8ced88
--- /dev/null
+++ b/Psso/ChallengeResult.php
@@ -0,0 +1,21 @@
+<?php
+namespace Psso;
+
+class ChallengeResult {
+ public protected(set) string $type;
+ public protected(set) bool $successful;
+ public protected(set) ?string $user;
+ public protected(set) ?string $message;
+
+ public function __construct(
+ string $type,
+ bool $successful,
+ ?string $user = null,
+ ?string $message = null
+ ) {
+ $this->type = $type;
+ $this->successful = $successful;
+ $this->user = $user;
+ $this->message = $message;
+ }
+}
diff --git a/Psso/Context.php b/Psso/Context.php
new file mode 100644
index 0000000..4a2cd82
--- /dev/null
+++ b/Psso/Context.php
@@ -0,0 +1,13 @@
+<?php
+namespace Psso;
+
+class Context {
+ public protected(set) array $results = [];
+ public ?string $user = null;
+ public protected(set) array $groups = [];
+ public protected(set) array $tags = [];
+
+ public function addResult(ChallengeResult $result): void {
+ $this->results[] = $result;
+ }
+}
diff --git a/Psso/Input.php b/Psso/Input.php
new file mode 100644
index 0000000..019d79f
--- /dev/null
+++ b/Psso/Input.php
@@ -0,0 +1,42 @@
+<?php
+namespace Psso;
+
+class Input {
+ // input-type constants that map to their HTML equivalents
+ /** Plain text. */
+ public const string TEXT = 'text';
+ /** Hidden/masked text. */
+ public const string SECRET = 'password';
+ /** Integer. */
+ public const string NUMERIC = 'number';
+
+ public protected(set) string $id;
+ public protected(set) string $type;
+ public protected(set) string $label;
+ public protected(set) bool $required;
+ public private(set) string $serial;
+
+ public function __construct(
+ string $id, string $type, string $label, bool $required = true
+ ) {
+ $this->id = $id;
+ $this->type = $type;
+ $this->label = $label;
+ $this->required = $required;
+ $this->serial = base64_encode(random_bytes(9));
+ }
+
+ private function getUniqueName(): string {
+ return $this->id . '__' . $this->serial;
+ }
+
+ public function addAsHtml(\SimpleXMLElement $parent): void {
+ $label = $parent->addChild('label', L($this->label));
+ $label->addAttribute('for', 'input-' . $this->getUniqueName());
+ $input = $parent->addChild('input');
+ $input->addAttribute('type', $this->type);
+ $input->addAttribute('name', $this->getUniqueName());
+ $input->addAttribute('id', 'input-' . $this->getUniqueName());
+ if ($this->required) $input->addAttribute('required', '');
+ }
+}
diff --git a/Psso/Router.php b/Psso/Router.php
new file mode 100644
index 0000000..db1f851
--- /dev/null
+++ b/Psso/Router.php
@@ -0,0 +1,66 @@
+<?php
+namespace Psso;
+
+class Router {
+ private array $config;
+ private string $routesDir;
+ private array $statusHandlers = [];
+ private \Closure $exceptionHandler;
+
+ function __construct(string $routesDir, array $config) {
+ $this->routesDir = $routesDir;
+ $this->config = $config;
+ }
+
+ function registerStatusHandler(int $status, callable $handler) {
+ $this->statusHandlers[$status] = $handler;
+ }
+
+ function setExceptionHandler(callable $handler) {
+ $this->exceptionHandler = \Closure::fromCallable($handler);
+ }
+
+ private function dealWithError(int $status, string $path) {
+ http_response_code($status);
+ if (isset($this->statusHandlers[$status])) {
+ $this->statusHandlers[$status]($path);
+ } else {
+ echo '<p>' . $status . ' for ' . htmlspecialchars($path) . '</p>';
+ }
+ }
+
+ private function dealWithException(\Throwable $e) {
+ http_response_code(500);
+ if (isset($this->exceptionHandler)) {
+ ($this->exceptionHandler)($e);
+ } else {
+ echo '<p>Internal Server Error</p>';
+ }
+ }
+
+ function dispatch(string $path) {
+ if (str_contains($path, '..')) {
+ $this->dealWithError(404, $path);
+ return;
+ }
+
+ if (file_exists($this->routesDir . $path . '.php')) {
+ require_once $this->routesDir . $path . '.php';
+ } elseif (file_exists($this->routesDir . $path . 'index.php')) {
+ require_once $this->routesDir . $path . 'index.php';
+ } else {
+ $this->dealWithError(404, $path);
+ return;
+ }
+
+ if (function_exists($_SERVER['REQUEST_METHOD'])) {
+ try {
+ $_SERVER['REQUEST_METHOD']($this->config);
+ } catch (\Exception|\Error $e) {
+ $this->dealWithException($e);
+ }
+ } else {
+ $this->dealWithError(405, $path);
+ }
+ }
+}
diff --git a/Psso/XMLResponse.php b/Psso/XMLResponse.php
new file mode 100644
index 0000000..1b17660
--- /dev/null
+++ b/Psso/XMLResponse.php
@@ -0,0 +1,44 @@
+<?php
+namespace Psso;
+
+class XMLResponse {
+ public \SimpleXMLElement $doc;
+ private static array $stylesheets;
+ private static array $runFirst;
+
+ static function addStylesheet(string $path) {
+ $sheet = \Dom\XMLDocument::createFromFile($path);
+ self::$stylesheets[] = $sheet;
+ }
+
+ static function addPreamble(callable $preamble) {
+ self::$runFirst[] = $preamble;
+ }
+
+ function __construct(string $rootElement = 'page') {
+ $this->doc = new \SimpleXMLElement("<$rootElement/>");
+ foreach (self::$runFirst as $fn) {
+ $fn($this->doc);
+ }
+ }
+
+ function sendRaw() {
+ header('Content-Type: application/xml');
+ echo $this->doc->asXML();
+ }
+
+ function send() {
+ if (isset($_GET['RawXML'])) {
+ $this->sendRaw();
+ return;
+ }
+
+ //header('Content-Type: application/xhtml+xml');
+ $processor = new \XSLTProcessor;
+ foreach (self::$stylesheets as $sheet) {
+ $processor->importStylesheet($sheet);
+ }
+ $root = \Dom\import_simplexml($this->doc);
+ echo $processor->transformToXml($root);
+ }
+}