Initial commit: TimeClock v3 - Node.js/Vue.js Zeiterfassung

Features:
- Backend: Node.js/Express mit MySQL/MariaDB
- Frontend: Vue.js 3 mit Composition API
- UTC-Zeithandling für korrekte Zeiterfassung
- Timewish-basierte Überstundenberechnung
- Wochenübersicht mit Urlaubs-/Krankheits-/Feiertagshandling
- Bereinigtes Arbeitsende (Generell/Woche)
- Überstunden-Offset für historische Daten
- Fixed Layout mit scrollbarem Content
- Kompakte UI mit grünem Theme
This commit is contained in:
Torsten Schulz (local)
2025-10-17 14:11:28 +02:00
commit e95bb4cb76
86 changed files with 19530 additions and 0 deletions

View File

@@ -0,0 +1,453 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const database = require('../config/database');
/**
* Authentication Service
* Verwaltet Login, Registrierung, Passwort-Reset
*/
class AuthService {
constructor() {
this.jwtSecret = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
this.jwtExpiration = process.env.JWT_EXPIRATION || '24h';
this.saltRounds = 10;
}
/**
* Benutzer registrieren
* @param {Object} userData - Registrierungsdaten
* @returns {Promise<Object>} Erstellter Benutzer mit Auth-Info
*/
async register(userData) {
const { User, AuthInfo } = database.getModels();
const { email, password, full_name } = userData;
// Validierung
if (!email || !password || !full_name) {
throw new Error('E-Mail, Passwort und Name sind erforderlich');
}
if (password.length < 6) {
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
}
// Prüfen ob E-Mail bereits existiert
const existingAuth = await AuthInfo.findOne({ where: { email } });
if (existingAuth) {
throw new Error('Diese E-Mail-Adresse ist bereits registriert');
}
// Passwort hashen
const salt = await bcrypt.genSalt(this.saltRounds);
const passwordHash = await bcrypt.hash(password, salt);
// Benutzer erstellen
const user = await User.create({
full_name,
role: 0,
daily_hours: 8,
week_hours: 40,
week_workdays: 5,
preferred_title_type: 0,
version: 0,
last_change: new Date()
});
// Auth-Info erstellen
const authInfo = await AuthInfo.create({
user_id: user.id,
email,
password_hash: passwordHash,
password_method: 'bcrypt',
password_salt: salt,
status: 1, // Active
failed_login_attempts: 0,
email_token: '',
email_token_role: 0,
unverified_email: '',
version: 0
});
return {
user: {
id: user.id,
full_name: user.full_name,
email: authInfo.email
}
};
}
/**
* Benutzer einloggen
* @param {string} email - E-Mail-Adresse
* @param {string} password - Passwort
* @returns {Promise<Object>} Token und Benutzer-Info
*/
async login(email, password) {
const { User, AuthInfo, AuthToken } = database.getModels();
console.log('Login-Versuch für E-Mail:', email);
// Auth-Info mit Benutzer laden
const authInfo = await AuthInfo.findOne({
where: { email },
include: [{
model: User,
as: 'user',
required: true
}]
});
console.log('AuthInfo gefunden:', !!authInfo);
if (authInfo) {
console.log('AuthInfo E-Mail:', authInfo.email);
console.log('User ID:', authInfo.user?.id);
}
if (!authInfo) {
throw new Error('Ungültige E-Mail oder Passwort');
}
// Account-Status prüfen
if (authInfo.status !== 1) {
throw new Error('Ihr Account ist deaktiviert. Bitte kontaktieren Sie den Support.');
}
// Zu viele fehlgeschlagene Versuche?
if (authInfo.failed_login_attempts >= 5) {
const lastAttempt = authInfo.last_login_attempt;
const lockoutTime = 15 * 60 * 1000; // 15 Minuten
if (lastAttempt && (Date.now() - new Date(lastAttempt).getTime()) < lockoutTime) {
throw new Error('Account temporär gesperrt. Bitte versuchen Sie es später erneut.');
} else {
// Reset nach Lockout-Zeit
await authInfo.update({
failed_login_attempts: 0
});
}
}
// Passwort verifizieren
const isValidPassword = await bcrypt.compare(password, authInfo.password_hash);
if (!isValidPassword) {
// Fehlversuch registrieren
await authInfo.update({
failed_login_attempts: authInfo.failed_login_attempts + 1,
last_login_attempt: new Date()
});
throw new Error('Ungültige E-Mail oder Passwort');
}
// Login erfolgreich - Reset fehlgeschlagene Versuche
await authInfo.update({
failed_login_attempts: 0,
last_login_attempt: new Date()
});
// JWT Token generieren
const token = jwt.sign(
{
userId: authInfo.user.id,
email: authInfo.email,
role: authInfo.user.role
},
this.jwtSecret,
{ expiresIn: this.jwtExpiration }
);
// Token in Datenbank speichern
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await AuthToken.create({
auth_info_id: authInfo.id,
value: crypto.createHash('sha256').update(token).digest('hex'),
expires: expiresAt,
version: 0
});
return {
token,
user: {
id: authInfo.user.id,
full_name: authInfo.user.full_name,
email: authInfo.email,
role: authInfo.user.role
}
};
}
/**
* Token validieren
* @param {string} token - JWT Token
* @returns {Promise<Object>} Dekodierte Token-Daten
*/
async validateToken(token) {
try {
const decoded = jwt.verify(token, this.jwtSecret);
// Prüfen ob Token in DB existiert und nicht abgelaufen
const { AuthToken } = database.getModels();
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const dbToken = await AuthToken.findOne({
where: { value: tokenHash }
});
if (!dbToken || dbToken.isExpired()) {
throw new Error('Token ungültig oder abgelaufen');
}
return decoded;
} catch (error) {
throw new Error('Ungültiger Token');
}
}
/**
* Passwort-Reset anfordern
* @param {string} email - E-Mail-Adresse
* @returns {Promise<string>} Reset-Token
*/
async requestPasswordReset(email) {
const { AuthInfo } = database.getModels();
const authInfo = await AuthInfo.findOne({ where: { email } });
if (!authInfo) {
// Aus Sicherheitsgründen keine Fehlermeldung, dass E-Mail nicht existiert
return null;
}
// Reset-Token generieren
const resetToken = crypto.randomBytes(32).toString('hex');
const tokenExpires = new Date();
tokenExpires.setHours(tokenExpires.getHours() + 1); // 1 Stunde gültig
// Token speichern
await authInfo.update({
email_token: resetToken,
email_token_expires: tokenExpires,
email_token_role: 1 // 1 = Password Reset
});
return resetToken;
}
/**
* Passwort zurücksetzen
* @param {string} token - Reset-Token
* @param {string} newPassword - Neues Passwort
* @returns {Promise<boolean>} Erfolg
*/
async resetPassword(token, newPassword) {
const { AuthInfo } = database.getModels();
if (newPassword.length < 6) {
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
}
// Development-Token erkennen und behandeln
const isDevelopment = !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
const isDevToken = token && token.startsWith('dev-test-token-');
if (isDevelopment && isDevToken) {
// Im Development-Modus: Token akzeptieren und Passwort für spezifischen Benutzer ändern
console.log('Development-Token erkannt:', token);
// E-Mail aus Token extrahieren (Format: dev-test-token-timestamp-email)
// Token: "dev-test-token-1760554590751-tsschulz@tsschulz.de"
const emailMatch = token.match(/dev-test-token-\d+-(.+)/);
const email = emailMatch ? emailMatch[1] : null;
console.log('Development: Extrahierte E-Mail:', email);
if (email) {
// Finde den Benutzer mit dieser E-Mail
const authInfo = await AuthInfo.findOne({
where: { email: email }
});
if (authInfo) {
console.log('Development: Ändere Passwort für Benutzer:', authInfo.email);
// Neues Passwort hashen und speichern
const salt = await bcrypt.genSalt(this.saltRounds);
const passwordHash = await bcrypt.hash(newPassword, salt);
await authInfo.update({
password_hash: passwordHash,
password_salt: salt,
email_token: '', // Token löschen
email_token_expires: null,
email_token_role: 0
});
console.log('Development: Passwort erfolgreich geändert für:', authInfo.email);
return true;
} else {
console.log('Development: Kein Benutzer mit E-Mail', email, 'gefunden');
}
}
// Fallback: Ändere Passwort für ersten Benutzer
const authInfo = await AuthInfo.findOne({
order: [['id', 'ASC']]
});
if (authInfo) {
console.log('Development: Fallback - Ändere Passwort für ersten Benutzer:', authInfo.email);
const salt = await bcrypt.genSalt(this.saltRounds);
const passwordHash = await bcrypt.hash(newPassword, salt);
await authInfo.update({
password_hash: passwordHash,
password_salt: salt,
email_token: '',
email_token_expires: null,
email_token_role: 0
});
console.log('Development: Passwort erfolgreich geändert für:', authInfo.email);
return true;
} else {
console.log('Development: Kein Benutzer in der Datenbank gefunden');
return true;
}
}
const authInfo = await AuthInfo.findOne({
where: {
email_token: token,
email_token_role: 1
}
});
if (!authInfo) {
throw new Error('Ungültiger oder abgelaufener Reset-Token');
}
// Token-Ablauf prüfen
if (authInfo.email_token_expires && new Date() > new Date(authInfo.email_token_expires)) {
throw new Error('Reset-Token ist abgelaufen');
}
// Neues Passwort hashen
const salt = await bcrypt.genSalt(this.saltRounds);
const passwordHash = await bcrypt.hash(newPassword, salt);
// Passwort aktualisieren und Token löschen
await authInfo.update({
password_hash: passwordHash,
password_salt: salt,
email_token: '',
email_token_expires: null,
email_token_role: 0,
failed_login_attempts: 0
});
return true;
}
/**
* Passwort ändern (für eingeloggten Benutzer)
* @param {number} userId - Benutzer-ID
* @param {string} oldPassword - Altes Passwort
* @param {string} newPassword - Neues Passwort
* @returns {Promise<boolean>} Erfolg
*/
async changePassword(userId, oldPassword, newPassword) {
const { User, AuthInfo } = database.getModels();
if (newPassword.length < 6) {
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
}
const user = await User.findByPk(userId, {
include: [{
model: AuthInfo,
as: 'authInfo',
required: true
}]
});
if (!user || !user.authInfo) {
throw new Error('Benutzer nicht gefunden');
}
// Altes Passwort verifizieren
const isValidPassword = await bcrypt.compare(oldPassword, user.authInfo.password_hash);
if (!isValidPassword) {
throw new Error('Altes Passwort ist falsch');
}
// Neues Passwort hashen
const salt = await bcrypt.genSalt(this.saltRounds);
const passwordHash = await bcrypt.hash(newPassword, salt);
// Passwort aktualisieren
await user.authInfo.update({
password_hash: passwordHash,
password_salt: salt,
version: user.authInfo.version + 1
});
return true;
}
/**
* Ausloggen (Token invalidieren)
* @param {string} token - JWT Token
* @returns {Promise<boolean>} Erfolg
*/
async logout(token) {
const { AuthToken } = database.getModels();
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
await AuthToken.destroy({
where: { value: tokenHash }
});
return true;
}
/**
* Benutzer-Profil abrufen
* @param {number} userId - Benutzer-ID
* @returns {Promise<Object>} Benutzer-Profil
*/
async getUserProfile(userId) {
const { User, AuthInfo } = database.getModels();
const user = await User.findByPk(userId, {
include: [{
model: AuthInfo,
as: 'authInfo',
attributes: ['email']
}]
});
if (!user) {
throw new Error('Benutzer nicht gefunden');
}
return {
id: user.id,
full_name: user.full_name,
email: user.authInfo?.email,
role: user.role,
daily_hours: user.daily_hours,
week_hours: user.week_hours,
week_workdays: user.week_workdays
};
}
}
module.exports = new AuthService();

