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 @@