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] + +;; .password-hash -> allow password login for user +;; password hash should be generated by php function 'password_hash' +winter.password-hash = "$2y$12$V3dwpbHF5fTx46g9xMflvODNGmr0apltiaDONUSE2skRrslgcRxSS" +;; more options to be added in future! diff --git a/index.php b/index.php deleted file mode 100644 index 3dee8eb..0000000 --- a/index.php +++ /dev/null @@ -1,50 +0,0 @@ -Server misconfiguration! couldn't figure out the request path

"; - exit; -} - -$path = explode('?', $path, 2)[0]; - -$config = parse_ini_file(__DIR__ . '/config.ini', true, INI_SCANNER_TYPED); - -App\XMLResponse::addStylesheet('templates/index.xsl'); -App\XMLResponse::addPreamble(function ($doc) use ($config) { - $doc->addAttribute('site-name', $config['site']['name']); - $doc->addAttribute('source', $config['site']['source-location']); -}); - -$router = new App\Router('routes', $config); - -$router->registerStatusHandler(404, function ($path) { - $resp = new App\XMLResponse; - $resp->doc->addAttribute('title', 'Not found'); - $resp->doc->addChild( - 'error', - "The requested page ($path) does not exist." - ); - $resp->send(); -}); - -$router->registerStatusHandler(405, function ($path) { - $method = $_SERVER['REQUEST_METHOD']; - $resp = new App\XMLResponse; - $resp->doc->addAttribute('title', 'Wrong method'); - $resp->doc->addChild( - 'error', - "Your request method ($method) is not valid for this path." - ); - $resp->send(); -}); - -$router->dispatch($path); diff --git a/localisation.php b/localisation.php new file mode 100644 index 0000000..0e193b0 --- /dev/null +++ b/localisation.php @@ -0,0 +1,14 @@ + 'Log in', + 'challenge.input.username' => 'Username', + 'challenge.input.password' => 'Password', + 'challenge.input.otp' => 'One-time passcode', + 'challenge.message.wrong-password' => 'Incorrect username or password', + 'challenge.message.wrong-otp' => 'Invalid OTP code', + 'challenge.continue' => 'Continue', + ][$key] ?? $key; +} diff --git a/routes/index.php b/routes/index.php index a5e66a8..685ab92 100644 --- a/routes/index.php +++ b/routes/index.php @@ -1,7 +1,7 @@ doc->addChild('content'); $content->addChild('p', 'Welcome to Pleasant SSO!'); $resp->send(); diff --git a/routes/test-challenges.php b/routes/test-challenges.php new file mode 100644 index 0000000..32aad7e --- /dev/null +++ b/routes/test-challenges.php @@ -0,0 +1,67 @@ +serial] = $c; + } + + $resp = new Psso\XMLResponse; + $resp->doc->addAttribute('title', L('login.title')); + if (isset($message)) { + $resp->doc->addChild('challenge-message', L($message)); + } + foreach ($challenges as $challenge) { + $challenge->addAsHtml($resp->doc); + } + $resp->send(); + // instead of just saving this to a file, it needs to be associated with the + // user's session somehow + file_put_contents('challenges-data', serialize($challenges)); +} + +function GET() { + $context = new Psso\Context; + presentChallenges($context); +} + +function POST(array $config) { + // we are receiving results of a previous challenge... load it in + $challenges = unserialize(file_get_contents('challenges-data')); + $answeredChallenge = $challenges[$_POST['challenge']]; + // match up the given input responses to their original Inputs + $inputData = []; + foreach ($_POST as $name => $value) { + if ($name == 'challenge') continue; + $serial = explode('__', $name, 2)[1]; + $input = $answeredChallenge->findInput($serial); + $inputData[$input->id] = $value; + } + + $providerClass = 'Psso\\AuthProvider\\' . $config['auth']['provider']; + $provider = new $providerClass($config); + $result = $answeredChallenge->validate($provider, $inputData); + + $context = $answeredChallenge->context; + $context->addResult($result); + + if ($result->successful) { + if (isset($result->user) && !isset($context->user)) { + $context->user = $result->user; + } + } + presentChallenges($context, $result->message); +} diff --git a/static/style.css b/static/style.css deleted file mode 100644 index 6e2b8c7..0000000 --- a/static/style.css +++ /dev/null @@ -1,94 +0,0 @@ -:root { - font-family: sans-serif; - color-scheme: light dark; - - /* theme colours */ - --primary: #055; - --secondary: #dee; - --negative: #a01; - --background: #eff; - --foreground: #022; - --behind: #dee; - --header-bg: var(--primary); - - @media (prefers-color-scheme: dark) { - --primary: #8cc; - --secondary: #122; - --negative: #e78; - --background: #233; - --foreground: #eff; - --behind: #011; - --header-bg: var(--secondary); - } - - /* dimensions */ - --page-width: 800px; - --is-wide: 1; - @media (width < 800px) { - --is-wide: 0; - } -} - -body { - margin: 0; - padding: 0; - - background-color: var(--behind); -} - -header { - background: linear-gradient( - to bottom, - color-mix(in oklab, var(--header-bg), var(--behind)), - var(--header-bg) - ); - color: contrast-color(var(--header-bg)); - - padding: 1em; - max-width: var(--page-width); - margin: calc(var(--is-wide) * 1em) auto; - height: 100px; - display: flex; - align-items: end; - - site-name { - font-weight: bold; - font-size: xx-large; - } -} - -footer { - font-size: small; - text-align: center; - margin: 1lh; - color: color-mix(in oklab, var(--foreground), var(--behind)); - - a { - color: inherit; - text-decoration-style: dotted; - } -} - -main { - padding: 1em; - max-width: var(--page-width); - margin: auto; - background-color: var(--background); - color: var(--foreground); -} - -error-message { - background-color: var(--negative); - color: contrast-color(var(--negative)); - margin: 1em 0; - padding: 1em; - border-radius: 4px; - display: flex; - flex-direction: column; - gap: 0.5lh; - width: max-content; - - box-label { - font-weight: bold; - } -} diff --git a/templates/index.xsl b/templates/index.xsl index ee60c72..65e84f6 100644 --- a/templates/index.xsl +++ b/templates/index.xsl @@ -60,8 +60,18 @@ Error - + + +

