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:
@@ -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}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
133
client/src/components/OAuthCallback.js
Normal file
133
client/src/components/OAuthCallback.js
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user