- Implement tests for writing data files with rotation, ensuring backups are created only on changes. - Verify that old backups are rotated correctly and the maximum number of backups is maintained. - Test restoration of backups while preserving the current state as a backup. - Utilize Vitest for testing framework and manage temporary file storage during tests.
533 lines
17 KiB
JavaScript
533 lines
17 KiB
JavaScript
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="<alter-schlüssel>"')
|
|
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)
|
|
}
|