From f37feef1e69fba389b4253f0771d17d60ca176f9 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 3 Aug 2026 09:40:00 +0200 Subject: [PATCH] Fix customer login email session refresh (#337) Invalidate cached auth sessions for every active customer token and return the persisted canonical login email. --- .../nginx/app/routes/userSecurityRoute.php | 22 ++- .../app/tests/Api/UserSecurityApiTest.php | 147 ++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 services/nginx/app/tests/Api/UserSecurityApiTest.php diff --git a/services/nginx/app/routes/userSecurityRoute.php b/services/nginx/app/routes/userSecurityRoute.php index 0e7d45fb..960f0d02 100644 --- a/services/nginx/app/routes/userSecurityRoute.php +++ b/services/nginx/app/routes/userSecurityRoute.php @@ -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 ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Api/UserSecurityApiTest.php b/services/nginx/app/tests/Api/UserSecurityApiTest.php new file mode 100644 index 00000000..5fae95bc --- /dev/null +++ b/services/nginx/app/tests/Api/UserSecurityApiTest.php @@ -0,0 +1,147 @@ +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); +});