[Web] OIDC/LDAP: check the OAuth state, keep the mailbox on token refresh, require unique IdP users

This commit is contained in:
FreddleSpl0it committed 2026-11-02 09:54:54 +01:00
1 parent 41467dc47f
commit 5eb3148bd5
5 files changed
+56 -10

No files matched your search

+12 -5
View File
@@ -538,7 +538,8 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
// get the mailcow_password attribute from keycloak user // get the mailcow_password attribute from keycloak user
$url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users"; $url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users";
$queryParams = array('email' => $user, 'exact' => true); // as string, http_build_query() would send exact=1 and Keycloak would search for substrings
$queryParams = array('email' => $user, 'exact' => 'true');
$queryString = http_build_query($queryParams); $queryString = http_build_query($queryParams);
$curl = curl_init(); $curl = curl_init();
curl_setopt($curl, CURLOPT_TIMEOUT, 7); curl_setopt($curl, CURLOPT_TIMEOUT, 7);
@@ -559,7 +560,10 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
if ($code != 200) { if ($code != 200) {
throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code); throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code);
} }
// exact=true can still return several users with that email // exact=true can still return several users with that email, only the exact address counts
if (is_array($users)) {
$users = array_values(array_filter($users, fn($u) => is_array($u) && strcasecmp((string)($u['email'] ?? ''), $user) === 0));
}
if (!is_array($users) || count($users) != 1) { if (!is_array($users) || count($users) != 1) {
return false; return false;
} }
@@ -710,12 +714,15 @@ function ldap_mbox_login($user, $pass, $extra = null){
->select(array_values(array_filter(array_merge([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn'], ->select(array_values(array_filter(array_merge([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn'],
\Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? ''))))); \Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? '')))));
$user_res = $ldap_query->firstOrFail(); $entries = $ldap_query->get();
} catch (\LdapRecord\Query\ObjectNotFoundException $e) {
return false;
} catch (Exception $e) { } catch (Exception $e) {
throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e); throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e);
} }
// the login name must identify exactly one entry, several matches are ambiguous
if (!is_array($entries) || count($entries) != 1) {
return false;
}
$user_res = reset($entries);
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp; $idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) { if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) {
return false; return false;
+12
View File
@@ -3013,6 +3013,18 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
return false; return false;
} }
// A refresh keeps the logged-in mailbox: a changed IdP email or another IdP user ends the session
if (strtolower($info['email']) !== strtolower($_SESSION['mailcow_cc_username'] ?? '') ||
!\Mailcow\Auth\AuthModule::fromGlobals()->idp->verifyLink($iam_settings['authsource'], $info['email'], (string)($info['sub'] ?? ''))) {
clear_session();
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $info['email'], 'The IdP user does not match the logged-in mailbox'),
'msg' => 'refresh_login_failed'
);
return false;
}
// The mailbox may have been deleted or deactivated since the login // The mailbox may have been deleted or deactivated since the login
$stmt = $pdo->prepare("SELECT `mailbox`.`authsource` FROM `mailbox` $stmt = $pdo->prepare("SELECT `mailbox`.`authsource` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
@@ -44,6 +44,8 @@ class SyncPlanner
/** @var array<string, DirectoryUser> $candidates email => user to create, template still open */ /** @var array<string, DirectoryUser> $candidates email => user to create, template still open */
$candidates = []; $candidates = [];
$duplicates = []; $duplicates = [];
/** @var array<string, true> $ambiguous unlinked mailboxes whose email several IdP users have, left as they are */
$ambiguous = [];
foreach ($listing->users as $user) { foreach ($listing->users as $user) {
if (isset($owners[$user->id])) { if (isset($owners[$user->id])) {
$username = $owners[$user->id]; $username = $owners[$user->id];
@@ -55,10 +57,19 @@ class SyncPlanner
$plan->skipped++; $plan->skipped++;
} elseif (isset($state->accounts[$user->email])) { } elseif (isset($state->accounts[$user->email])) {
$account = $state->accounts[$user->email]; $account = $state->accounts[$user->email];
if ($account->externalId !== null || isset($plan->links[$user->email])) { if ($account->externalId !== null) {
self::conflict($plan, $user->email . ' is linked to another IdP user'); self::conflict($plan, $user->email . ' is linked to another IdP user');
continue; continue;
} }
if (isset($plan->links[$user->email]) || isset($ambiguous[$user->email])) {
// Which IdP user owns the mailbox is unclear: link none of them
if (!isset($ambiguous[$user->email])) {
self::conflict($plan, 'Several IdP users have the email ' . $user->email);
}
unset($plan->links[$user->email], $matched[$user->email]);
$ambiguous[$user->email] = true;
continue;
}
$plan->links[$user->email] = $user->id; $plan->links[$user->email] = $user->id;
$matched[$user->email] = $user; $matched[$user->email] = $user;
} elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled } elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled
@@ -85,7 +96,7 @@ class SyncPlanner
if ($groups !== null) { if ($groups !== null) {
$this->planGroups($settings, $groups, $state, $usernames, $matched, $plan); $this->planGroups($settings, $groups, $state, $usernames, $matched, $plan);
} }
$this->planStates($settings, $state, $matched, $plan); $this->planStates($settings, $state, $matched, $ambiguous, $plan);
if ($settings->periodicSync) { if ($settings->periodicSync) {
$this->planUpdates($settings, $state, $matched, $plan); $this->planUpdates($settings, $state, $matched, $plan);
} }
@@ -211,10 +222,13 @@ class SyncPlanner
} }
/** @param array<string, DirectoryUser> $matched */ /** @param array<string, DirectoryUser> $matched */
private function planStates(Settings $settings, State $state, array $matched, SyncPlan $plan): void private function planStates(Settings $settings, State $state, array $matched, array $ambiguous, SyncPlan $plan): void
{ {
foreach ($state->accounts as $username => $account) { foreach ($state->accounts as $username => $account) {
$username = (string)$username; $username = (string)$username;
if (isset($ambiguous[$username])) {
continue;
}
$user = $matched[$username] ?? null; $user = $matched[$username] ?? null;
if ($user === null && $account->missingSince === null) { if ($user === null && $account->missingSince === null) {
$plan->missing[] = $username; $plan->missing[] = $username;
+4 -2
View File
@@ -19,8 +19,10 @@ if ($iam_provider){
header('Location: ' . $redirect_uri); header('Location: ' . $redirect_uri);
die(); die();
} }
} elseif ($_GET['code'] && $_GET['state'] === $_SESSION['oauth2state']) { } elseif (!empty($_GET['code']) && !empty($_SESSION['oauth2state']) && is_string($_GET['state'] ?? null)
// Check given state against previously stored one to mitigate CSRF attack && hash_equals((string)$_SESSION['oauth2state'], $_GET['state'])) {
// Check given state against previously stored one to mitigate CSRF attack, a state is used once
unset($_SESSION['oauth2state']);
// Received access token in $_GET['code'] // Received access token in $_GET['code']
// extract info and verify user // extract info and verify user
identity_provider('verify-sso'); identity_provider('verify-sso');
@@ -26,6 +26,17 @@ class SyncPlannerTest extends TestCase
$this->assertSame([], $plan->conflicts); $this->assertSame([], $plan->conflicts);
} }
public function testUnlinkedMailboxWithSeveralIdpUsersIsNeitherLinkedNorOffboarded(): void
{
$users = [self::user('u1', 'a@example.org'), self::user('u2', 'a@example.org', false), self::user('u3', 'a@example.org')];
$plan = $this->plan($users, [new Account('a@example.org', 'A', 1)]);
$this->assertSame([], $plan->links);
$this->assertSame(['Several IdP users have the email a@example.org'], $plan->conflicts);
$this->assertSame([], $plan->missing);
$this->assertSame([], $plan->deactivate);
}
public function testLinkedUserWithNewEmailIsAConflictAndNotCreated(): void public function testLinkedUserWithNewEmailIsAConflictAndNotCreated(): void
{ {
$plan = $this->plan([self::user('u1', 'new@example.org')], [new Account('a@example.org', 'A', 1, 'u1')]); $plan = $this->plan([self::user('u1', 'new@example.org')], [new Account('a@example.org', 'A', 1, 'u1')]);