forked from Public/pics
Switch from email-based OIDC matching to the stable `sub` claim. Existing users are migrated by email on first login, new users are auto-enrolled from OIDC claims, and admin status is synced from the IdP's groups claim. Also expose oidc_sub on the admin edit-user page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
153 lines
4.0 KiB
PHP
153 lines
4.0 KiB
PHP
<?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));
|
|
}
|
|
}
|