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 | |
| parent | 880a274e1ecbed42c76d3dc4a81161b3ae372726 (diff) | |
implement a large chunk of the auth system itself :D
| -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 (renamed from App/Router.php) | 22 | ||||
| -rw-r--r-- | Psso/XMLResponse.php (renamed from App/XMLResponse.php) | 2 | ||||
| -rw-r--r-- | auth-flow.conf.php | 28 | ||||
| -rw-r--r-- | config.ini | 19 | ||||
| -rw-r--r-- | localisation.php | 14 | ||||
| -rw-r--r-- | routes/index.php | 2 | ||||
| -rw-r--r-- | routes/test-challenges.php | 67 | ||||
| -rw-r--r-- | templates/index.xsl | 12 | ||||
| -rw-r--r-- | webroot/index.php (renamed from index.php) | 27 | ||||
| -rw-r--r-- | webroot/static/style.css (renamed from static/style.css) | 28 |
23 files changed, 492 insertions, 12 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/App/Router.php b/Psso/Router.php index 42fd3e1..db1f851 100644 --- a/App/Router.php +++ b/Psso/Router.php @@ -1,10 +1,11 @@ <?php -namespace App; +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; @@ -15,6 +16,10 @@ class Router { $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])) { @@ -24,6 +29,15 @@ class Router { } } + 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); @@ -40,7 +54,11 @@ class Router { } if (function_exists($_SERVER['REQUEST_METHOD'])) { - $_SERVER['REQUEST_METHOD']($this->config); + try { + $_SERVER['REQUEST_METHOD']($this->config); + } catch (\Exception|\Error $e) { + $this->dealWithException($e); + } } else { $this->dealWithError(405, $path); } diff --git a/App/XMLResponse.php b/Psso/XMLResponse.php index bdbf44b..1b17660 100644 --- a/App/XMLResponse.php +++ b/Psso/XMLResponse.php @@ -1,5 +1,5 @@ <?php -namespace App; +namespace Psso; class XMLResponse { public \SimpleXMLElement $doc; 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]; @@ -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] + +;; <user>.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/localisation.php b/localisation.php new file mode 100644 index 0000000..0e193b0 --- /dev/null +++ b/localisation.php @@ -0,0 +1,14 @@ +<?php + +function L(string $key) { + // me when im lazy + return [ + 'login.title' => '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 @@ <?php function GET() { - $resp = new App\XMLResponse; + $resp = new Psso\XMLResponse; $content = $resp->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 @@ +<?php + +function presentChallenges( + Psso\Context $context, ?string $message = null +) { + $challengeTypes = Psso\AuthFlow::nextStep($context); + if ($challengeTypes === true) { + // auth finished! all good + header('Location: /login-success'); //temporary crap for testing + return; + } + if (count($challengeTypes) == 0) { + throw new RuntimeException('no more challenges available!! auth fail'); + } + + $challenges = []; + foreach ($challengeTypes as $type) { + $c = $type::create($context); + $challenges[$c->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/templates/index.xsl b/templates/index.xsl index ee60c72..65e84f6 100644 --- a/templates/index.xsl +++ b/templates/index.xsl @@ -60,8 +60,18 @@ <xsl:template match="error"> <error-message> <box-label>Error</box-label> - <xsl:copy-of select="."/> + <xsl:value-of select="."/> </error-message> </xsl:template> + <xsl:template match="challenge-message"> + <p class="challenge-message"> + <xsl:value-of select="."/> + </p> + </xsl:template> + + <xsl:template match="form"> + <xsl:copy-of select="."/> + </xsl:template> + </xsl:stylesheet> diff --git a/index.php b/webroot/index.php index 3dee8eb..729cdf2 100644 --- a/index.php +++ b/webroot/index.php @@ -3,7 +3,7 @@ spl_autoload_register(function ($class) { $sep = DIRECTORY_SEPARATOR; $cname = str_replace('\\', $sep, $class); - include __DIR__ . "$sep$cname.php"; + include __DIR__ . "$sep..$sep$cname.php"; }); $path = $_SERVER['PATH_INFO'] ?? $_SERVER['REQUEST_URI'] ?? null; @@ -16,18 +16,20 @@ if (!isset($path)) { $path = explode('?', $path, 2)[0]; -$config = parse_ini_file(__DIR__ . '/config.ini', true, INI_SCANNER_TYPED); +$config = parse_ini_file(__DIR__ . '/../config.ini', true, INI_SCANNER_TYPED); -App\XMLResponse::addStylesheet('templates/index.xsl'); -App\XMLResponse::addPreamble(function ($doc) use ($config) { +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 App\Router('routes', $config); +$router = new Psso\Router('../routes', $config); $router->registerStatusHandler(404, function ($path) { - $resp = new App\XMLResponse; + $resp = new Psso\XMLResponse; $resp->doc->addAttribute('title', 'Not found'); $resp->doc->addChild( 'error', @@ -38,7 +40,7 @@ $router->registerStatusHandler(404, function ($path) { $router->registerStatusHandler(405, function ($path) { $method = $_SERVER['REQUEST_METHOD']; - $resp = new App\XMLResponse; + $resp = new Psso\XMLResponse; $resp->doc->addAttribute('title', 'Wrong method'); $resp->doc->addChild( 'error', @@ -47,4 +49,15 @@ $router->registerStatusHandler(405, function ($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/static/style.css b/webroot/static/style.css index 6e2b8c7..b12e886 100644 --- a/static/style.css +++ b/webroot/static/style.css @@ -87,8 +87,36 @@ error-message { 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; + } +} |