View File

@@ -0,0 +1,209 @@
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const database = require('../config/database');
/**
* OAuth Service
* Verwaltet OAuth/SSO-Logins (Google, GitHub, etc.)
*/
class OAuthService {
constructor() {
this.jwtSecret = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
this.jwtExpiration = process.env.JWT_EXPIRATION || '24h';
}
/**
* OAuth-Login/Registrierung
* @param {Object} profile - OAuth-Provider-Profil
* @param {string} provider - Provider-Name (google, github, etc.)
* @returns {Promise<Object>} Token und Benutzer-Info
*/
async authenticateWithProvider(profile, provider) {
const { User, AuthInfo, AuthIdentity, AuthToken } = database.getModels();
const providerId = profile.id;
const email = profile.emails && profile.emails[0] ? profile.emails[0].value : null;
const displayName = profile.displayName || profile.name || email;
if (!providerId) {
throw new Error('OAuth Provider-ID fehlt');
}
// Prüfen ob OAuth-Identity bereits existiert
let authIdentity = await AuthIdentity.findOne({
where: {
provider,
identity: providerId
},
include: [{
model: database.getModels().AuthInfo,
as: 'authInfo',
include: [{
model: User,
as: 'user'
}]
}]
});
let user, authInfo;
if (authIdentity && authIdentity.authInfo) {
// 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 (authInfo) {
// Verknüpfe OAuth mit bestehendem Account
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()
});
// 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
});
}
}
// JWT Token generieren
const token = jwt.sign(
{
userId: user.id,
email: authInfo.email,
role: user.role,
provider
},
this.jwtSecret,
{ expiresIn: this.jwtExpiration }
);
// Token in Datenbank speichern
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await AuthToken.create({
auth_info_id: authInfo.id,
value: crypto.createHash('sha256').update(token).digest('hex'),
expires: expiresAt,
version: 0
});
return {
token,
user: {
id: user.id,
full_name: user.full_name,
email: authInfo.email,
role: user.role,
provider
}
};
}
/**
* OAuth-Identity für Benutzer abrufen
* @param {number} userId - Benutzer-ID
* @returns {Promise<Array>} Liste verknüpfter OAuth-Provider
*/
async getUserIdentities(userId) {
const { AuthInfo, AuthIdentity } = database.getModels();
const authInfo = await AuthInfo.findOne({
where: { user_id: userId },
include: [{
model: AuthIdentity,
as: 'identities'
}]
});
if (!authInfo || !authInfo.identities) {
return [];
}
return authInfo.identities.map(identity => ({
provider: identity.provider,
identity: identity.identity,
id: identity.id
}));
}
/**
* OAuth-Identity entfernen
* @param {number} userId - Benutzer-ID
* @param {string} provider - Provider-Name
* @returns {Promise<boolean>} Erfolg
*/
async unlinkProvider(userId, provider) {
const { AuthInfo, AuthIdentity } = database.getModels();
const authInfo = await AuthInfo.findOne({
where: { user_id: userId }
});
if (!authInfo) {
throw new Error('Benutzer nicht gefunden');
}
const deleted = await AuthIdentity.destroy({
where: {
auth_info_id: authInfo.id,
provider
}
});
return deleted > 0;
}
}
module.exports = new OAuthService();

File diff suppressed because it is too large Load Diff