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:
Torsten Schulz (local)
2026-05-15 08:27:36 +02:00
parent 95b611fd04
commit c16d2a6e4d
20 changed files with 768 additions and 127 deletions

View File

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

View File

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

View File

@@ -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();

View File

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

View File

@@ -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();