Compare commits
2 Commits
master
...
d631a07d3d
| Author | SHA1 | Date | |
|---|---|---|---|
| d631a07d3d | |||
| 65d5cb62e5 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
152
controllers/OIDCLogin.php
Normal file
152
controllers/OIDCLogin.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
/*****************************************************************************
|
||||
* OIDCLogin.php
|
||||
* Handles OIDC authentication via Kanidm (or other OIDC providers).
|
||||
*****************************************************************************/
|
||||
|
||||
use Jumbojett\OpenIDConnectClient;
|
||||
|
||||
class OIDCLogin
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
if (empty(OIDC_PROVIDER_URL))
|
||||
{
|
||||
header('Location: ' . BASEURL . '/login/');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Already logged in? Redirect home.
|
||||
if (Registry::get('user')->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));
|
||||
}
|
||||
}
|
||||
2
migrations/2026-02-15-oidc-sub.sql
Normal file
2
migrations/2026-02-15-oidc-sub.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users ADD COLUMN oidc_sub TEXT;
|
||||
CREATE UNIQUE INDEX idx_users_oidc_sub ON users (oidc_sub);
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class Router
|
||||
'edituser' => 'EditUser',
|
||||
'login' => 'Login',
|
||||
'logout' => 'Logout',
|
||||
'oidclogin' => 'OIDCLogin',
|
||||
'managealbums' => 'ManageAlbums',
|
||||
'manageassets' => 'ManageAssets',
|
||||
'manageerrors' => 'ManageErrors',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,5 +63,18 @@ class LogInForm extends SubTemplate
|
||||
</div>
|
||||
</div>
|
||||
</form>';
|
||||
|
||||
if (!empty(OIDC_PROVIDER_URL))
|
||||
{
|
||||
$oidc_url = BASEURL . '/oidclogin/';
|
||||
if (!empty($this->redirect_url))
|
||||
$oidc_url .= '?redirect=' . base64_encode($this->redirect_url);
|
||||
|
||||
echo '
|
||||
<div class="mt-4 text-center">
|
||||
<hr>
|
||||
<a class="btn btn-secondary" href="', htmlspecialchars($oidc_url), '">Login with ', htmlspecialchars(OIDC_PROVIDER_NAME), '</a>
|
||||
</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user