From a91e3c6d4272d6091af80bae74a0d9d30ce309ae Mon Sep 17 00:00:00 2001 From: BacLuc Date: Sat, 9 Aug 2025 16:21:11 +0200 Subject: [PATCH 1/4] refresh the access token in the background Use gesdinet/jwt-refresh-token-bundle to achieve this. The access token lifetime was reduced to 30 mins according to https://cloud.google.com/apigee/docs/api-platform/antipatterns/oauth-long-expiration as a starting point. Do not log out if the refresh in the background fails, the user might want to backup the content he is currently editing. One proposed pattern is to intercept 401 responses and refresh the token when the requests fail. Because we often have multiple requests running, this did not work well. Now we have a timeout in the background which refreshes the access token periodically. closes #4898 --- api/composer.json | 1 + api/composer.lock | 82 ++++++++++++++++++- api/config/bundles.php | 2 + .../packages/gesdinet_jwt_refresh_token.yaml | 11 +++ api/config/packages/security.yaml | 4 + api/config/routes.yaml | 3 + api/config/services.yaml | 6 ++ .../schema/Version20250809140557.php | 26 ++++++ api/src/Entity/RefreshToken.php | 10 +++ api/src/OpenApi/JwtDecorator.php | 2 +- api/src/OpenApi/RefreshTokenDecorator.php | 33 ++++++++ .../Normalizer/UriTemplateNormalizer.php | 1 + api/symfony.lock | 14 ++++ api/tests/Api/FirewallTest.php | 1 + .../SnapshotTests/EndpointPerformanceTest.php | 1 + .../SnapshotTests/ResponseSnapshotTest.php | 1 + ...est__testOpenApiSpecMatchesSnapshot__1.yml | 10 +++ ...t__testRootEndpointMatchesSnapshot__1.json | 3 + .../Normalizer/UriTemplateNormalizerTest.php | 6 ++ frontend/src/main.js | 4 + frontend/src/plugins/auth.js | 53 ++++++++++++ frontend/src/plugins/store/auth.js | 13 +++ 22 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 api/config/packages/gesdinet_jwt_refresh_token.yaml create mode 100644 api/migrations/schema/Version20250809140557.php create mode 100644 api/src/Entity/RefreshToken.php create mode 100644 api/src/OpenApi/RefreshTokenDecorator.php diff --git a/api/composer.json b/api/composer.json index a08ce2cbb3..b4168da572 100644 --- a/api/composer.json +++ b/api/composer.json @@ -23,6 +23,7 @@ "exercise/htmlpurifier-bundle": "5.1", "friendsofsymfony/http-cache": "3.1.1", "friendsofsymfony/http-cache-bundle": "3.2.0", + "gesdinet/jwt-refresh-token-bundle": "1.5.0", "google/recaptcha": "1.3.1", "guzzlehttp/guzzle": "7.10.0", "knpuniversity/oauth2-client-bundle": "2.18.4", diff --git a/api/composer.lock b/api/composer.lock index 824ea9ac5d..ba95f57a1a 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fa180abac53ec02e32860392dca5a40b", + "content-hash": "d0305ff3ccf8b1e8903564b46aa973c7", "packages": [ { "name": "api-platform/doctrine-common", @@ -3286,6 +3286,86 @@ ], "time": "2025-04-04T17:19:27+00:00" }, + { + "name": "gesdinet/jwt-refresh-token-bundle", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/markitosgv/JWTRefreshTokenBundle.git", + "reference": "8706b0d8dcb26610358ba3328ec412315b55c3cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/markitosgv/JWTRefreshTokenBundle/zipball/8706b0d8dcb26610358ba3328ec412315b55c3cd", + "reference": "8706b0d8dcb26610358ba3328ec412315b55c3cd", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^1.3.3|^2.0|^3.0|^4.0", + "lexik/jwt-authentication-bundle": "^2.0|^3.0", + "php": ">=7.4", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/deprecation-contracts": "^2.1|^3.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/polyfill-php80": "^1.15", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/security-bundle": "^5.4|^6.0|^7.0", + "symfony/security-core": "^5.4|^6.0|^7.0", + "symfony/security-http": "^5.4|^6.0|^7.0" + }, + "conflict": { + "doctrine/mongodb-odm": "<2.2", + "doctrine/orm": "<2.7" + }, + "require-dev": { + "doctrine/annotations": "^1.13|^2.0", + "doctrine/cache": "^1.11|^2.0", + "doctrine/mongodb-odm": "^2.2", + "doctrine/orm": "^2.7|^3.0", + "matthiasnoback/symfony-config-test": "^4.2|^5.0", + "matthiasnoback/symfony-dependency-injection-test": "^4.2|^5.0", + "phpunit/phpunit": "^9.5", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/security-guard": "^5.4" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Gesdinet\\JWTRefreshTokenBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marcos Gómez Vilches", + "email": "marcos@gesdinet.com" + } + ], + "description": "Implements a refresh token system over Json Web Tokens in Symfony", + "keywords": [ + "jwt refresh token bundle symfony json web" + ], + "support": { + "issues": "https://github.com/markitosgv/JWTRefreshTokenBundle/issues", + "source": "https://github.com/markitosgv/JWTRefreshTokenBundle/tree/v1.5.0" + }, + "time": "2025-06-24T13:08:37+00:00" + }, { "name": "google/recaptcha", "version": "1.3.1", diff --git a/api/config/bundles.php b/api/config/bundles.php index 8df45f3cfd..1c9079527d 100644 --- a/api/config/bundles.php +++ b/api/config/bundles.php @@ -6,6 +6,7 @@ use Exercise\HTMLPurifierBundle\ExerciseHTMLPurifierBundle; use Fidry\AliceDataFixtures\Bridge\Symfony\FidryAliceDataFixturesBundle; use FOS\HttpCacheBundle\FOSHttpCacheBundle; +use Gesdinet\JWTRefreshTokenBundle\GesdinetJWTRefreshTokenBundle; use Hautelook\AliceBundle\HautelookAliceBundle; use KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle; use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle; @@ -44,4 +45,5 @@ SentryBundle::class => ['all' => true], TwigExtraBundle::class => ['all' => true], FOSHttpCacheBundle::class => ['all' => true], + GesdinetJWTRefreshTokenBundle::class => ['all' => true], ]; diff --git a/api/config/packages/gesdinet_jwt_refresh_token.yaml b/api/config/packages/gesdinet_jwt_refresh_token.yaml new file mode 100644 index 0000000000..b10f394de4 --- /dev/null +++ b/api/config/packages/gesdinet_jwt_refresh_token.yaml @@ -0,0 +1,11 @@ +gesdinet_jwt_refresh_token: + cookie: + enabled: true + same_site: strict + path: / + http_only: true + secure: '%env(bool:COOKIE_SECURE)%' + remove_token_from_body: true + refresh_token_class: App\Entity\RefreshToken + single_use: true + token_parameter_name: '%env(COOKIE_PREFIX)%refresh_token' diff --git a/api/config/packages/security.yaml b/api/config/packages/security.yaml index 5e9a060ede..6a6148083d 100644 --- a/api/config/packages/security.yaml +++ b/api/config/packages/security.yaml @@ -28,6 +28,7 @@ security: lazy: true provider: app_user_provider user_checker: App\Security\UserStatusChecker + entry_point: jwt json_login: check_path: /authentication_token username_path: identifier @@ -35,6 +36,8 @@ security: success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: lexik_jwt_authentication.handler.authentication_failure jwt: ~ + refresh_jwt: + check_path: /token/refresh custom_authenticators: - App\Security\OAuth\GoogleAuthenticator - App\Security\OAuth\HitobitoAuthenticator @@ -46,6 +49,7 @@ security: - { path: ^/auth, roles: PUBLIC_ACCESS } # OAuth and resend password endpoints - { path: ^/content_types, roles: PUBLIC_ACCESS } # Content types is more or less static and the same for all camps - { path: ^/invitations/.*/(find|reject), roles: PUBLIC_ACCESS } + - { path: ^/token/refresh, roles: PUBLIC_ACCESS } - { path: ^/users$, methods: [POST], roles: PUBLIC_ACCESS } # register - { path: ^/users/.*/activate$, methods: [PATCH], roles: PUBLIC_ACCESS } - { path: .*, roles: [ROLE_USER] } # Protect all other routes must be at the end diff --git a/api/config/routes.yaml b/api/config/routes.yaml index 7169860d33..73fbaa045b 100644 --- a/api/config/routes.yaml +++ b/api/config/routes.yaml @@ -4,3 +4,6 @@ authentication_token: path: /authentication_token methods: ['POST'] +api_refresh_token: + path: /token/refresh + methods: ['POST'] diff --git a/api/config/services.yaml b/api/config/services.yaml index d8ce8c5382..ac8a9ca8cc 100644 --- a/api/config/services.yaml +++ b/api/config/services.yaml @@ -103,6 +103,12 @@ services: App\OpenApi\OAuthDecorator: decorates: 'api_platform.openapi.factory' + App\OpenApi\RefreshTokenDecorator: + decorates: 'api_platform.openapi.factory' + arguments: + - '@.inner' + - '%env(COOKIE_PREFIX)%' + App\OAuth\UrlGeneratorDecorator: class: App\OAuth\UrlGeneratorDecorator arguments: diff --git a/api/migrations/schema/Version20250809140557.php b/api/migrations/schema/Version20250809140557.php new file mode 100644 index 0000000000..aaa3db8ce2 --- /dev/null +++ b/api/migrations/schema/Version20250809140557.php @@ -0,0 +1,26 @@ +addSql('CREATE TABLE refresh_tokens (refresh_token VARCHAR(128) NOT NULL, username VARCHAR(255) NOT NULL, valid TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_9BACE7E1C74F2195 ON refresh_tokens (refresh_token)'); + } + + public function down(Schema $schema): void { + $this->addSql('DROP TABLE refresh_tokens'); + } +} diff --git a/api/src/Entity/RefreshToken.php b/api/src/Entity/RefreshToken.php new file mode 100644 index 0000000000..91810545d6 --- /dev/null +++ b/api/src/Entity/RefreshToken.php @@ -0,0 +1,10 @@ + [ - 'description' => "Get a JWT token split across the two cookies {$cookiePrefix}jwt_hp and {$cookiePrefix}jwt_s", + 'description' => "Get a JWT token split across the two cookies {$cookiePrefix}jwt_hp and {$cookiePrefix}jwt_s. Also returns a refresh token in {$cookiePrefix}refresh_token", ], ], summary: 'Log in using email and password.', diff --git a/api/src/OpenApi/RefreshTokenDecorator.php b/api/src/OpenApi/RefreshTokenDecorator.php new file mode 100644 index 0000000000..d65e24416b --- /dev/null +++ b/api/src/OpenApi/RefreshTokenDecorator.php @@ -0,0 +1,33 @@ +decorated)($context); + + $cookiePrefix = $this->cookiePrefix; + $pathItem = new Model\PathItem( + ref: 'JWT Token Refresh', + post: new Model\Operation( + operationId: 'postRefreshToken', + tags: ['JWT Refresh'], + responses: [ + '204' => [ + 'description' => "Get a refreshed JWT token split across the two cookies {$cookiePrefix}jwt_hp and {$cookiePrefix}jwt_s. Also returns a new refresh token {$cookiePrefix}refresh_token.", + ], + ], + summary: 'Refresh token.', + ), + ); + $openApi->getPaths()->addPath('/token/refresh', $pathItem); + + return $openApi; + } +} diff --git a/api/src/Serializer/Normalizer/UriTemplateNormalizer.php b/api/src/Serializer/Normalizer/UriTemplateNormalizer.php index 52bbfa5110..0790886f28 100644 --- a/api/src/Serializer/Normalizer/UriTemplateNormalizer.php +++ b/api/src/Serializer/Normalizer/UriTemplateNormalizer.php @@ -51,6 +51,7 @@ public function normalize($data, $format = null, array $context = []): array|\Ar $result['_links']['oauthPbsmidata'] = ['href' => $this->urlGenerator->generate('connect_pbsmidata_start').'{?callback}', 'templated' => true]; $result['_links']['oauthCevidb'] = ['href' => $this->urlGenerator->generate('connect_cevidb_start').'{?callback}', 'templated' => true]; $result['_links']['oauthJubladb'] = ['href' => $this->urlGenerator->generate('connect_jubladb_start').'{?callback}', 'templated' => true]; + $result['_links']['refreshToken'] = ['href' => $this->urlGenerator->generate('api_refresh_token')]; $result['_links']['resetPassword'] = ['href' => $this->urlGenerator->generate('_api_/auth/reset_password{._format}_post').'{/id}', 'templated' => true]; $result['_links']['resendActivation'] = ['href' => $this->urlGenerator->generate('_api_/auth/resend_activation{._format}_post'), 'templated' => false]; diff --git a/api/symfony.lock b/api/symfony.lock index d8d349716f..4deb9870bf 100644 --- a/api/symfony.lock +++ b/api/symfony.lock @@ -174,6 +174,20 @@ "gedmo/doctrine-extensions": { "version": "v3.0.5" }, + "gesdinet/jwt-refresh-token-bundle": { + "version": "1.5", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.0", + "ref": "2390b4ed5c195e0b3f6dea45221f3b7c0af523a0" + }, + "files": [ + "config/packages/gesdinet_jwt_refresh_token.yaml", + "config/routes/gesdinet_jwt_refresh_token.yaml", + "src/Entity/RefreshToken.php" + ] + }, "google/recaptcha": { "version": "1.2", "recipe": { diff --git a/api/tests/Api/FirewallTest.php b/api/tests/Api/FirewallTest.php index 9677862463..360fd16a86 100644 --- a/api/tests/Api/FirewallTest.php +++ b/api/tests/Api/FirewallTest.php @@ -116,6 +116,7 @@ private static function isProtectedByFirewall(mixed $endpoint): bool { '/auth/resend_activation' => false, '/content_types' => false, '/invitations' => false, + '/token/refresh' => false, default => true }; } diff --git a/api/tests/Api/SnapshotTests/EndpointPerformanceTest.php b/api/tests/Api/SnapshotTests/EndpointPerformanceTest.php index 60a2de7e8a..346c626aeb 100644 --- a/api/tests/Api/SnapshotTests/EndpointPerformanceTest.php +++ b/api/tests/Api/SnapshotTests/EndpointPerformanceTest.php @@ -244,6 +244,7 @@ private static function getCollectionEndpoints() { '/auth/resend_activation' => false, '/invitations' => false, '/personal_invitations' => false, + '/token/refresh' => false, default => true }; }); diff --git a/api/tests/Api/SnapshotTests/ResponseSnapshotTest.php b/api/tests/Api/SnapshotTests/ResponseSnapshotTest.php index 0eb1a4c37e..388a56d2f3 100644 --- a/api/tests/Api/SnapshotTests/ResponseSnapshotTest.php +++ b/api/tests/Api/SnapshotTests/ResponseSnapshotTest.php @@ -117,6 +117,7 @@ public static function getCollectionEndpoints() { '/auth/resend_activation' => false, '/invitations' => false, '/personal_invitations' => false, + '/token/refresh' => false, '/users' => false, default => true }; diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml index 6738d1e845..4e66532170 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml +++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml @@ -34692,6 +34692,16 @@ paths: summary: 'Updates the ScheduleEntry resource.' tags: - ScheduleEntry + /token/refresh: + post: + operationId: postRefreshToken + responses: + 204: + description: 'Get a refreshed JWT token split across the two cookies example_com_jwt_hp and example_com_jwt_s. Also returns a new refresh token example_com_refresh_token.' + summary: 'Refresh token.' + tags: + - 'JWT Refresh' + ref: 'JWT Token Refresh' /users: get: deprecated: false diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testRootEndpointMatchesSnapshot__1.json b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testRootEndpointMatchesSnapshot__1.json index 0297f21a1b..5e13e3838c 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testRootEndpointMatchesSnapshot__1.json +++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testRootEndpointMatchesSnapshot__1.json @@ -111,6 +111,9 @@ "href": "/profiles{/id}{?user.collaborations.camp,user.collaborations.camp[],user,user[]}", "templated": true }, + "refreshToken": { + "href": "/token/refresh" + }, "resendActivation": { "href": "/auth/resend_activation", "templated": false diff --git a/api/tests/Serializer/Normalizer/UriTemplateNormalizerTest.php b/api/tests/Serializer/Normalizer/UriTemplateNormalizerTest.php index a56114b449..e788a5410d 100644 --- a/api/tests/Serializer/Normalizer/UriTemplateNormalizerTest.php +++ b/api/tests/Serializer/Normalizer/UriTemplateNormalizerTest.php @@ -45,6 +45,9 @@ protected function setUp(): void { case 'connect_jubladb_start': return '/auth/jubladb'; + case 'api_refresh_token': + return '/token/refresh'; + case '_api_/auth/resend_activation{._format}_post': return '/auth/resend_activation'; @@ -91,6 +94,9 @@ protected function setUp(): void { 'href' => '/auth/reset_password{/id}', 'templated' => true, ], + 'refreshToken' => [ + 'href' => '/token/refresh', + ], ]; } diff --git a/frontend/src/main.js b/frontend/src/main.js index 5714f00f51..6f98317b3b 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -23,6 +23,7 @@ import 'vue-toastification/dist/index.css' import { ClickOutside, Resize } from 'vuetify/lib/directives' import ResizeObserver from 'v-resize-observer' +import { initRefresh } from '@/plugins/auth.js' const env = getEnv() if (env && env.SENTRY_FRONTEND_DSN) { @@ -63,3 +64,6 @@ new Vue({ unhead, render: (h) => h(App), }).$mount('#app') + +// noinspection JSIgnoredPromiseFromCall +initRefresh() diff --git a/frontend/src/plugins/auth.js b/frontend/src/plugins/auth.js index 0c93a31945..f830367d54 100644 --- a/frontend/src/plugins/auth.js +++ b/frontend/src/plugins/auth.js @@ -11,6 +11,54 @@ axios.interceptors.response.use(null, (error) => { return Promise.reject(error) }) +let scheduledRefresh = null + +export async function initRefresh() { + // Cookies.get was not reliable to detect if the cookie was present. + if (store.getters.hasLoggedOut) { + return + } + let originalTarget = `${window.location.pathname}` + if (window.location.search) { + originalTarget += `?${window.location.search}` + } + let refreshedSuccessfully = false + if (!isLoggedIn()) { + try { + await refresh() + } catch { + /* empty */ + } + if (!isLoggedIn()) { + return + } + refreshedSuccessfully = true + } + rescheduleRefresh() + if (refreshedSuccessfully) { + await router.replace(originalTarget) + } +} + +function rescheduleRefresh() { + if (scheduledRefresh != null) { + clearTimeout(scheduledRefresh) + } + const timeout = (getJWTExpirationTimestamp() - Date.now()) / 2 + const realTimeout = Math.max(Math.min(timeout, 30 * 60 * 1000), 2 * 60 * 1000) + scheduledRefresh = setTimeout(refreshAndSchedule, realTimeout) +} + +async function refreshAndSchedule() { + await refresh() + rescheduleRefresh() +} + +async function refresh() { + const url = await apiStore.href(apiStore.get(), 'refreshToken') + return apiStore.post(url) +} + function getJWTPayloadFromCookie() { const jwtHeaderAndPayload = Cookies.get(headerAndPayloadCookieName()) if (!jwtHeaderAndPayload) return '' @@ -58,6 +106,7 @@ export function isAdmin() { async function login(email, password) { const url = await apiStore.href(apiStore.get(), 'login') return apiStore.post(url, { identifier: email, password: password }).then(() => { + rescheduleRefresh() return isLoggedIn() }) } @@ -141,6 +190,9 @@ async function loginJublaDB() { } export async function logout() { + if (scheduledRefresh != null) { + clearTimeout(scheduledRefresh) + } Cookies.remove(headerAndPayloadCookieName()) store.commit('logout') return router @@ -159,6 +211,7 @@ function cookiePrefix() { } export const auth = { + initRefresh, isLoggedIn, isAdmin, login, diff --git a/frontend/src/plugins/store/auth.js b/frontend/src/plugins/store/auth.js index 560c2c8e0c..23ef38d9c3 100644 --- a/frontend/src/plugins/store/auth.js +++ b/frontend/src/plugins/store/auth.js @@ -1,5 +1,12 @@ import { apiStore } from '@/plugins/store/index' +/** + * Because we cannot differentiate between a expired cookie and a deleted cookie, + * we use localStorage to track if a user has logged out and does not want + * to refresh the access token. + */ +const HAS_LOGGED_OUT = 'hasLoggedOut' + export const state = { user: null, } @@ -7,10 +14,12 @@ export const state = { export const mutations = { login(state, user) { state.user = user + window.localStorage.setItem(HAS_LOGGED_OUT, 'false') }, logout(state) { state.user = null + window.localStorage.setItem(HAS_LOGGED_OUT, 'true') }, } export const getters = { @@ -21,6 +30,10 @@ export const getters = { getLoggedInUser: (authState) => { return authState.user ? apiStore.get(authState.user._meta.self) : authState.user }, + + hasLoggedOut() { + return window.localStorage.getItem(HAS_LOGGED_OUT) === 'true' + }, } export default { From 92171b55c5dccce787ebdc916aded502518c4605 Mon Sep 17 00:00:00 2001 From: BacLuc Date: Sat, 30 Aug 2025 20:20:52 +0200 Subject: [PATCH 2/4] api: make AUTHENTICATION_TOKEN_TTL configurable Don't add it to the workflows yet because https://github.com/ecamp/ecamp3/pull/8025 will make this a lot easier. Has merge conflict with https://github.com/ecamp/ecamp3/pull/8025 The ttl will be made shorter ones all users have refresh tokens. --- .helm/ecamp3/templates/api_configmap.yaml | 3 +++ .helm/ecamp3/values.yaml | 1 + api/.env | 2 ++ api/config/packages/lexik_jwt_authentication.yaml | 4 +--- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.helm/ecamp3/templates/api_configmap.yaml b/.helm/ecamp3/templates/api_configmap.yaml index 589c51d203..055aa233d9 100644 --- a/.helm/ecamp3/templates/api_configmap.yaml +++ b/.helm/ecamp3/templates/api_configmap.yaml @@ -7,6 +7,9 @@ metadata: {{- include "app.commonLabels" . | nindent 4 }} data: ADDITIONAL_TRUSTED_HOSTS: {{ .Values.domain | quote }} + {{- if not (.Values.api.authenticationTokenTtl | empty) }} + AUTHENTICATION_TOKEN_TTL: {{ .Values.api.authenticationTokenTtl | quote }} + {{- end }} COOKIE_PREFIX: {{ include "api.cookiePrefix" . | quote }} APP_ENV: {{ .Values.api.appEnv | quote }} APP_DEBUG: {{ .Values.api.appDebug | quote }} diff --git a/.helm/ecamp3/values.yaml b/.helm/ecamp3/values.yaml index 58a0342d8f..e36f706d05 100644 --- a/.helm/ecamp3/values.yaml +++ b/.helm/ecamp3/values.yaml @@ -18,6 +18,7 @@ featureToggle: checklist: false # enables checklist feature in frontend api: + authenticationTokenTtl: subpath: "/api" image: repository: "docker.io/ecamp/ecamp3-api" diff --git a/api/.env b/api/.env index cf58c589b6..661319cbca 100644 --- a/api/.env +++ b/api/.env @@ -81,4 +81,6 @@ MAIL_FROM_NAME="eCamp v3" RECAPTCHA_SECRET="disabled" ###< google/recaptcha ### +# Tokens are valid for 12 hours.. +AUTHENTICATION_TOKEN_TTL=43200 TRANSLATE_ERRORS_TO_LOCALES="en,de,fr,it,rm" diff --git a/api/config/packages/lexik_jwt_authentication.yaml b/api/config/packages/lexik_jwt_authentication.yaml index 9c41d2d8c3..f2e3b4e20c 100644 --- a/api/config/packages/lexik_jwt_authentication.yaml +++ b/api/config/packages/lexik_jwt_authentication.yaml @@ -7,9 +7,7 @@ lexik_jwt_authentication: public_key: '%env(resolve:JWT_PUBLIC_KEY)%' pass_phrase: '%env(JWT_PASSPHRASE)%' - # Tokens are valid for 12 hours, should be safe because we never expose the whole token to JavaScript. - # Of course it would be even better to have only short-lived tokens but renew them on every request. - token_ttl: 43200 + token_ttl: '%env(AUTHENTICATION_TOKEN_TTL)%' # Read the JWT token from a split cookie: The [api-domain]_jwt_hp and [api-domain]_jwt_s cookies are combined with a period (.) # to form the full JWT token. From 0c94eaaf24f00c51baeef25fe2fad0df133146e9 Mon Sep 17 00:00:00 2001 From: BacLuc Date: Sat, 30 Aug 2025 20:47:34 +0200 Subject: [PATCH 3/4] frontend: perform token refresh on visibilityChange to visible That you also refresh the token when you reactivate the tab. Using the vuex store to get this value was not reliable when the tab was reactivated. -> get it directly from localStorage Issue: #4898 --- frontend/src/App.vue | 7 +++++++ frontend/src/plugins/auth.js | 3 ++- frontend/src/plugins/store/auth.js | 6 +++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f0f57a09ce..157677d534 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -31,6 +31,7 @@ export default { window.addEventListener('offline', this.offlineListener) window.addEventListener('online', this.onlineListener) + window.addEventListener('visibilitychange', this.visibilityChangeListener) }, async mounted() { if (this.$auth.isLoggedIn()) { @@ -53,6 +54,12 @@ export default { onlineListener() { this.offline = false }, + async visibilityChangeListener() { + if (document.visibilityState !== 'visible') { + return + } + await this.$auth.initRefresh() + }, }, } diff --git a/frontend/src/plugins/auth.js b/frontend/src/plugins/auth.js index f830367d54..71129888a4 100644 --- a/frontend/src/plugins/auth.js +++ b/frontend/src/plugins/auth.js @@ -1,5 +1,6 @@ import axios from 'axios' import { apiStore, store } from '@/plugins/store' +import { hasLoggedOutFromLocalStorage } from '@/plugins/store/auth.js' import router from '@/router' import Cookies from 'js-cookie' import { getEnv } from '@/environment.js' @@ -15,7 +16,7 @@ let scheduledRefresh = null export async function initRefresh() { // Cookies.get was not reliable to detect if the cookie was present. - if (store.getters.hasLoggedOut) { + if (hasLoggedOutFromLocalStorage()) { return } let originalTarget = `${window.location.pathname}` diff --git a/frontend/src/plugins/store/auth.js b/frontend/src/plugins/store/auth.js index 23ef38d9c3..7608f81398 100644 --- a/frontend/src/plugins/store/auth.js +++ b/frontend/src/plugins/store/auth.js @@ -30,10 +30,10 @@ export const getters = { getLoggedInUser: (authState) => { return authState.user ? apiStore.get(authState.user._meta.self) : authState.user }, +} - hasLoggedOut() { - return window.localStorage.getItem(HAS_LOGGED_OUT) === 'true' - }, +export function hasLoggedOutFromLocalStorage() { + return window.localStorage.getItem(HAS_LOGGED_OUT) === 'true' } export default { From c483357888fbba5270954c07b965d73ab4c97a68 Mon Sep 17 00:00:00 2001 From: BacLuc Date: Sat, 30 Aug 2025 20:48:17 +0200 Subject: [PATCH 4/4] frontend: perform token refresh when you have connectivity again Issue: #4898 --- frontend/src/App.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 157677d534..1677c7d7de 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -51,8 +51,9 @@ export default { offlineListener() { this.offline = true }, - onlineListener() { + async onlineListener() { this.offline = false + await this.$auth.initRefresh() }, async visibilityChangeListener() { if (document.visibilityState !== 'visible') {