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 classes\authentication;
|
||||||
use objects\logs_o;
|
use objects\logs_o;
|
||||||
|
use objects\tokens_o;
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
|
|
||||||
class userSecurityRoute
|
class userSecurityRoute
|
||||||
@@ -43,8 +44,25 @@ class userSecurityRoute
|
|||||||
} else {
|
} else {
|
||||||
// Change the email
|
// Change the email
|
||||||
$user->setEmail($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');
|
(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