aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorwinter Sparkles2026-08-09 15:44:18 +0100
committerwinter Sparkles2026-08-09 15:44:18 +0100
commit96387025da63025894f6259f7eebed0fc6fc53ac (patch)
treea83d73eeb7ec419b24ff78291634503d4ebceec1
parentc8998ccdff070d7c73a634e031a041260b87c3b1 (diff)
implement the rest of login process incl. cookie redirects and 'next'
-rw-r--r--Psso/AuthInterface/Groups.php6
-rw-r--r--Psso/AuthInterface/UserExtra.php7
-rw-r--r--Psso/AuthProvider/ConfigFile.php21
-rw-r--r--Psso/Context.php3
-rw-r--r--Psso/Identity.php10
-rw-r--r--Psso/Session.php14
-rw-r--r--config.ini19
-rw-r--r--locale/en.ini3
-rw-r--r--misc.php27
-rw-r--r--routes/continue.php33
-rw-r--r--routes/integration/auth-request.php24
-rw-r--r--routes/login.php47
-rw-r--r--routes/logout.php1
-rw-r--r--templates/index.xsl8
-rw-r--r--webroot/index.php1
-rw-r--r--webroot/static/style.css17
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()
diff --git a/config.ini b/config.ini
index 633a88c..d09ef3d 100644
--- a/config.ini
+++ b/config.ini
@@ -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;
+ }
}