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("\"", "\\\"")}\"") }