diff options
| author | winter Sparkles | 2026-08-09 15:44:18 +0100 |
|---|---|---|
| committer | winter Sparkles | 2026-08-09 15:44:18 +0100 |
| commit | 96387025da63025894f6259f7eebed0fc6fc53ac (patch) | |
| tree | a83d73eeb7ec419b24ff78291634503d4ebceec1 | |
| parent | c8998ccdff070d7c73a634e031a041260b87c3b1 (diff) | |
implement the rest of login process incl. cookie redirects and 'next'
| -rw-r--r-- | Psso/AuthInterface/Groups.php | 6 | ||||
| -rw-r--r-- | Psso/AuthInterface/UserExtra.php | 7 | ||||
| -rw-r--r-- | Psso/AuthProvider/ConfigFile.php | 21 | ||||
| -rw-r--r-- | Psso/Context.php | 3 | ||||
| -rw-r--r-- | Psso/Identity.php | 10 | ||||
| -rw-r--r-- | Psso/Session.php | 14 | ||||
| -rw-r--r-- | config.ini | 19 | ||||
| -rw-r--r-- | locale/en.ini | 3 | ||||
| -rw-r--r-- | misc.php | 27 | ||||
| -rw-r--r-- | routes/continue.php | 33 | ||||
| -rw-r--r-- | routes/integration/auth-request.php | 24 | ||||
| -rw-r--r-- | routes/login.php | 47 | ||||
| -rw-r--r-- | routes/logout.php | 1 | ||||
| -rw-r--r-- | templates/index.xsl | 8 | ||||
| -rw-r--r-- | webroot/index.php | 1 | ||||
| -rw-r--r-- | webroot/static/style.css | 17 |
16 files changed, 217 insertions, 24 deletions
diff --git a/Psso/AuthInterface/Groups.php b/Psso/AuthInterface/Groups.php new file mode 100644 index 0000000..463fd0b --- /dev/null +++ b/Psso/AuthInterface/Groups.php @@ -0,0 +1,6 @@ +<?php +namespace Psso\AuthInterface; + +interface Groups { + public function userGroups(string $username): array; +} diff --git a/Psso/AuthInterface/UserExtra.php b/Psso/AuthInterface/UserExtra.php new file mode 100644 index 0000000..3a3de93 --- /dev/null +++ b/Psso/AuthInterface/UserExtra.php @@ -0,0 +1,7 @@ +<?php +namespace Psso\AuthInterface; + +interface UserExtra { + public function userDisplayName(string $username): ?string; + public function userEmailAddress(string $username): ?string; +} diff --git a/Psso/AuthProvider/ConfigFile.php b/Psso/AuthProvider/ConfigFile.php index 688c193..e9b45ce 100644 --- a/Psso/AuthProvider/ConfigFile.php +++ b/Psso/AuthProvider/ConfigFile.php @@ -1,9 +1,10 @@ <?php namespace Psso\AuthProvider; -use Psso\{AuthProvider, AuthInterface}; +use Psso\AuthProvider; +use Psso\AuthInterface\{Groups, Password, UserExists, UserExtra}; class ConfigFile extends AuthProvider -implements AuthInterface\Password, AuthInterface\UserExists { +implements Password, UserExists, UserExtra, Groups { public function validatePassword( string $username, #[\SensitiveParameter] string $password @@ -18,4 +19,20 @@ implements AuthInterface\Password, AuthInterface\UserExists { public function userExists(string $username): bool { return isset($this->config['users'][$username]); } + + public function userGroups(string $username): array { + $gstr = $this->config['users'][$username]['groups'] ?? ''; + $groups = []; + for ($ng = strtok($gstr, ' '); $ng !== false; $ng = strtok(' ')) + $groups[] = $ng; + return $groups; + } + + public function userDisplayName(string $username): ?string { + return $this->config['users'][$username]['name'] ?? null; + } + + public function userEmailAddress(string $username): ?string { + return $this->config['users'][$username]['email'] ?? null; + } } diff --git a/Psso/Context.php b/Psso/Context.php index 5af2102..ed1e6f9 100644 --- a/Psso/Context.php +++ b/Psso/Context.php @@ -4,8 +4,9 @@ namespace Psso; class Context { public protected(set) array $results = []; public ?string $user = null; - public protected(set) array $groups = []; + public array $groups = []; public protected(set) array $tags = []; + public array $extras = []; public function addResult(ChallengeResult $result): void { $this->results[] = $result; diff --git a/Psso/Identity.php b/Psso/Identity.php index 68272d4..a06f57e 100644 --- a/Psso/Identity.php +++ b/Psso/Identity.php @@ -4,9 +4,17 @@ namespace Psso; class Identity { public protected(set) string $user; public protected(set) array $groups; + public array $extras; - public function __construct(string $user, array $groups = []) { + public function __construct( + string $user, array $groups = [], array $extras = [] + ) { $this->user = $user; $this->groups = $groups; + $this->extras = $extras; + } + + public static function fromContext(Context $context) { + return new static($context->user, $context->groups, $context->extras); } } diff --git a/Psso/Session.php b/Psso/Session.php index 34dd94a..9893096 100644 --- a/Psso/Session.php +++ b/Psso/Session.php @@ -7,7 +7,7 @@ class Session { protected static ?array $config = null; protected \PDO $db; - private ?string $token = null; + public protected(set) ?string $token = null; public static function setDsn( array $config, @@ -34,6 +34,14 @@ class Session { return self::$instance; } + public static function override(string $newToken): void { + if (!isset(self::$instance)) { + self::$instance = new self(); + } + self::$instance->token = $newToken; + self::$instance->setCookie(true); + } + protected function setup(): void { $this->db->exec( <<<'END' @@ -50,8 +58,8 @@ class Session { return explode('.', $_SERVER['HTTP_HOST'], 2)[1]; } - protected function setCookie(): void { - if (isset($_COOKIE['PSSO_session'])) return; + protected function setCookie(bool $force = false): void { + if (isset($_COOKIE['PSSO_session']) && !$force) return; header( 'Set-Cookie: PSSO_session=' . $this->currentToken() . '; Domain=' . $this->domain() @@ -8,12 +8,16 @@ name = Pleasant SSO ;; primary domain where your login page will be primary-domain = auth.example.com -;; all domains that need to be visited to set cookies appropriately +;; additional domains that need to be visited to set cookies appropriately +;; (primary-domain is implicitly always included so don't put it again here) ;; note that for now, it's assumed that the domain one level higher than these ;; is the "registrable domain" where the cookies are set to -;; e.g. for 'auth.example.com', the cookie will get Domain=example.com -cookie-domains[] = auth.example.com +;; e.g. for 'auth.example.net', the cookie will get Domain=example.net cookie-domains[] = auth.example.net +cookie-domains[] = auth.example.org + +;; additional domains where it's allowed to redirect to (includes subdomains) +redirect-domains[] = thirdparty.example ;; location where the source code of the version of the software running on your ;; server can be found. so if you make any significant changes to the source @@ -26,6 +30,7 @@ source-location = https://git.зима.net/winter/pleasant-sso/ ;; 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' +;; e.g. if you want to match authelia, set it to 'Remote' header-prefix = X-Login @@ -33,6 +38,7 @@ header-prefix = X-Login ;; what AuthProvider to use - i.e. who to ask for users' information ;; ConfigFile -> look in this file, see below +;; coming soon will be Ldap, etc. provider = ConfigFile ;; how long (in seconds) until a session expires and you have to log in again @@ -47,5 +53,12 @@ lifetime = 604800 ;; password-hash -> allow password login for user ;; password hash should be generated by php function 'password_hash' +;; groups -> space-separated list of groups to put the user in +;; name -> display name +;; email -> email address +;; none are required but at least one must be present for the user to exist +winter[name] = winter Sparkles winter[password-hash] = "$2y$12$V3dwpbHF5fTx46g9xMflvODNGmr0apltiaDONUSE2skRrslgcRxSS" +winter[groups] = admin + ;; more options to be added in future! diff --git a/locale/en.ini b/locale/en.ini index 295ffcc..cb1766d 100644 --- a/locale/en.ini +++ b/locale/en.ini @@ -16,4 +16,5 @@ challenge.input.otp = "One-time passcode" challenge.message.wrong-username = "Username does not exist" challenge.message.wrong-password = "Incorrect username or password" challenge.message.wrong-otp = "Invalid OTP code" -challenge.continue = "Continue"
\ No newline at end of file +challenge.continue = "Continue" +challenge.separator = "or" diff --git a/misc.php b/misc.php new file mode 100644 index 0000000..7965066 --- /dev/null +++ b/misc.php @@ -0,0 +1,27 @@ +<?php +namespace Psso; + +function isValidRedirect(string $target, array $config): bool { + $redirHost = parse_url($target, PHP_URL_HOST); + + $primary = explode('.', $config['site']['primary-domain'], 2)[1]; + if (str_ends_with($redirHost, '.' . $primary) || $redirHost == $primary) { + return true; + } + + foreach ($config['site']['cookie-domains'] ?? [] as $cdomain) { + $upper = explode('.', $cdomain, 2)[1]; + if (str_ends_with($redirHost, '.' . $upper) || $redirHost == $upper) { + return true; + } + } + + foreach ($config['site']['redirect-domains'] ?? [] as $rdomain) { + if (str_ends_with($redirHost, '.' . $rdomain) || + $redirHost == $rdomain) { + return true; + } + } + + return false; +} diff --git a/routes/continue.php b/routes/continue.php new file mode 100644 index 0000000..2839ef9 --- /dev/null +++ b/routes/continue.php @@ -0,0 +1,33 @@ +<?php + +// this is the confusingly named cookie-relay page + +function GET(array $config) { + $ci = array_search($_SERVER['HTTP_HOST'], + $config['site']['cookie-domains'] ?? []); + if ($ci === false) { + header('Location: https://' . $config['site']['primary-domain']); + return; + } + if (!isset($_GET['sid'])) { + header('Location: /'); + return; + } + + Psso\Session::override($_GET['sid']); + + $nextDomain = $config['site']['cookie-domains'][$ci + 1] ?? null; + if (!isset($nextDomain)) { + $redir = $_GET['next'] ?? null; + if (isset($redir) && Psso\isValidRedirect($redir, $config)) { + header('Location: ' . $redir); + } else { + header('Location: https://' . $config['site']['primary-domain']); + } + return; + } + $target = 'Location: https://' . $nextDomain . '/continue' + . '?sid=' . urlencode($_GET['sid']); + if (isset($_GET['next'])) $target .= '&next=' . urlencode($_GET['next']); + header('Location: ' . $target); +} diff --git a/routes/integration/auth-request.php b/routes/integration/auth-request.php index 82d1f03..51b54d8 100644 --- a/routes/integration/auth-request.php +++ b/routes/integration/auth-request.php @@ -4,11 +4,27 @@ // returns 200 for allowed requests // otherwise, returns 401 and sends the login page url as a header +function addHeader(array $config, string $baseName, string $value): void { + $prefix = $config['integration']['header-prefix']; + header("$prefix-$baseName: $value"); +} + function GET(array $config) { - // in the absence of any auth backends i will say No to all requests + $session = Psso\Session::get(); + $identity = $session->getIdentity(); + if (isset($identity)) { + // ok + addHeader($config, 'User', $identity->user); + addHeader($config, 'Groups', implode(',', $identity->groups)); + foreach ($identity->extras as $key => $value) { + if ($value !== null) addHeader($config, ucfirst($key), $value); + } + return; + } + http_response_code(401); - header( - $config['integration']['header-prefix'] . '-Location: https://' - . $config['site']['primary-domain'] . '/login' + addHeader( + $config, 'Location', + 'https://' . $config['site']['primary-domain'] . '/login' ); } diff --git a/routes/login.php b/routes/login.php index 2f73271..ea81c3b 100644 --- a/routes/login.php +++ b/routes/login.php @@ -1,16 +1,33 @@ <?php +function nextTarget(array $config, string $sid): string { + if (count($config['site']['cookie-domains'] ?? []) > 0) { + $next = 'https://' . $config['site']['cookie-domains'][0] . '/continue' + . '?sid=' . urlencode($sid); + if (isset($_GET['next'])) { + $next .= '&next=' . urlencode($_GET['next']); + } + return $next; + } + if (isset($_GET['next']) && Psso\isValidRedirect($_GET['next'], $config)) { + return $_GET['next']; + } + return '/'; +} + + function presentChallenges( - Psso\Session $session, Psso\Context $context, ?string $message = null + array $config, + Psso\Session $session, + Psso\Context $context, + ?string $message = null ) { $challengeTypes = Psso\AuthFlow::nextStep($context); if ($challengeTypes === true) { // auth finished! all good $session->setChallenges(null); - $session->setIdentity( - new Psso\Identity($context->user, $context->groups) - ); - header('Location: /'); //temporary crap for testing + $session->setIdentity(Psso\Identity::fromContext($context)); + header('Location: ' . nextTarget($config, $session->token)); return; } if (count($challengeTypes) == 0) { @@ -27,6 +44,7 @@ function presentChallenges( // send challenges to user $resp = new Psso\XMLResponse; $resp->doc->addAttribute('title', L('login.title')); + $resp->doc->addAttribute('kind', 'challenges'); if (isset($message)) { $resp->doc->addChild('challenge-message', L($message)); } @@ -39,7 +57,8 @@ function presentChallenges( $session->setChallenges($challenges); } -function GET() { + +function GET(array $config) { $session = Psso\Session::get(); if ($session->getIdentity() !== null) { // already logged in @@ -47,9 +66,10 @@ function GET() { } $context = new Psso\Context; - presentChallenges($session, $context); + presentChallenges($config, $session, $context); } + function POST(array $config) { $session = Psso\Session::get(); @@ -75,9 +95,18 @@ function POST(array $config) { $context = $answeredChallenge->context; $context->addResult($result); - // also set the user in context if we're able to + // also set the user and groups in context if we're able to if ($result->successful && isset($result->user) && !isset($context->user)) { $context->user = $result->user; + if ($provider instanceof Psso\AuthInterface\Groups) { + $context->groups = $provider->userGroups($context->user); + } + if ($provider instanceof Psso\AuthInterface\UserExtra) { + $context->extras['name'] = + $provider->userDisplayName($context->user); + $context->extras['email'] = + $provider->userEmailAddress($context->user); + } } - presentChallenges($session, $context, $result->message); + presentChallenges($config, $session, $context, $result->message); } diff --git a/routes/logout.php b/routes/logout.php index 21b2179..14fcedb 100644 --- a/routes/logout.php +++ b/routes/logout.php @@ -10,6 +10,7 @@ function GET() { $resp = new Psso\XMLResponse; $resp->doc->addAttribute('title', L('logout.title')); + $resp->doc->addAttribute('kind', 'challenges'); $form = $resp->doc->addChild('form'); $form->addChild('p', L('logout.warning')); $form->addAttribute('method', 'post'); diff --git a/templates/index.xsl b/templates/index.xsl index 35300ec..091959e 100644 --- a/templates/index.xsl +++ b/templates/index.xsl @@ -30,6 +30,9 @@ <body> <xsl:call-template name="page-header"/> <main> + <xsl:if test="@kind = 'challenges'"> + <xsl:attribute name="class">challenge-page</xsl:attribute> + </xsl:if> <xsl:if test="@title"> <h1><xsl:value-of select="@title"/></h1> </xsl:if> @@ -78,6 +81,11 @@ <xsl:template match="form"> <xsl:copy-of select="."/> + <xsl:if test="position() != last()"> + <challenge-separator> + <xsl:value-of select="psso:L('challenge.separator')"/> + </challenge-separator> + </xsl:if> </xsl:template> </xsl:stylesheet> diff --git a/webroot/index.php b/webroot/index.php index eda73c2..8a4be02 100644 --- a/webroot/index.php +++ b/webroot/index.php @@ -21,6 +21,7 @@ $config = parse_ini_file(__DIR__ . '/../config.ini', true, INI_SCANNER_TYPED); Psso\Session::setDsn($config, 'sqlite:' . __DIR__ . '/../data/sessions.sqlite'); require_once __DIR__ . '/../localisation.php'; +require_once __DIR__ . '/../misc.php'; Psso\XMLResponse::addStylesheet('../templates/index.xsl'); Psso\XMLResponse::addPreamble(function ($doc) use ($config) { diff --git a/webroot/static/style.css b/webroot/static/style.css index b12e886..f126140 100644 --- a/webroot/static/style.css +++ b/webroot/static/style.css @@ -75,6 +75,12 @@ main { margin: auto; background-color: var(--background); color: var(--foreground); + + &.challenge-page { + display: flex; + flex-direction: column; + align-items: center; + } } error-message { @@ -119,4 +125,15 @@ form { width: max-content; justify-self: center; } + + p { + grid-column: 1 / 3; + } +} + +challenge-separator { + &::before, &::after { + content: "—"; + padding: 0 1ex; + } } |
