diff --git a/composer.json b/composer.json index a4b52e4..4294764 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "ext-imagick": "*", "ext-mysqli": "*", "twbs/bootstrap": "^5.3", - "twbs/bootstrap-icons": "^1.10" + "twbs/bootstrap-icons": "^1.10", + "jumbojett/openid-connect-php": "^1.0" } } diff --git a/config.php.dist b/config.php.dist index 0c86137..86fb8f0 100644 --- a/config.php.dist +++ b/config.php.dist @@ -34,3 +34,10 @@ const DB_LOG_QUERIES = false; const SITE_TITLE = 'HashRU Pics'; const SITE_SLOGAN = 'Nijmeegs Nerdclubje'; + +// OIDC authentication (e.g. Kanidm). OIDC is enabled when OIDC_PROVIDER_URL is non-empty. +const OIDC_PROVIDER_URL = ''; // e.g. 'https://kanidm.example.com/oauth2/openid/pics' +const OIDC_CLIENT_ID = ''; +const OIDC_CLIENT_SECRET = ''; +const OIDC_PROVIDER_NAME = ''; // e.g. 'Kanidm' — used as button label +const OIDC_ADMIN_GROUP = ''; // OIDC group claim value that grants admin, e.g. 'pics_admins' diff --git a/controllers/EditUser.php b/controllers/EditUser.php index 089dca6..b303988 100644 --- a/controllers/EditUser.php +++ b/controllers/EditUser.php @@ -109,6 +109,14 @@ class EditUser extends HTMLController 'maxlength' => 255, 'is_optional' => true, ], + 'oidc_sub' => [ + 'header' => 'OIDC', + 'type' => 'text', + 'label' => 'OIDC subject identifier', + 'size' => 50, + 'maxlength' => 255, + 'is_optional' => true, + ], 'is_admin' => [ 'header' => 'Privileges', 'type' => 'checkbox', @@ -145,6 +153,10 @@ class EditUser extends HTMLController // Quick stripping. $data['slug'] = strtr(strtolower($data['slug']), [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '/' => '-', '\\' => '-']); + // Normalise empty OIDC sub to null (unique constraint). + if (empty($data['oidc_sub'])) + $data['oidc_sub'] = null; + // Checkboxes, fun! $data['is_admin'] = empty($data['is_admin']) ? 0 : 1; diff --git a/controllers/OIDCLogin.php b/controllers/OIDCLogin.php new file mode 100644 index 0000000..0f1145b --- /dev/null +++ b/controllers/OIDCLogin.php @@ -0,0 +1,152 @@ +isLoggedIn()) + { + header('Location: ' . BASEURL . '/'); + exit; + } + + // Store redirect URL in session before OIDC flow. + if (isset($_GET['redirect'])) + $_SESSION['oidc_redirect_url'] = base64_decode($_GET['redirect']); + + $oidc = new OpenIDConnectClient(OIDC_PROVIDER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET); + $oidc->setRedirectURL(BASEURL . '/oidclogin/'); + $oidc->addScope(['openid', 'email', 'profile', 'groups']); + + try + { + $oidc->authenticate(); + } + catch (\Exception $e) + { + $_SESSION['login_msg'] = ['', 'OIDC authentication failed: ' . $e->getMessage(), 'danger']; + header('Location: ' . BASEURL . '/login/'); + exit; + } + + // Get the stable subject identifier from the ID token. + $sub = $oidc->getVerifiedClaims('sub'); + if (empty($sub)) + { + $_SESSION['login_msg'] = ['', 'No subject identifier received from OIDC provider.', 'danger']; + header('Location: ' . BASEURL . '/login/'); + exit; + } + + // Step 1: Look up user by oidc_sub. + $user = Member::fromOidcSub($sub); + + // Step 2: One-time email migration — link existing account by email. + if ($user === null || $user === false) + { + $email = $oidc->requestUserInfo('email'); + if (!empty($email)) + { + $user = Member::fromEmailAddress($email); + if ($user !== null && $user !== false) + $user->update(['oidc_sub' => $sub]); + } + } + + // Step 3: Auto-enroll — create a new user from OIDC claims. + if ($user === null || $user === false) + { + $first_name = $oidc->requestUserInfo('given_name') ?: ''; + $last_name = $oidc->requestUserInfo('family_name') ?: ''; + $email = $oidc->requestUserInfo('email') ?: $sub . '@oidc.placeholder'; + + $slug = $this->generateSlug($first_name, $last_name); + + $user = Member::createNew([ + 'first_name' => $first_name ?: 'OIDC', + 'surname' => $last_name ?: 'User', + 'slug' => $slug, + 'emailaddress' => $email, + 'oidc_sub' => $sub, + ]); + + if ($user === false) + { + $_SESSION['login_msg'] = ['', 'Failed to create account. Please contact an administrator.', 'danger']; + header('Location: ' . BASEURL . '/login/'); + exit; + } + } + + // Step 4: Sync admin status from groups claim. + $groups = $oidc->requestUserInfo('groups'); + $should_be_admin = is_array($groups) && in_array(OIDC_ADMIN_GROUP, $groups); + if ($should_be_admin !== $user->isAdmin()) + $user->update(['is_admin' => $should_be_admin ? 1 : 0]); + + // Set session and redirect. + $_SESSION['user_id'] = $user->getUserId(); + + if (!empty($_SESSION['oidc_redirect_url'])) + { + $redirect = $_SESSION['oidc_redirect_url']; + unset($_SESSION['oidc_redirect_url']); + header('Location: ' . $redirect); + } + elseif (!empty($_SESSION['login_url'])) + { + $redirect = $_SESSION['login_url']; + unset($_SESSION['login_url']); + header('Location: ' . $redirect); + } + else + header('Location: ' . BASEURL . '/'); + + exit; + } + + private function generateSlug($first_name, $last_name) + { + $base = trim($first_name . ' ' . $last_name); + if (empty($base)) + $base = 'user'; + + $slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $base)); + $slug = trim($slug, '-'); + if (empty($slug)) + $slug = 'user'; + + // Check if slug is available. + $candidate = $slug; + for ($i = 0; $i < 10; $i++) + { + try + { + Member::fromSlug($candidate); + // Slug taken — append random suffix. + $candidate = $slug . '-' . bin2hex(random_bytes(3)); + } + catch (NotFoundException $e) + { + // Slug is available. + return $candidate; + } + } + + // Fallback: use sub-based slug. + return 'user-' . bin2hex(random_bytes(4)); + } +} diff --git a/migrations/2026-02-15-oidc-sub.sql b/migrations/2026-02-15-oidc-sub.sql new file mode 100644 index 0000000..695ba91 --- /dev/null +++ b/migrations/2026-02-15-oidc-sub.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN oidc_sub TEXT; +CREATE UNIQUE INDEX idx_users_oidc_sub ON users (oidc_sub); diff --git a/models/Member.php b/models/Member.php index 0417a0e..ea78108 100644 --- a/models/Member.php +++ b/models/Member.php @@ -27,6 +27,15 @@ class Member extends User ['email_address' => $email_address]); } + public static function fromOidcSub($sub) + { + return Registry::get('db')->queryObject(static::class, ' + SELECT * + FROM users + WHERE oidc_sub = :oidc_sub', + ['oidc_sub' => $sub]); + } + public static function fromId($id_user) { $row = Registry::get('db')->queryAssoc(' @@ -73,18 +82,27 @@ class Member extends User 'surname' => !empty($data['surname']) ? $data['surname'] : $error |= true, 'slug' => !empty($data['slug']) ? $data['slug'] : $error |= true, 'emailaddress' => !empty($data['emailaddress']) ? $data['emailaddress'] : $error |= true, - 'password_hash' => !empty($data['password']) ? Authentication::computeHash($data['password']) : $error |= true, 'creation_time' => time(), 'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', 'is_admin' => empty($data['is_admin']) ? 0 : 1, 'reset_key' => '', ]; + // Password is required unless oidc_sub is provided. + if (!empty($data['password'])) + $new_user['password_hash'] = Authentication::computeHash($data['password']); + elseif (!empty($data['oidc_sub'])) + $new_user['password_hash'] = ''; + else + $error |= true; + + if (!empty($data['oidc_sub'])) + $new_user['oidc_sub'] = $data['oidc_sub']; + if ($error) return false; - $db = Registry::get('db'); - $bool = $db->insert('insert', 'users', [ + $columns = [ 'first_name' => 'string-30', 'surname' => 'string-60', 'slug' => 'string-90', @@ -93,8 +111,14 @@ class Member extends User 'creation_time' => 'int', 'ip_address' => 'string-45', 'is_admin' => 'int', - 'reset_key' => 'string-16' - ], $new_user, ['id_user']); + 'reset_key' => 'string-16', + ]; + + if (isset($new_user['oidc_sub'])) + $columns['oidc_sub'] = 'string-255'; + + $db = Registry::get('db'); + $bool = $db->insert('insert', 'users', $columns, $new_user, ['id_user']); if (!$bool) return false; @@ -113,7 +137,7 @@ class Member extends User { foreach ($new_data as $key => $value) { - if (in_array($key, ['first_name', 'surname', 'slug', 'emailaddress'])) + if (in_array($key, ['first_name', 'surname', 'slug', 'emailaddress', 'oidc_sub'])) $this->$key = $value; elseif ($key === 'password') $this->password_hash = Authentication::computeHash($value); @@ -132,7 +156,8 @@ class Member extends User slug = :slug, emailaddress = :emailaddress, password_hash = :password_hash, - is_admin = :is_admin + is_admin = :is_admin, + oidc_sub = :oidc_sub WHERE id_user = :id_user', get_object_vars($this)); } diff --git a/models/Router.php b/models/Router.php index da37499..0374380 100644 --- a/models/Router.php +++ b/models/Router.php @@ -20,6 +20,7 @@ class Router 'edituser' => 'EditUser', 'login' => 'Login', 'logout' => 'Logout', + 'oidclogin' => 'OIDCLogin', 'managealbums' => 'ManageAlbums', 'manageassets' => 'ManageAssets', 'manageerrors' => 'ManageErrors', diff --git a/models/User.php b/models/User.php index a26241d..8bfafbd 100644 --- a/models/User.php +++ b/models/User.php @@ -24,6 +24,7 @@ abstract class User protected $is_admin; protected $reset_key; protected $reset_blocked_until; + protected $oidc_sub; protected bool $is_logged; protected bool $is_guest; diff --git a/templates/LogInForm.php b/templates/LogInForm.php index 0e0c96a..5f04b6e 100644 --- a/templates/LogInForm.php +++ b/templates/LogInForm.php @@ -63,5 +63,18 @@ class LogInForm extends SubTemplate '; + + if (!empty(OIDC_PROVIDER_URL)) + { + $oidc_url = BASEURL . '/oidclogin/'; + if (!empty($this->redirect_url)) + $oidc_url .= '?redirect=' . base64_encode($this->redirect_url); + + echo ' +
'; + } } }