diff options
| author | winter Sparkles | 2026-08-08 23:01:51 +0100 |
|---|---|---|
| committer | winter Sparkles | 2026-08-08 23:01:51 +0100 |
| commit | b422b6ded608807c7d3bb8b965b3797ca513610b (patch) | |
| tree | 7925fa972efbfff2a9bf8648334d49e3fd6b5df1 /Psso | |
| parent | 880a274e1ecbed42c76d3dc4a81161b3ae372726 (diff) | |
implement a large chunk of the auth system itself :D
Diffstat (limited to 'Psso')
| -rw-r--r-- | Psso/AuthFlow.php | 9 | ||||
| -rw-r--r-- | Psso/AuthInterface/Password.php | 9 | ||||
| -rw-r--r-- | Psso/AuthInterface/Totp.php | 9 | ||||
| -rw-r--r-- | Psso/AuthProvider.php | 10 | ||||
| -rw-r--r-- | Psso/AuthProvider/ConfigFile.php | 16 | ||||
| -rw-r--r-- | Psso/AuthProvider/Dummy.php | 20 | ||||
| -rw-r--r-- | Psso/Challenge.php | 59 | ||||
| -rw-r--r-- | Psso/Challenge/Password.php | 32 | ||||
| -rw-r--r-- | Psso/Challenge/Totp.php | 27 | ||||
| -rw-r--r-- | Psso/Challenge/Username.php | 16 | ||||
| -rw-r--r-- | Psso/ChallengeResult.php | 21 | ||||
| -rw-r--r-- | Psso/Context.php | 13 | ||||
| -rw-r--r-- | Psso/Input.php | 42 | ||||
| -rw-r--r-- | Psso/Router.php | 66 | ||||
| -rw-r--r-- | Psso/XMLResponse.php | 44 |
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); + } +} |
