import bcrypt from 'bcryptjs' import jwt from 'jsonwebtoken' import crypto from 'crypto' import { promises as fs } from 'fs' import path from 'path' import { encryptObject, decryptObject } from './encryption.js' import { writeDataFileWithRotation } from './data-file-rotation.js' // Export migrateUserRoles für Verwendung in anderen Modulen export function migrateUserRoles(user) { if (!user) return user // Wenn bereits roles Array vorhanden, nichts tun if (Array.isArray(user.roles)) { return user } // Wenn role vorhanden, zu roles Array konvertieren if (user.role) { user.roles = [user.role] delete user.role } else { // Fallback: Standard-Rolle user.roles = ['mitglied'] } return user } const JWT_SECRET = process.env.JWT_SECRET || 'harheimertc-secret-key-change-in-production' // Handle both dev and production paths // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // filename is always a hardcoded constant (e.g., 'users.json'), never user input const getDataPath = (filename) => { const cwd = process.cwd() // In production (.output/server), working dir is .output if (cwd.endsWith('.output')) { // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal return path.join(cwd, '../server/data', filename) } // In development, working dir is project root // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal return path.join(cwd, 'server/data', filename) } const USERS_FILE = getDataPath('users.json') const SESSIONS_FILE = getDataPath('sessions.json') const ANDROID_ACCESS_TOKEN_TTL = '15m' const REFRESH_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000 const refreshMutationState = globalThis.__HTC_REFRESH_MUTATION_STATE__ || { tail: Promise.resolve() } globalThis.__HTC_REFRESH_MUTATION_STATE__ = refreshMutationState // Get encryption key from environment function getEncryptionKey() { return process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production' } // Liste möglicher alter Verschlüsselungsschlüssel (für Migration) function getPossibleEncryptionKeys() { const currentKey = getEncryptionKey() const oldKeys = [ 'default-key-change-in-production', 'local_development_encryption_key_change_in_production' ] // Aktueller Schlüssel zuerst, dann alte Schlüssel const keys = [currentKey] for (const oldKey of oldKeys) { if (oldKey !== currentKey) { keys.push(oldKey) } } // Optional: Alter Schlüssel aus Environment-Variable if (process.env.OLD_ENCRYPTION_KEY && process.env.OLD_ENCRYPTION_KEY !== currentKey) { keys.push(process.env.OLD_ENCRYPTION_KEY) } return keys } // Check if data is encrypted by trying to parse as JSON first function isEncrypted(data) { try { const parsed = JSON.parse(data.trim()) if (Array.isArray(parsed)) { return false // Unencrypted array } if (typeof parsed === 'object' && parsed !== null && !parsed.encryptedData) { return false } return false } catch { // JSON parsing failed - likely encrypted base64 return true } } // Read users from file (with encryption support and migration) export async function readUsers() { try { const data = await fs.readFile(USERS_FILE, 'utf-8') const encrypted = isEncrypted(data) let users = [] if (encrypted) { // Versuche mit verschiedenen Schlüsseln zu entschlüsseln (für Migration) const possibleKeys = getPossibleEncryptionKeys() const encryptionKey = getEncryptionKey() let lastError = null for (let i = 0; i < possibleKeys.length; i++) { const key = possibleKeys[i] try { users = decryptObject(data, key) // Wenn mit altem Schlüssel entschlüsselt wurde, warnen und neu verschlüsseln if (i > 0 && key !== encryptionKey) { console.warn(`⚠️ Benutzerdaten wurden mit altem Schlüssel entschlüsselt. Automatische Neuverschlüsselung...`) try { await writeUsers(users) console.log('✅ Benutzerdaten erfolgreich mit neuem Schlüssel neu verschlüsselt') } catch (writeError) { console.error('❌ Fehler beim Neuverschlüsseln:', writeError.message) console.error(' Bitte führen Sie manuell aus: node scripts/re-encrypt-data.js') } } break // Erfolgreich entschlüsselt } catch (decryptError) { lastError = decryptError // Versuche nächsten Schlüssel continue } } // Wenn alle Schlüssel fehlgeschlagen sind if (!users || users.length === 0) { console.error('Fehler beim Entschlüsseln der Benutzerdaten:') console.error(' Versuchte Schlüssel:', possibleKeys.length) console.error(' Letzter Fehler:', lastError?.message || 'Unbekannter Fehler') console.error('') console.error('💡 Lösung: Führen Sie das Re-Encrypt-Skript aus:') console.error(' node scripts/re-encrypt-data.js --old-key=""') console.error(' Oder setzen Sie OLD_ENCRYPTION_KEY als Environment-Variable') // Fallback: try to read as plain JSON try { users = JSON.parse(data) console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen') } catch { console.error('Konnte Benutzerdaten weder entschlüsseln noch als JSON lesen') return [] } } } else { // Plain JSON - migrate to encrypted format users = JSON.parse(data) console.log('Migriere unverschlüsselte Benutzerdaten zu verschlüsselter Speicherung...') } // Migriere Rollen von role zu roles Array let needsMigration = false users = users.map(user => { const migrated = migrateUserRoles(user) if (!Array.isArray(user.roles) && user.role) { needsMigration = true } return migrated }) // Wenn Migration nötig war, speichere zurück if (needsMigration) { console.log('Migriere Rollen von role zu roles Array...') await writeUsers(users) } else if (!encrypted) { // Write back encrypted wenn noch nicht verschlüsselt await writeUsers(users) } return users } catch (error) { if (error.code === 'ENOENT') { return [] } console.error('Fehler beim Lesen der Benutzerdaten:', error) return [] } } // Write users to file (always encrypted) export async function writeUsers(users) { try { const encryptionKey = getEncryptionKey() const encryptedData = encryptObject(users, encryptionKey) await writeDataFileWithRotation(USERS_FILE, encryptedData, { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der Benutzerdaten:', error) return false } } // Prüft ob Sessions-Daten verschlüsselt sind function isSessionsEncrypted(data) { try { const parsed = JSON.parse(data.trim()) if (Array.isArray(parsed)) { return false } if (typeof parsed === 'object' && parsed !== null && !parsed.encryptedData) { return false } return false } catch { return true } } // Read sessions from file (with encryption support) export async function readSessions() { try { const data = await fs.readFile(SESSIONS_FILE, 'utf-8') const encrypted = isSessionsEncrypted(data) if (encrypted) { const encryptionKey = getEncryptionKey() try { return decryptObject(data, encryptionKey) } catch (decryptError) { console.error('Fehler beim Entschlüsseln der Sessions:', decryptError) try { const plainData = JSON.parse(data) console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen') return plainData } catch { console.error('Konnte Sessions weder entschlüsseln noch als JSON lesen') return [] } } } else { // Plain JSON - migriere zu verschlüsselter Speicherung const sessions = JSON.parse(data) console.log('Migriere unverschlüsselte Sessions zu verschlüsselter Speicherung...') await writeSessions(sessions) return sessions } } catch (error) { if (error.code === 'ENOENT') { return [] } console.error('Fehler beim Lesen der Sessions:', error) return [] } } // Write sessions to file (always encrypted) export async function writeSessions(sessions) { try { const encryptionKey = getEncryptionKey() const encryptedData = encryptObject(sessions, encryptionKey) await writeDataFileWithRotation(SESSIONS_FILE, encryptedData, { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der Sessions:', error) return false } } // Hash password export async function hashPassword(password) { const salt = await bcrypt.genSalt(10) return await bcrypt.hash(password, salt) } // Verify password export async function verifyPassword(password, hash) { return await bcrypt.compare(password, hash) } // Generate JWT token export function generateToken(user, { expiresIn = '7d', sessionId = null } = {}) { // Stelle sicher, dass Rollen migriert sind const migratedUser = migrateUserRoles({ ...user }) const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied']) const claims = { id: user.id, email: user.email, roles: roles } if (sessionId) { claims.sid = sessionId } return jwt.sign( claims, JWT_SECRET, { expiresIn } ) } export function generateAndroidAccessToken(user, sessionId) { return generateToken(user, { expiresIn: ANDROID_ACCESS_TOKEN_TTL, sessionId }) } // Verify JWT token export function verifyToken(token) { try { return jwt.verify(token, JWT_SECRET) } catch { return null } } // Get user by ID export async function getUserById(id) { const users = await readUsers() const user = users.find(u => u.id === id) return user ? migrateUserRoles(user) : null } // Get user by email export async function getUserByEmail(email) { const users = await readUsers() const user = users.find(u => u.email === email) return user ? migrateUserRoles(user) : null } // Prüft ob Benutzer eine bestimmte Rolle hat export function hasRole(user, role) { if (!user) return false const roles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) return roles.includes(role) } // Prüft ob Benutzer eine der angegebenen Rollen hat export function hasAnyRole(user, ...roles) { if (!user) return false const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) return roles.some(role => userRoles.includes(role)) } // Prüft ob Benutzer alle angegebenen Rollen hat export function hasAllRoles(user, ...roles) { if (!user) return false const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) return roles.every(role => userRoles.includes(role)) } // Get user from token export async function getUserFromToken(token) { const decoded = verifyToken(token) if (!decoded) return null if (decoded.sid) { const sessions = await readSessions() const session = sessions.find(s => s.id === decoded.sid && s.userId === decoded.id) if (!session || session.revokedAt || new Date(session.expiresAt).getTime() <= Date.now()) { return null } } const users = await readUsers() const user = users.find(u => u.id === decoded.id) // Migriere Rollen beim Laden if (user) { migrateUserRoles(user) } return user } // Create session export async function createSession(userId, token) { const sessions = await readSessions() const session = { id: Date.now().toString(), userId, token, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days } sessions.push(session) await writeSessions(sessions) return session } // Delete session export async function deleteSession(token) { const sessions = await readSessions() const filtered = sessions.filter(s => s.token !== token) await writeSessions(filtered) } function hashRefreshToken(token) { return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex') } function issueRefreshToken() { return crypto.randomBytes(48).toString('base64url') } function serializeRefreshMutation(operation) { const result = refreshMutationState.tail.then(operation, operation) refreshMutationState.tail = result.then(() => undefined, () => undefined) return result } export async function createRefreshSession(userId, deviceName = 'Android') { return serializeRefreshMutation(async () => { const sessions = await readSessions() const refreshToken = issueRefreshToken() const createdAt = new Date().toISOString() const session = { id: crypto.randomUUID(), familyId: crypto.randomUUID(), type: 'android_refresh', userId, deviceName: String(deviceName || 'Android').slice(0, 100), refreshTokenHash: hashRefreshToken(refreshToken), createdAt, lastUsedAt: createdAt, expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS).toISOString(), revokedAt: null } sessions.push(session) await writeSessions(sessions) return { session, refreshToken } }) } export async function rotateRefreshSession(refreshToken) { return serializeRefreshMutation(async () => { const sessions = await readSessions() const tokenHash = hashRefreshToken(refreshToken) const session = sessions.find(s => s.type === 'android_refresh' && s.refreshTokenHash === tokenHash) const now = new Date() const nowIso = now.toISOString() if (!session) return { status: 'invalid' } if (session.revokedAt) { if (session.rotatedAt) { for (const related of sessions) { if (related.familyId === session.familyId && !related.revokedAt) { related.revokedAt = nowIso related.revokeReason = 'refresh_token_reuse' } } await writeSessions(sessions) return { status: 'reused', session } } return { status: 'revoked', session } } if (new Date(session.expiresAt).getTime() <= now.getTime()) { session.revokedAt = nowIso session.revokeReason = 'expired' await writeSessions(sessions) return { status: 'expired', session } } const nextRefreshToken = issueRefreshToken() const nextSession = { ...session, id: crypto.randomUUID(), refreshTokenHash: hashRefreshToken(nextRefreshToken), createdAt: nowIso, lastUsedAt: nowIso, expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS).toISOString(), revokedAt: null } session.lastUsedAt = nowIso session.revokedAt = nowIso session.rotatedAt = nowIso session.replacedBy = nextSession.id session.revokeReason = 'rotated' sessions.push(nextSession) await writeSessions(sessions) return { status: 'rotated', session: nextSession, refreshToken: nextRefreshToken } }) } export async function revokeRefreshSession(refreshToken, reason = 'logout') { return serializeRefreshMutation(async () => { const sessions = await readSessions() const tokenHash = hashRefreshToken(refreshToken) const session = sessions.find(s => s.type === 'android_refresh' && s.refreshTokenHash === tokenHash) if (!session) return false const revokedAt = new Date().toISOString() for (const related of sessions) { if (related.familyId === session.familyId && !related.revokedAt) { related.revokedAt = revokedAt related.revokeReason = reason } } await writeSessions(sessions) return true }) } export async function revokeRefreshSessionsForUser(userId, reason) { return serializeRefreshMutation(async () => { const sessions = await readSessions() const revokedAt = new Date().toISOString() let changed = false for (const session of sessions) { if (session.type === 'android_refresh' && session.userId === userId && !session.revokedAt) { session.revokedAt = revokedAt session.revokeReason = reason changed = true } } if (changed) await writeSessions(sessions) }) } // Clean expired sessions export async function cleanExpiredSessions() { const sessions = await readSessions() const now = new Date() const valid = sessions.filter(s => new Date(s.expiresAt) > now) await writeSessions(valid) }