From 8e8d93e4a6177f76ba249009ce8ecd71aa0cfe76 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Fri, 15 Aug 2025 19:48:45 +0200 Subject: [PATCH] Implement Google OAuth flow and enhance login functionality - Updated the Google Sign-In integration to utilize the new OAuth callback mechanism. - Added a redirect flow for Google authentication, improving user experience. - Enhanced error handling and user feedback during the login process. - Removed hardcoded Google client ID in favor of environment variable usage. - Introduced a new component for handling OAuth callbacks and updated the App component to manage authentication states accordingly. - Improved API route for processing OAuth callbacks, including token exchange and user verification. --- client/src/App.js | 9 +- client/src/components/Login.js | 140 ++++++++++++++++++++++--- client/src/components/OAuthCallback.js | 133 +++++++++++++++++++++++ package.json | 2 +- src/routes/auth.js | 92 ++++++++++++++++ webpack.config.js | 2 +- 6 files changed, 359 insertions(+), 19 deletions(-) create mode 100644 client/src/components/OAuthCallback.js diff --git a/client/src/App.js b/client/src/App.js index 6768d18..98a940d 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -14,6 +14,7 @@ import UploadIcon from '@mui/icons-material/Upload'; import AuthService from './services/AuthService'; import DataViewer from './components/DataViewer'; import Login from './components/Login'; +import OAuthCallback from './components/OAuthCallback'; const theme = createTheme({ palette: { @@ -230,6 +231,10 @@ class App extends Component { } }; + isOAuthCallback = () => { + return window.location.pathname === '/auth/callback'; + }; + render() { const { isAuthenticated, user, loading, currentView, documentStatus, processingStatus, snackbar } = this.state; @@ -448,7 +453,9 @@ class App extends Component { - {isAuthenticated ? ( + {this.isOAuthCallback() ? ( + + ) : isAuthenticated ? ( { if (window.google && window.google.accounts) { try { + // Note: Removed debug logging to avoid deprecated method warnings + + console.log('REACT_APP_GOOGLE_CLIENT_ID', process.env.REACT_APP_GOOGLE_CLIENT_ID); + console.log('Current origin for Google auth:', window.location.origin); + console.log('User agent:', navigator.userAgent); + window.google.accounts.id.initialize({ - client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID || 'your_google_client_id_here', + client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID, callback: this.handleGoogleResponse, auto_select: false, - cancel_on_tap_outside: true, + cancel_on_tap_outside: false, }); console.log('βœ… Google Sign-In initialized'); } catch (error) { @@ -48,6 +58,9 @@ class Login extends Component { }; handleGoogleResponse = (response) => { + // Mark FedCM as successful if we get here + this.fedcmSucceeded = true; + this.setState({ loading: true, error: null }); this.props.onLogin(response) .catch((error) => { @@ -70,6 +83,11 @@ class Login extends Component { errorMessage = '🚫 Zugriff verweigert: Ihre E-Mail-Adresse ist nicht autorisiert. Versuchen Sie, sich mit einem anderen Google-Konto anzumelden.'; } else if (error.message.includes('No authorized users configured')) { errorMessage = 'πŸ”’ Kein Zugriff: Derzeit sind keine Benutzer autorisiert. Wenden Sie sich an den Administrator.'; + } else if (error.message.includes('Not signed in with the identity provider') || + error.message.includes('NetworkError') || + error.message.includes('FedCM')) { + // FedCM failed, offer redirect option + errorMessage = 'πŸ”„ Schnelle Anmeldung nicht verfΓΌgbar. Versuchen Sie die Standard-Anmeldung.'; } else { // Show the actual error message from the server errorMessage = `❌ Anmeldefehler: ${error.message}`; @@ -92,29 +110,105 @@ class Login extends Component { return; } - // Clear any previous error - this.setState({ error: null, loading: false }); + // Clear any previous error and start loading + this.setState({ error: null, loading: true }); + // Try FedCM first (seamless for users already signed in to Google) + console.log('🎯 Trying FedCM first for optimal UX...'); + this.tryFedCMFirst(); + }; + + tryFedCMFirst = () => { if (window.google && window.google.accounts && window.google.accounts.id) { try { - window.google.accounts.id.prompt(); - } catch (error) { - console.error('Google prompt error:', error); - this.setState({ - error: 'Google-Anmeldung konnte nicht geladen werden. Die Seite wird aktualisiert, um es erneut zu versuchen.', - loading: true + console.log('βœ… Trying FedCM for seamless sign-in...'); + + // Listen for the specific FedCM errors that indicate no Google session + const originalConsoleError = console.error; + let errorIntercepted = false; + + console.error = (...args) => { + const errorMessage = args.join(' '); + if (!errorIntercepted && ( + errorMessage.includes('Not signed in with the identity provider') || + errorMessage.includes('FedCM get() rejects with NetworkError') || + errorMessage.includes('Error retrieving a token') + )) { + errorIntercepted = true; + console.error = originalConsoleError; // Restore immediately + console.log('πŸ”„ FedCM failed (user not signed in to Google), using redirect...'); + this.redirectToGoogleOAuth(); + return; + } + originalConsoleError.apply(console, args); + }; + + // Try FedCM + window.google.accounts.id.prompt((notification) => { + console.log('πŸ” FedCM notification:', notification); + console.error = originalConsoleError; // Restore console.error + // If we get here without error, FedCM is working }); - setTimeout(() => window.location.reload(), 2000); + + } catch (error) { + console.error('FedCM initialization error, falling back to redirect:', error); + this.redirectToGoogleOAuth(); } } else { - this.setState({ - error: 'Google-Anmeldung nicht geladen. Die Seite wird aktualisiert, um es erneut zu versuchen.', - loading: true - }); - setTimeout(() => window.location.reload(), 2000); + // Google Identity Services not loaded, go straight to redirect + console.log('πŸ“‹ GSI not loaded, using redirect flow...'); + this.redirectToGoogleOAuth(); } }; + redirectToGoogleOAuth = () => { + try { + // Generate a random state parameter for security + const state = this.generateRandomString(32); + sessionStorage.setItem('oauth_state', state); + + // Build the Google OAuth2 authorization URL + const params = new URLSearchParams({ + client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID, + redirect_uri: window.location.origin + '/auth/callback', + response_type: 'code', + scope: 'openid email profile', + state: state, + access_type: 'online', + prompt: 'select_account' + }); + + const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; + + console.log('πŸ”— Redirecting to Google OAuth:', authUrl); + + // Redirect to Google OAuth + window.location.href = authUrl; + + } catch (error) { + console.error('Redirect OAuth error:', error); + this.setState({ + error: 'Google-Anmeldung konnte nicht gestartet werden.', + loading: false + }); + } + }; + + generateRandomString = (length) => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }; + + handleUseRedirect = () => { + console.log('πŸ”„ User chose redirect flow'); + this.setState({ error: null, loading: true }); + this.redirectToGoogleOAuth(); + }; + @@ -157,6 +251,20 @@ class Login extends Component { {loading ? 'Anmeldung lΓ€uft...' : 'Mit Google anmelden'} + {error && error.includes('Standard-Anmeldung') && ( + + )} + diff --git a/client/src/components/OAuthCallback.js b/client/src/components/OAuthCallback.js new file mode 100644 index 0000000..6076704 --- /dev/null +++ b/client/src/components/OAuthCallback.js @@ -0,0 +1,133 @@ +import React, { Component } from 'react'; +import { Box, CircularProgress, Typography, Alert } from '@mui/material'; + +class OAuthCallback extends Component { + constructor(props) { + super(props); + this.state = { + loading: true, + error: null, + }; + } + + componentDidMount() { + this.handleOAuthCallback(); + } + + handleOAuthCallback = async () => { + try { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const state = urlParams.get('state'); + const error = urlParams.get('error'); + + // Check for OAuth errors + if (error) { + throw new Error(`OAuth error: ${error}`); + } + + // Verify state parameter for security + const storedState = sessionStorage.getItem('oauth_state'); + if (!state || state !== storedState) { + throw new Error('Invalid state parameter - possible CSRF attack'); + } + + // Clear stored state + sessionStorage.removeItem('oauth_state'); + + if (!code) { + throw new Error('No authorization code received'); + } + + console.log('πŸ”‘ Authorization code received, exchanging for tokens...'); + + // Exchange authorization code for tokens via our backend + const response = await fetch('/api/auth/google/callback', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code: code, + redirect_uri: window.location.origin + '/auth/callback' + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success && data.token) { + console.log('βœ… OAuth callback successful'); + + // Store the JWT token + localStorage.setItem('token', data.token); + + // Redirect to main app + window.location.href = '/'; + } else { + throw new Error(data.message || 'Authentication failed'); + } + + } catch (error) { + console.error('OAuth callback error:', error); + this.setState({ + loading: false, + error: error.message || 'Authentication failed' + }); + } + }; + + render() { + const { loading, error } = this.state; + + if (error) { + return ( + + + + Anmeldung fehlgeschlagen + + + {error} + + + + + ZurΓΌck zur Anmeldung + + + + ); + } + + return ( + + + + Anmeldung wird verarbeitet... + + + Sie werden automatisch weitergeleitet. + + + ); + } +} + +export default OAuthCallback; diff --git a/package.json b/package.json index f18367a..3b3ec29 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"", "dev:frontend": "webpack serve --mode development --config webpack.config.js", - "dev:backend": "nodemon src/index.js", + "dev:backend": "nodemon --exitcrash src/index.js", "build": "webpack --config webpack.prod.config.js", "build:prod": "npm run build && npm run start:prod", "start": "npm run build && node src/index.js", diff --git a/src/routes/auth.js b/src/routes/auth.js index fe6e3ef..1eda935 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -76,6 +76,98 @@ router.post('/google', async (req, res) => { } }); +// Google OAuth callback (redirect flow) +router.post('/google/callback', async (req, res) => { + try { + const { code, redirect_uri } = req.body; + + console.log('πŸ”„ Processing OAuth callback with authorization code'); + + if (!code) { + console.log('❌ No authorization code provided'); + return res.status(400).json({ error: 'Authorization code is required' }); + } + + // Exchange authorization code for tokens + const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID, + client_secret: process.env.GOOGLE_CLIENT_SECRET, + code: code, + grant_type: 'authorization_code', + redirect_uri: redirect_uri, + }), + }); + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.text(); + console.log('❌ Token exchange failed:', errorData); + return res.status(400).json({ error: 'Failed to exchange authorization code' }); + } + + const tokens = await tokenResponse.json(); + console.log('🎯 Received tokens from Google'); + + // Use the ID token to get user info + const ticket = await client.verifyIdToken({ + idToken: tokens.id_token, + audience: process.env.GOOGLE_CLIENT_ID, + }); + + const payload = ticket.getPayload(); + const googleId = payload['sub']; + const email = payload['email']; + const name = payload['name']; + const picture = payload['picture']; + + console.log(`πŸ‘€ OAuth callback verified for: ${email}`); + + // Check if email is authorized + const authorized = await isEmailAuthorized(email); + console.log(`πŸ”’ Email authorization check for ${email}: ${authorized ? 'ALLOWED' : 'DENIED'}`); + + if (!authorized) { + console.log(`❌ Access denied for ${email}`); + return res.status(403).json({ + error: 'Access denied', + message: 'Your email address is not authorized to access this application' + }); + } + + // Create user object + const user = { + id: googleId, + email, + name, + picture, + google_id: googleId, + }; + console.log('βœ… User object created from OAuth callback'); + + // Generate JWT token + const jwtToken = generateToken(user); + + res.json({ + success: true, + token: jwtToken, + user: { + id: user.id, + email: user.email, + name: user.name, + picture: user.picture, + }, + }); + + } catch (error) { + console.error('OAuth callback error:', error); + res.status(401).json({ error: 'OAuth authentication failed' }); + } +}); + // Verify JWT token router.get('/verify', authenticateToken, async (req, res) => { try { diff --git a/webpack.config.js b/webpack.config.js index ba5c1c5..77a7339 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -44,7 +44,7 @@ module.exports = { new HtmlWebpackPlugin({ template: './client/public/index.html', templateParameters: { - REACT_APP_GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || 'your_google_client_id_here', + REACT_APP_GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, }, }), new webpack.DefinePlugin({