From b422b6ded608807c7d3bb8b965b3797ca513610b Mon Sep 17 00:00:00 2001 From: winter Sparkles Date: Sat, 8 Aug 2026 23:01:51 +0100 Subject: implement a large chunk of the auth system itself :D --- App/Router.php | 48 --------------- App/XMLResponse.php | 44 -------------- Psso/AuthFlow.php | 9 +++ Psso/AuthInterface/Password.php | 9 +++ Psso/AuthInterface/Totp.php | 9 +++ Psso/AuthProvider.php | 10 ++++ Psso/AuthProvider/ConfigFile.php | 16 +++++ Psso/AuthProvider/Dummy.php | 20 +++++++ Psso/Challenge.php | 59 +++++++++++++++++++ Psso/Challenge/Password.php | 32 ++++++++++ Psso/Challenge/Totp.php | 27 +++++++++ Psso/Challenge/Username.php | 16 +++++ Psso/ChallengeResult.php | 21 +++++++ Psso/Context.php | 13 +++++ Psso/Input.php | 42 ++++++++++++++ Psso/Router.php | 66 +++++++++++++++++++++ Psso/XMLResponse.php | 44 ++++++++++++++ auth-flow.conf.php | 28 +++++++++ config.ini | 19 ++++++ index.php | 50 ---------------- localisation.php | 14 +++++ routes/index.php | 2 +- routes/test-challenges.php | 67 +++++++++++++++++++++ static/style.css | 94 ------------------------------ templates/index.xsl | 12 +++- webroot/index.php | 63 ++++++++++++++++++++ webroot/static/style.css | 122 +++++++++++++++++++++++++++++++++++++++ 27 files changed, 718 insertions(+), 238 deletions(-) delete mode 100644 App/Router.php delete mode 100644 App/XMLResponse.php create mode 100644 Psso/AuthFlow.php create mode 100644 Psso/AuthInterface/Password.php create mode 100644 Psso/AuthInterface/Totp.php create mode 100644 Psso/AuthProvider.php create mode 100644 Psso/AuthProvider/ConfigFile.php create mode 100644 Psso/AuthProvider/Dummy.php create mode 100644 Psso/Challenge.php create mode 100644 Psso/Challenge/Password.php create mode 100644 Psso/Challenge/Totp.php create mode 100644 Psso/Challenge/Username.php create mode 100644 Psso/ChallengeResult.php create mode 100644 Psso/Context.php create mode 100644 Psso/Input.php create mode 100644 Psso/Router.php create mode 100644 Psso/XMLResponse.php create mode 100644 auth-flow.conf.php delete mode 100644 index.php create mode 100644 localisation.php create mode 100644 routes/test-challenges.php delete mode 100644 static/style.css create mode 100644 webroot/index.php create mode 100644 webroot/static/style.css diff --git a/App/Router.php b/App/Router.php deleted file mode 100644 index 42fd3e1..0000000 --- a/App/Router.php +++ /dev/null @@ -1,48 +0,0 @@ -routesDir = $routesDir; - $this->config = $config; - } - - function registerStatusHandler(int $status, callable $handler) { - $this->statusHandlers[$status] = $handler; - } - - private function dealWithError(int $status, string $path) { - http_response_code($status); - if (isset($this->statusHandlers[$status])) { - $this->statusHandlers[$status]($path); - } else { - echo '
' . $status . ' for ' . htmlspecialchars($path) . '
'; - } - } - - 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'])) { - $_SERVER['REQUEST_METHOD']($this->config); - } else { - $this->dealWithError(405, $path); - } - } -} diff --git a/App/XMLResponse.php b/App/XMLResponse.php deleted file mode 100644 index bdbf44b..0000000 --- a/App/XMLResponse.php +++ /dev/null @@ -1,44 +0,0 @@ -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); - } -} 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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 '' . $status . ' for ' . htmlspecialchars($path) . '
'; + } + } + + private function dealWithException(\Throwable $e) { + http_response_code(500); + if (isset($this->exceptionHandler)) { + ($this->exceptionHandler)($e); + } else { + echo 'Internal Server Error
'; + } + } + + 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 @@ +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); + } +} diff --git a/auth-flow.conf.php b/auth-flow.conf.php new file mode 100644 index 0000000..e9f6e61 --- /dev/null +++ b/auth-flow.conf.php @@ -0,0 +1,28 @@ +/* + in this file is where you can customise the authentication flow. + it's evaluated when a user goes to the login page. + you can access these variables: + - $context contains a Psso\Context object, describing what the user has + already done up to this point, and what's known about them + - $last contains the most recent ChallengeResult, or null if no challenges + were completed yet + you are expected to return either an array of Challenge subclasses, which + specifies what options the user has to continue (if you return multiple, they + will be presented in parallel for the user to choose just one to answer), or + an empty array, which indicates the auth flow has failed and cannot continue, + or the boolean 'true', which indicates the auth flow has succeeded and we can + trust that the user is who they say they are. +*/ + +use Psso\Challenge\{Username, Password, Totp}; + +if ($last?->successful) { + // unfortunately only Dummy provider supports Totp so far, therefore don't + //return match ($last->type) { + // Password::class => [Totp::class], + // Totp::class => true, + //}; + return true; +} + +return [$last?->type ?? Password::class]; diff --git a/config.ini b/config.ini index bb88a55..f5a083c 100644 --- a/config.ini +++ b/config.ini @@ -14,8 +14,27 @@ primary-domain = auth.example.com ;; this is required for AGPL compliance source-location = https://git.зима.net/winter/pleasant-sso/ + [integration] ;; what string to put on the front of header names destined for proxies ;; e.g. if this is 'X-Login', it will make headers like 'X-Login-User' header-prefix = X-Login + + +[auth] + +;; what AuthProvider to use - i.e. who to ask for users' information +;; ConfigFile -> look in this file, see below +provider = ConfigFile + + +;; this next section allows you to define users very simple here in the config +;; file, in case you don't want to use an external auth provider (set above) +;; but if you are using an external provider it's okay to remove all of this +[users] + +;;