+ +

+
+ + + + + diff --git a/webroot/index.php b/webroot/index.php new file mode 100644 index 0000000..729cdf2 --- /dev/null +++ b/webroot/index.php @@ -0,0 +1,63 @@ +Server misconfiguration! couldn't figure out the request path

"; + exit; +} + +$path = explode('?', $path, 2)[0]; + +$config = parse_ini_file(__DIR__ . '/../config.ini', true, INI_SCANNER_TYPED); + +require_once __DIR__ . '/../localisation.php'; + +Psso\XMLResponse::addStylesheet('../templates/index.xsl'); +Psso\XMLResponse::addPreamble(function ($doc) use ($config) { + $doc->addAttribute('site-name', $config['site']['name']); + $doc->addAttribute('source', $config['site']['source-location']); +}); + +$router = new Psso\Router('../routes', $config); + +$router->registerStatusHandler(404, function ($path) { + $resp = new Psso\XMLResponse; + $resp->doc->addAttribute('title', 'Not found'); + $resp->doc->addChild( + 'error', + "The requested page ($path) does not exist." + ); + $resp->send(); +}); + +$router->registerStatusHandler(405, function ($path) { + $method = $_SERVER['REQUEST_METHOD']; + $resp = new Psso\XMLResponse; + $resp->doc->addAttribute('title', 'Wrong method'); + $resp->doc->addChild( + 'error', + "Your request method ($method) is not valid for this path." + ); + $resp->send(); +}); + +$router->setExceptionHandler(function (Throwable $e) { + $resp = new Psso\XMLResponse; + $resp->doc->addAttribute('title', 'Uncaught exception'); + $resp->doc->addChild( + 'error', + $e::class . ': ' . $e->getMessage() + ); + $resp->send(); + error_log($e); +}); + +$router->dispatch($path); diff --git a/webroot/static/style.css b/webroot/static/style.css new file mode 100644 index 0000000..b12e886 --- /dev/null +++ b/webroot/static/style.css @@ -0,0 +1,122 @@ +:root { + font-family: sans-serif; + color-scheme: light dark; + + /* theme colours */ + --primary: #055; + --secondary: #dee; + --negative: #a01; + --background: #eff; + --foreground: #022; + --behind: #dee; + --header-bg: var(--primary); + + @media (prefers-color-scheme: dark) { + --primary: #8cc; + --secondary: #122; + --negative: #e78; + --background: #233; + --foreground: #eff; + --behind: #011; + --header-bg: var(--secondary); + } + + /* dimensions */ + --page-width: 800px; + --is-wide: 1; + @media (width < 800px) { + --is-wide: 0; + } +} + +body { + margin: 0; + padding: 0; + + background-color: var(--behind); +} + +header { + background: linear-gradient( + to bottom, + color-mix(in oklab, var(--header-bg), var(--behind)), + var(--header-bg) + ); + color: contrast-color(var(--header-bg)); + + padding: 1em; + max-width: var(--page-width); + margin: calc(var(--is-wide) * 1em) auto; + height: 100px; + display: flex; + align-items: end; + + site-name { + font-weight: bold; + font-size: xx-large; + } +} + +footer { + font-size: small; + text-align: center; + margin: 1lh; + color: color-mix(in oklab, var(--foreground), var(--behind)); + + a { + color: inherit; + text-decoration-style: dotted; + } +} + +main { + padding: 1em; + max-width: var(--page-width); + margin: auto; + background-color: var(--background); + color: var(--foreground); +} + +error-message { + background-color: var(--negative); + color: contrast-color(var(--negative)); + margin: 1em 0; + padding: 1em; + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 0.5lh; + width: max-content; + max-width: 100%; + box-sizing: border-box; + + box-label { + font-weight: bold; + } +} + +.challenge-message { + border: 1px solid var(--negative); + padding: 1em; + width: max-content; + max-width: 100%; + box-sizing: border-box; +} + +form { + display: grid; + grid-template-columns: max-content max-content; + gap: 1em; + background-color: var(--behind); + margin: 1em 0; + padding: 1em; + width: max-content; + max-width: 100%; + box-sizing: border-box; + + button { + grid-column: 1 / 3; + width: max-content; + justify-self: center; + } +} -- cgit v1.3