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.
This commit is contained in:
sebseb7
2025-08-15 19:48:45 +02:00
parent fee9f02faa
commit 8e8d93e4a6
6 changed files with 359 additions and 19 deletions

View File

@@ -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 {
<Box sx={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
<Container maxWidth={false} sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', width: '100%' }}>
{isAuthenticated ? (
{this.isOAuthCallback() ? (
<OAuthCallback />
) : isAuthenticated ? (
<DataViewer
user={user}
onUpdateExportData={this.updateExportData}

View File

@@ -9,6 +9,10 @@ class Login extends Component {
error: null,
loading: false,
};
// Flags to track FedCM attempts and success
this.fedcmAttempted = false;
this.fedcmSucceeded = false;
}
componentDidMount() {
@@ -34,11 +38,17 @@ class Login extends Component {
initializeGoogleSignIn = () => {
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'}
</Button>
{error && error.includes('Standard-Anmeldung') && (
<Button
fullWidth
variant="outlined"
size="large"
startIcon={<GoogleIcon />}
onClick={this.handleUseRedirect}
disabled={loading}
sx={{ py: 1.5, mt: 2 }}
>
Standard Google-Anmeldung verwenden
</Button>
)}
<Typography variant="caption" display="block" textAlign="center" sx={{ mt: 2 }}>

View File

@@ -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 (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="60vh"
flexDirection="column"
>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
<Typography variant="h6" gutterBottom>
Anmeldung fehlgeschlagen
</Typography>
<Typography variant="body2">
{error}
</Typography>
</Alert>
<Typography variant="body2" color="textSecondary">
<a href="/" style={{ color: 'inherit' }}>
Zurück zur Anmeldung
</a>
</Typography>
</Box>
);
}
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="60vh"
flexDirection="column"
>
<CircularProgress size={60} sx={{ mb: 2 }} />
<Typography variant="h6" gutterBottom>
Anmeldung wird verarbeitet...
</Typography>
<Typography variant="body2" color="textSecondary">
Sie werden automatisch weitergeleitet.
</Typography>
</Box>
);
}
}
export default OAuthCallback;

View File

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

View File

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

View File

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