Fix customer login email session refresh (#337)

Invalidate cached auth sessions for every active customer token and return the persisted canonical login email.
This commit is contained in:
Jeppe B
2026-08-03 09:40:00 +02:00
committed by GitHub
parent c795df4aad
commit f37feef1e6
2 changed files with 167 additions and 2 deletions
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication;
use objects\logs_o;
use objects\tokens_o;
use traits\route_t;
class userSecurityRoute
@@ -43,8 +44,25 @@ class userSecurityRoute
} else {
// Change the email
$user->setEmail($email);
$canonical_email = (string)$user->email->value();
try {
$tokenRows = (new tokens_o())->getFieldsWhere([
'user_id' => [(int)$user->id],
], ['token']);
foreach ($tokenRows as $tokenRow) {
$token = (string)($tokenRow['token'] ?? '');
if ($token !== '') {
redis->clear_auth_session($token);
}
}
} catch (\Throwable) {
// Session cache invalidation is best-effort; the persistent update above is authoritative.
}
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_CHANGE_EMAIL', 'Email changed');
$response->success(['message' => 'Email changed']);
$response->success([
'message' => 'Email changed',
'email' => $canonical_email,
]);
}
},
[
@@ -151,4 +169,4 @@ class userSecurityRoute
]
);
}
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('persists exact login emails and invalidates stale session data without changing the economic email', function (): void {
api_test_covers('POST /account/security/change-email', 'happy');
$oldLoginEmail = 'old-login@example.test';
$newLoginEmail = 'k.sand@example.test';
$invoiceEmail = 'invoice@example.test';
$permissions = [
'fetch_session',
'user_security_change_email',
];
$session = api_fixtures()->createUserSession($permissions, [
'email' => $oldLoginEmail,
'password_plaintext' => 'Secret123!',
]);
$parallelToken = api_fixtures()->createAuthToken((int)$session['user']['id']);
$parallelHeaders = api_fixtures()->bearerHeaders($parallelToken);
$economicCustomer = [
'customerNumber' => (int)$session['user']['customer_number'],
'name' => (string)$session['user']['display_name'],
'email' => $invoiceEmail,
'country' => 'DK',
'currency' => 'DKK',
'barred' => false,
];
$encodedEconomicCustomer = json_encode($economicCustomer, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
expect($encodedEconomicCustomer)->toBeString();
$redis = api_test_runtime()->redis();
expect($redis)->not->toBeNull();
$redis->set('users_' . (int)$session['user']['id'] . '_economic_customer', $encodedEconomicCustomer);
$redis->set('`users`_' . (int)$session['user']['id'] . '_economic_customer', $encodedEconomicCustomer);
api_fixtures()->cacheAuthSessionForUser($session['user'], $session['token'], $permissions, [
'email' => $oldLoginEmail,
'economic_customer' => $economicCustomer,
]);
api_fixtures()->cacheAuthSessionForUser($session['user'], $parallelToken, $permissions, [
'email' => $oldLoginEmail,
'economic_customer' => $economicCustomer,
]);
$warmSession = api_client()->get('/auth/session', $session['headers']);
$warmSession
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($warmSession->data()['email'] ?? null)
->toBe($oldLoginEmail)
->and($warmSession->data()['economic_customer']['email'] ?? null)
->toBe($invoiceEmail);
$warmParallelSession = api_client()->get('/auth/session', $parallelHeaders);
$warmParallelSession
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($warmParallelSession->data()['email'] ?? null)
->toBe($oldLoginEmail)
->and($warmParallelSession->data()['economic_customer']['email'] ?? null)
->toBe($invoiceEmail);
$changeResponse = api_client()->post('/account/security/change-email', [
'email' => $newLoginEmail,
'password' => $session['user']['password_plaintext'],
], $session['headers']);
$changeResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Email changed');
expect($changeResponse->data()['email'] ?? null)->toBe($newLoginEmail);
$storedUser = api_test_runtime()->queryOne(
'SELECT email FROM users WHERE id = ' . (int)$session['user']['id'] . ' LIMIT 1'
);
expect($storedUser['email'] ?? null)->toBe($newLoginEmail);
$freshSession = api_client()->get('/auth/session', $session['headers']);
$freshSession
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($freshSession->data()['email'] ?? null)
->toBe($newLoginEmail)
->and($freshSession->data()['economic_customer']['email'] ?? null)
->toBe($invoiceEmail);
$freshParallelSession = api_client()->get('/auth/session', $parallelHeaders);
$freshParallelSession
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($freshParallelSession->data()['email'] ?? null)
->toBe($newLoginEmail)
->and($freshParallelSession->data()['economic_customer']['email'] ?? null)
->toBe($invoiceEmail);
});
it('rejects invalid emails and wrong passwords without changing the login email', function (): void {
api_test_covers('POST /account/security/change-email', 'failure');
$originalEmail = 'unchanged@example.test';
$session = api_fixtures()->createUserSession([
'user_security_change_email',
], [
'email' => $originalEmail,
'password_plaintext' => 'Secret123!',
]);
$invalidEmail = api_client()->post('/account/security/change-email', [
'email' => 'not-an-email',
'password' => $session['user']['password_plaintext'],
], $session['headers']);
$invalidEmail
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid email');
$wrongPassword = api_client()->post('/account/security/change-email', [
'email' => 'valid-new@example.test',
'password' => 'wrong-password',
], $session['headers']);
$wrongPassword
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid password');
$storedUser = api_test_runtime()->queryOne(
'SELECT email FROM users WHERE id = ' . (int)$session['user']['id'] . ' LIMIT 1'
);
expect($storedUser['email'] ?? null)->toBe($originalEmail);
});