From c23d260bdc8a43d136cfa8d100e8dc887f7ce664 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 15 May 2026 09:22:45 +0200 Subject: [PATCH] Implement state management for OAuth cookies in OAuthController, enhancing security and user experience during Google OAuth flow. Update OAuthCallback and Profile views to manage local storage for linking status and improve user feedback. Increment mobile app version to 0.8.0-alpha5 to reflect these changes. --- backend/src/controllers/OAuthController.js | 48 +++++++++++++++++++--- frontend/src/views/OAuthCallback.vue | 8 +++- frontend/src/views/Profile.vue | 7 +++- mobile-app/composeApp/build.gradle.kts | 4 +- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/backend/src/controllers/OAuthController.js b/backend/src/controllers/OAuthController.js index d937440..d229466 100644 --- a/backend/src/controllers/OAuthController.js +++ b/backend/src/controllers/OAuthController.js @@ -6,6 +6,26 @@ const oauthService = require('../services/OAuthService'); * Verwaltet OAuth-Logins */ class OAuthController { + createStateCookie(stateToken) { + const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''; + return `timeclock_oauth_state=${encodeURIComponent(stateToken)}; Max-Age=900; Path=/api/auth/google/callback; HttpOnly; SameSite=Lax${secure}`; + } + + clearStateCookie() { + const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''; + return `timeclock_oauth_state=; Max-Age=0; Path=/api/auth/google/callback; HttpOnly; SameSite=Lax${secure}`; + } + + readStateCookie(req) { + const cookieHeader = req.headers.cookie || ''; + const cookie = cookieHeader + .split(';') + .map(part => part.trim()) + .find(part => part.startsWith('timeclock_oauth_state=')); + + return cookie ? decodeURIComponent(cookie.slice('timeclock_oauth_state='.length)) : null; + } + /** * Google OAuth initiieren * GET /api/auth/google @@ -14,11 +34,24 @@ class OAuthController { const state = req.query.stateToken || oauthService.createStateToken({ platform: req.query.platform === 'android' ? 'android' : 'web' }); - passport.authenticate('google', { - scope: ['profile', 'email'], - session: false, + + const clientId = process.env.GOOGLE_CLIENT_ID; + const callbackUrl = process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3010/api/auth/google/callback'; + + if (!clientId) { + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_not_configured`); + } + + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: callbackUrl, + scope: 'profile email', state - })(req, res, next); + }); + + res.setHeader('Set-Cookie', this.createStateCookie(state)); + return res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`); } /** @@ -58,7 +91,8 @@ class OAuthController { } try { - const state = oauthService.verifyStateToken(req.query.state); + const stateToken = req.query.state || this.readStateCookie(req); + const state = oauthService.verifyStateToken(stateToken); const authResult = await oauthService.completeOAuthLogin(result.profile, result.provider, { linkUserId: state.mode === 'link' ? state.userId : null }); @@ -73,6 +107,7 @@ class OAuthController { email: authResult.email || '', provider: result.provider }); + res.setHeader('Set-Cookie', this.clearStateCookie()); return res.redirect(`${target}?${params.toString()}`); } @@ -83,9 +118,11 @@ class OAuthController { params.set('linked', result.provider); } + res.setHeader('Set-Cookie', this.clearStateCookie()); return res.redirect(`${target}?${params.toString()}`); } catch (callbackError) { console.error('Google OAuth Callback-Verarbeitung fehlgeschlagen:', callbackError); + res.setHeader('Set-Cookie', this.clearStateCookie()); return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`); } })(req, res, next); @@ -175,4 +212,3 @@ class OAuthController { } module.exports = new OAuthController(); - diff --git a/frontend/src/views/OAuthCallback.vue b/frontend/src/views/OAuthCallback.vue index 1328d76..15e5be2 100644 --- a/frontend/src/views/OAuthCallback.vue +++ b/frontend/src/views/OAuthCallback.vue @@ -71,6 +71,7 @@ async function linkExistingAccount() { throw new Error(result.error || 'Verknüpfung fehlgeschlagen') } pendingToken.value = '' + localStorage.removeItem('timeclock_oauth_link_started') await finishLogin(result.token, '/settings/profile?oauthLinked=google') } catch (error) { errorMessage.value = error.message || 'Verknüpfung fehlgeschlagen' @@ -84,6 +85,7 @@ onMounted(async () => { const error = route.query.error const pending = route.query.pending const linked = route.query.linked + const linkStarted = localStorage.getItem('timeclock_oauth_link_started') === 'google' if (error) { status.value = 'OAuth-Login fehlgeschlagen' @@ -102,7 +104,10 @@ onMounted(async () => { if (token) { try { - await finishLogin(token, linked ? '/settings/profile?oauthLinked=google' : '/') + if (linked || linkStarted) { + localStorage.removeItem('timeclock_oauth_link_started') + } + await finishLogin(token, linked || linkStarted ? '/settings/profile?oauthLinked=google' : '/') } catch (err) { status.value = 'Fehler beim Login' setTimeout(() => { @@ -180,4 +185,3 @@ onMounted(async () => { 100% { transform: rotate(360deg); } } - diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue index 8c0a1f8..5e61038 100644 --- a/frontend/src/views/Profile.vue +++ b/frontend/src/views/Profile.vue @@ -267,6 +267,7 @@ async function linkGoogle() { if (!response.ok || !result.url) { throw new Error(result.error || 'Google-Verknüpfung konnte nicht gestartet werden') } + localStorage.setItem('timeclock_oauth_link_started', 'google') window.location.href = result.url } catch (error) { await alert(`Fehler: ${error.message}`, 'Fehler') @@ -310,7 +311,11 @@ onMounted(async () => { ]) if (route.query.oauthLinked === 'google') { - await alert('Google-Konto wurde erfolgreich verknüpft', 'Erfolg') + if (googleLinked.value) { + await alert('Google-Konto wurde erfolgreich verknüpft', 'Erfolg') + } else { + await alert('Google-Rückmeldung erhalten, aber es ist keine Google-Verknüpfung am Account gespeichert.', 'Fehler') + } router.replace('/settings/profile') } }) diff --git a/mobile-app/composeApp/build.gradle.kts b/mobile-app/composeApp/build.gradle.kts index d9e3d3e..23cf35f 100644 --- a/mobile-app/composeApp/build.gradle.kts +++ b/mobile-app/composeApp/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "de.tsschulz.timeclock" minSdk = 26 targetSdk = 36 - versionCode = 5 - versionName = "0.8.0-alpha4" + versionCode = 6 + versionName = "0.8.0-alpha5" buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"") }