aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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.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.php28
-rw-r--r--config.ini19
-rw-r--r--localisation.php14
-rw-r--r--routes/index.php2
-rw-r--r--routes/test-challenges.php67
-rw-r--r--templates/index.xsl12
-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];
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]
+
+;; <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;
+ }
+}