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:
@@ -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,
|
||||
]);
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user