From c16d2a6e4d9d395658085187e61256bb986106ae Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 15 May 2026 08:27:36 +0200 Subject: [PATCH] Implement Google OAuth linking functionality. Update backend to handle linking existing accounts with Google, including state token management. Enhance frontend to support linking process, including new UI components for user input and feedback. Update mobile app to handle OAuth callbacks and integrate linking features. Refactor related services and controllers for improved error handling and user experience. --- GOOGLE_OAUTH_MANUAL_STEPS.md | 83 +++++++ backend/env.production.template | 4 +- backend/src/config/passport.js | 4 +- backend/src/controllers/OAuthController.js | 91 +++++++- backend/src/routes/auth.js | 4 +- backend/src/services/OAuthService.js | 202 ++++++++++++------ frontend/src/views/OAuthCallback.vue | 112 ++++++++-- frontend/src/views/Profile.vue | 31 ++- .../composeApp/release/composeApp-release.aab | Bin 14311531 -> 14329447 bytes .../composeApp/src/main/AndroidManifest.xml | 11 +- .../de/tsschulz/timeclock/MainActivity.kt | 41 ++++ .../tsschulz/timeclock/data/api/AuthDtos.kt | 19 ++ .../timeclock/data/api/TimeClockApiClient.kt | 26 +++ .../timeclock/data/auth/AuthRepository.kt | 33 +++ .../data/settings/SettingsRepository.kt | 8 + .../de/tsschulz/timeclock/ui/TimeClockApp.kt | 18 ++ .../timeclock/ui/auth/AuthViewModel.kt | 68 ++++++ .../tsschulz/timeclock/ui/auth/LoginScreen.kt | 123 +++++++---- .../timeclock/ui/settings/SettingsScreens.kt | 3 + .../ui/settings/SettingsViewModel.kt | 14 ++ 20 files changed, 768 insertions(+), 127 deletions(-) create mode 100644 GOOGLE_OAUTH_MANUAL_STEPS.md diff --git a/GOOGLE_OAUTH_MANUAL_STEPS.md b/GOOGLE_OAUTH_MANUAL_STEPS.md new file mode 100644 index 0000000..250b12e --- /dev/null +++ b/GOOGLE_OAUTH_MANUAL_STEPS.md @@ -0,0 +1,83 @@ +# Google OAuth: manuelle Restarbeiten + +Stand: 2026-05-15 + +## 1. Google Cloud Console vorbereiten + +1. In der Google Cloud Console das passende Projekt öffnen oder ein neues Projekt anlegen. +2. Unter `APIs & Services > OAuth consent screen` den Consent Screen konfigurieren. +3. App-Name, Support-E-Mail und Entwicklerkontakt eintragen. +4. Scopes reichen für diese App: + - `profile` + - `email` +5. Unter `APIs & Services > Credentials` einen OAuth-Client vom Typ `Web application` anlegen. +6. Als Authorized redirect URI eintragen: + - Produktion: `https://stechuhr3.tsschulz.de/api/auth/google/callback` + - Lokal, falls benötigt: `http://localhost:3010/api/auth/google/callback` +7. `Client ID` und `Client Secret` notieren. + +## 2. Server-Environment setzen + +Auf dem Server in der produktiven Environment-Datei folgende Werte setzen: + +```env +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_CALLBACK_URL=https://stechuhr3.tsschulz.de/api/auth/google/callback +API_PUBLIC_URL=https://stechuhr3.tsschulz.de/api +FRONTEND_URL=https://stechuhr3.tsschulz.de +SESSION_SECRET= +JWT_SECRET= +``` + +Wichtig: `GOOGLE_CALLBACK_URL` muss exakt mit der Redirect URI in der Google Console übereinstimmen. + +## 3. Backend deployen und neu starten + +1. Code auf den Server bringen. +2. Backend neu starten, z. B. über das vorhandene Deploy-Skript oder PM2. +3. Log prüfen. Erwartet: + +```text +✅ Google OAuth konfiguriert +``` + +Wenn stattdessen `Google OAuth nicht konfiguriert` erscheint, fehlen `GOOGLE_CLIENT_ID` oder `GOOGLE_CLIENT_SECRET` im laufenden Prozess. + +## 4. Web-Frontend deployen + +1. Frontend neu bauen. +2. Neue `dist`-Dateien deployen. +3. Browser-Cache bei Bedarf leeren. + +## 5. Android-App neu bauen/installieren + +1. Debug/APK oder Release/AAB neu bauen. +2. App neu installieren. +3. Prüfen, dass Android den Deep Link `timeclock://oauth-callback` öffnet. + +## 6. Testfälle + +1. Google-Account ohne vorhandene TimeClock-E-Mail: + - `Mit Google anmelden` + - Erwartung: neuer Account wird erstellt und eingeloggt. +2. Google-Account bereits verknüpft: + - `Mit Google anmelden` + - Erwartung: direkter Login. +3. Google-Account mit E-Mail eines bestehenden Passwort-Accounts: + - `Mit Google anmelden` + - Erwartung: Verknüpfungsabfrage. + - Bestehende E-Mail + Passwort eingeben. + - Erwartung: Account wird verknüpft und eingeloggt. +4. Eingeloggt unter `Persönliche Daten`: + - `Mit Google-Konto verknüpfen` + - Erwartung: Google Flow startet, danach Rückkehr und Verknüpfung. +5. Danach ausloggen und erneut `Mit Google anmelden`. + - Erwartung: direkter Login in denselben bestehenden Account. + +## 7. Falls etwas fehlschlägt + +- Google meldet `redirect_uri_mismatch`: Redirect URI in Google Console und `GOOGLE_CALLBACK_URL` vergleichen. +- Backend zeigt OAuth nicht konfiguriert: PM2/Deploy lädt die neuen Env-Werte nicht. Prozess mit aktualisierter Environment neu starten. +- Android kehrt nicht in die App zurück: prüfen, ob der Browser auf `timeclock://oauth-callback?...` weiterleitet und die neu gebaute App installiert ist. +- Verknüpfung schlägt fehl: prüfen, ob der alte Account wirklich ein Passwort hat. OAuth-only Accounts können sich nicht per Passwort bestätigen. diff --git a/backend/env.production.template b/backend/env.production.template index b634d3b..5e89792 100644 --- a/backend/env.production.template +++ b/backend/env.production.template @@ -56,7 +56,8 @@ EMAIL_FROM_NAME=TimeClock Zeiterfassung # Erstelle OAuth Credentials unter: https://console.cloud.google.com/ GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_CALLBACK_URL=https://stechuhr3.tsschulz.de/api/auth/oauth/google/callback +GOOGLE_CALLBACK_URL=https://stechuhr3.tsschulz.de/api/auth/google/callback +API_PUBLIC_URL=https://stechuhr3.tsschulz.de/api # ============================================================================= # SECURITY & CORS @@ -88,4 +89,3 @@ BACKUP_DIR=/var/backups/timeclock # Backup-Retention in Tagen BACKUP_RETENTION_DAYS=30 - diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js index e71d105..8f97705 100644 --- a/backend/src/config/passport.js +++ b/backend/src/config/passport.js @@ -17,8 +17,7 @@ class PassportConfig { }, async (accessToken, refreshToken, profile, done) => { try { - const result = await oauthService.authenticateWithProvider(profile, 'google'); - return done(null, result); + return done(null, { profile, provider: 'google' }); } catch (error) { return done(error, null); } @@ -45,4 +44,3 @@ class PassportConfig { module.exports = PassportConfig; - diff --git a/backend/src/controllers/OAuthController.js b/backend/src/controllers/OAuthController.js index f209fdc..b351b0a 100644 --- a/backend/src/controllers/OAuthController.js +++ b/backend/src/controllers/OAuthController.js @@ -1,4 +1,5 @@ const passport = require('passport'); +const oauthService = require('../services/OAuthService'); /** * OAuth Controller @@ -10,12 +11,38 @@ class OAuthController { * GET /api/auth/google */ googleAuth(req, res, next) { + const state = req.query.stateToken || oauthService.createStateToken({ + platform: req.query.platform === 'android' ? 'android' : 'web' + }); passport.authenticate('google', { scope: ['profile', 'email'], - session: false + session: false, + state })(req, res, next); } + /** + * Google OAuth-Verknüpfung für eingeloggte Benutzer starten + * POST /api/auth/google/link-url + */ + createGoogleLinkUrl(req, res) { + try { + const platform = req.body?.platform === 'android' ? 'android' : 'web'; + const stateToken = oauthService.createStateToken({ + mode: 'link', + platform, + userId: req.user.userId + }); + const baseUrl = process.env.API_PUBLIC_URL || `${req.protocol}://${req.get('host')}/api`; + res.json({ + success: true, + url: `${baseUrl}/auth/google?stateToken=${encodeURIComponent(stateToken)}` + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + } + /** * Google OAuth Callback * GET /api/auth/google/callback @@ -24,18 +51,67 @@ class OAuthController { passport.authenticate('google', { session: false, failureRedirect: `${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed` - }, (err, result) => { + }, async (err, result) => { if (err || !result) { console.error('Google OAuth Fehler:', err); return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`); } - // Redirect zum Frontend mit Token - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5010'; - res.redirect(`${frontendUrl}/oauth-callback?token=${result.token}`); + try { + const state = oauthService.verifyStateToken(req.query.state); + const authResult = await oauthService.completeOAuthLogin(result.profile, result.provider, { + linkUserId: state.mode === 'link' ? state.userId : null + }); + + const target = state.platform === 'android' + ? 'timeclock://oauth-callback' + : `${process.env.FRONTEND_URL || 'http://localhost:5010'}/oauth-callback`; + + if (authResult.requiresLink) { + const params = new URLSearchParams({ + pending: authResult.pendingToken, + email: authResult.email || '', + provider: result.provider + }); + return res.redirect(`${target}?${params.toString()}`); + } + + return res.redirect(`${target}?token=${encodeURIComponent(authResult.token)}`); + } catch (callbackError) { + console.error('Google OAuth Callback-Verarbeitung fehlgeschlagen:', callbackError); + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`); + } })(req, res, next); } + async linkExistingAccount(req, res) { + try { + const { pendingToken, email, password } = req.body; + if (!pendingToken || !email || !password) { + return res.status(400).json({ success: false, error: 'pendingToken, E-Mail und Passwort sind erforderlich' }); + } + const result = await oauthService.linkPendingToPasswordAccount(pendingToken, email, password); + res.json({ success: true, token: result.token, user: result.user }); + } catch (error) { + console.error('OAuth-Verknüpfung mit bestehendem Account fehlgeschlagen:', error); + res.status(401).json({ success: false, error: error.message }); + } + } + + async linkPendingToCurrentUser(req, res) { + try { + const { pendingToken } = req.body; + if (!pendingToken) { + return res.status(400).json({ success: false, error: 'pendingToken ist erforderlich' }); + } + const result = await oauthService.linkPendingToAuthenticatedUser(pendingToken, req.user.userId); + res.json({ success: true, token: result.token, user: result.user }); + } catch (error) { + console.error('OAuth-Verknüpfung fehlgeschlagen:', error); + res.status(400).json({ success: false, error: error.message }); + } + } + /** * OAuth-Identities für Benutzer abrufen * GET /api/auth/identities @@ -43,8 +119,6 @@ class OAuthController { async getIdentities(req, res) { try { const userId = req.user.userId; - const oauthService = require('../services/OAuthService'); - const identities = await oauthService.getUserIdentities(userId); res.json({ @@ -69,8 +143,6 @@ class OAuthController { try { const userId = req.user.userId; const { provider } = req.params; - const oauthService = require('../services/OAuthService'); - const unlinked = await oauthService.unlinkProvider(userId, provider); if (unlinked) { @@ -98,4 +170,3 @@ class OAuthController { module.exports = new OAuthController(); - diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 6b5ad8c..0a1c681 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -13,14 +13,16 @@ router.post('/reset-password', authController.resetPassword.bind(authController) // OAuth Routes (öffentlich) router.get('/google', oauthController.googleAuth.bind(oauthController)); router.get('/google/callback', oauthController.googleCallback.bind(oauthController)); +router.post('/oauth/link-existing', oauthController.linkExistingAccount.bind(oauthController)); // Geschützte Routes (Auth erforderlich) router.post('/logout', authenticateToken, authController.logout.bind(authController)); router.get('/me', authenticateToken, authController.getCurrentUser.bind(authController)); router.post('/change-password', authenticateToken, authController.changePassword.bind(authController)); router.get('/validate', authenticateToken, authController.validateToken.bind(authController)); +router.post('/google/link-url', authenticateToken, oauthController.createGoogleLinkUrl.bind(oauthController)); +router.post('/oauth/link-current', authenticateToken, oauthController.linkPendingToCurrentUser.bind(oauthController)); router.get('/identities', authenticateToken, oauthController.getIdentities.bind(oauthController)); router.delete('/identity/:provider', authenticateToken, oauthController.unlinkProvider.bind(oauthController)); module.exports = router; - diff --git a/backend/src/services/OAuthService.js b/backend/src/services/OAuthService.js index dc478e6..1cd2e81 100644 --- a/backend/src/services/OAuthService.js +++ b/backend/src/services/OAuthService.js @@ -19,6 +19,10 @@ class OAuthService { * @returns {Promise} Token und Benutzer-Info */ async authenticateWithProvider(profile, provider) { + return this.completeOAuthLogin(profile, provider); + } + + async completeOAuthLogin(profile, provider, options = {}) { const { User, AuthInfo, AuthIdentity, AuthToken } = database.getModels(); const providerId = profile.id; @@ -51,68 +55,150 @@ class OAuthService { // Bestehender OAuth-Benutzer authInfo = authIdentity.authInfo; user = authInfo.user; - } else { - // Neuer OAuth-Benutzer oder Verknüpfung mit bestehendem Account - - // Prüfen ob Benutzer mit dieser E-Mail bereits existiert - if (email) { - authInfo = await AuthInfo.findOne({ - where: { email }, - include: [{ - model: User, - as: 'user' - }] - }); + if (options.linkUserId && Number(user.id) !== Number(options.linkUserId)) { + throw new Error('Dieser Google-Account ist bereits mit einem anderen Benutzer verknüpft'); } + } else { + if (options.linkUserId) { + authInfo = await AuthInfo.findOne({ + where: { user_id: options.linkUserId }, + include: [{ model: User, as: 'user' }] + }); - if (authInfo) { - // Verknüpfe OAuth mit bestehendem Account + if (!authInfo) { + throw new Error('Benutzer für Verknüpfung nicht gefunden'); + } + + authIdentity = await AuthIdentity.create({ + auth_info_id: authInfo.id, + provider, + identity: providerId, + version: 0 + }); user = authInfo.user; - - authIdentity = await AuthIdentity.create({ - auth_info_id: authInfo.id, - provider, - identity: providerId, - version: 0 - }); } else { - // Neuen Benutzer erstellen - user = await User.create({ - full_name: displayName, - role: 0, - daily_hours: 8, - week_hours: 40, - week_workdays: 5, - preferred_title_type: 0, - version: 0, - last_change: new Date() - }); + // Neuer OAuth-Benutzer oder explizite Verknüpfung mit bestehendem Account - // Auth-Info erstellen (ohne Passwort für OAuth-only Accounts) - authInfo = await AuthInfo.create({ - user_id: user.id, - email: email || `${provider}_${providerId}@oauth.local`, - password_hash: '', // Kein Passwort für OAuth-only - password_method: 'oauth', - password_salt: '', - status: 1, - failed_login_attempts: 0, - email_token: '', - email_token_role: 0, - unverified_email: '', - version: 0 - }); + // Prüfen ob Benutzer mit dieser E-Mail bereits existiert + if (email) { + authInfo = await AuthInfo.findOne({ + where: { email }, + include: [{ + model: User, + as: 'user' + }] + }); + } - // OAuth-Identity erstellen - authIdentity = await AuthIdentity.create({ - auth_info_id: authInfo.id, - provider, - identity: providerId, - version: 0 - }); + if (authInfo) { + return { + requiresLink: true, + pendingToken: this.createPendingToken(profile, provider), + email, + displayName + }; + } else { + // Neuen Benutzer erstellen + user = await User.create({ + full_name: displayName, + role: 0, + daily_hours: 8, + week_hours: 40, + week_workdays: 5, + preferred_title_type: 0, + version: 0, + last_change: new Date() + }); + + // Auth-Info erstellen (ohne Passwort für OAuth-only Accounts) + authInfo = await AuthInfo.create({ + user_id: user.id, + email: email || `${provider}_${providerId}@oauth.local`, + password_hash: '', // Kein Passwort für OAuth-only + password_method: 'oauth', + password_salt: '', + status: 1, + failed_login_attempts: 0, + email_token: '', + email_token_role: 0, + unverified_email: '', + version: 0 + }); + + // OAuth-Identity erstellen + authIdentity = await AuthIdentity.create({ + auth_info_id: authInfo.id, + provider, + identity: providerId, + version: 0 + }); + } } } + return this.createLoginResult(user, authInfo, provider); + } + + createPendingToken(profile, provider) { + return jwt.sign( + { + provider, + providerId: profile.id, + email: profile.emails && profile.emails[0] ? profile.emails[0].value : null, + displayName: profile.displayName || profile.name || null, + type: 'oauth-pending' + }, + this.jwtSecret, + { expiresIn: '15m' } + ); + } + + verifyPendingToken(pendingToken) { + const data = jwt.verify(pendingToken, this.jwtSecret); + if (data.type !== 'oauth-pending' || !data.provider || !data.providerId) { + throw new Error('Ungültiger OAuth-Verknüpfungstoken'); + } + return data; + } + + createStateToken(data) { + return jwt.sign({ ...data, type: 'oauth-state' }, this.jwtSecret, { expiresIn: '15m' }); + } + + verifyStateToken(stateToken) { + if (!stateToken) return {}; + const data = jwt.verify(stateToken, this.jwtSecret); + if (data.type !== 'oauth-state') { + throw new Error('Ungültiger OAuth-State'); + } + return data; + } + + async linkPendingToPasswordAccount(pendingToken, email, password) { + const authService = require('./AuthService'); + const pending = this.verifyPendingToken(pendingToken); + const loginResult = await authService.login(email, password, '0'); + const profile = { + id: pending.providerId, + displayName: pending.displayName || loginResult.user.full_name, + emails: pending.email ? [{ value: pending.email }] : [] + }; + return this.completeOAuthLogin(profile, pending.provider, { linkUserId: loginResult.user.id }); + } + + async linkPendingToAuthenticatedUser(pendingToken, userId) { + const pending = this.verifyPendingToken(pendingToken); + const profile = { + id: pending.providerId, + displayName: pending.displayName || null, + emails: pending.email ? [{ value: pending.email }] : [] + }; + return this.completeOAuthLogin(profile, pending.provider, { linkUserId: userId }); + } + + createLoginResult(user, authInfo, provider) { + const { AuthToken } = database.getModels(); + // JWT Token generieren const token = jwt.sign( { @@ -129,14 +215,12 @@ class OAuthService { const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + 24); - await AuthToken.create({ + return AuthToken.create({ auth_info_id: authInfo.id, value: crypto.createHash('sha256').update(token).digest('hex'), expires: expiresAt, version: 0 - }); - - return { + }).then(() => ({ token, user: { id: user.id, @@ -145,7 +229,7 @@ class OAuthService { role: user.role, provider } - }; + })); } /** @@ -205,5 +289,3 @@ class OAuthService { module.exports = new OAuthService(); - - diff --git a/frontend/src/views/OAuthCallback.vue b/frontend/src/views/OAuthCallback.vue index 64fe514..346a743 100644 --- a/frontend/src/views/OAuthCallback.vue +++ b/frontend/src/views/OAuthCallback.vue @@ -1,6 +1,24 @@