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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ class OAuthService {
|
||||
* @returns {Promise<Object>} 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();
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user