Files
harheimertc/server/utils/password-reset-log.js
Torsten Schulz (local) 58fd7fa5c6
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m7s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
feat(auth): implement Android refresh token handling and session management
- Added support for generating Android access tokens and managing refresh sessions in the auth endpoints.
- Implemented new tests for login, logout, and refresh functionalities specific to Android clients.
- Enhanced password reset logging with normalization and masking of email addresses.
- Created a new diagnostics endpoint for password reset attempts, including filtering and summarizing logs.
- Introduced a new utility for managing password reset logs with retention policies.
- Added tests for password reset log utilities to ensure proper functionality and privacy compliance.
- Updated WebAuthn configuration tests to validate origin handling for production and allowed origins.
2026-05-27 19:34:53 +02:00

137 lines
3.8 KiB
JavaScript

import crypto from 'crypto'
import fs from 'fs/promises'
import path from 'path'
const RETENTION_MS = 72 * 60 * 60 * 1000
function getDataPath(filename) {
const cwd = process.cwd()
if (cwd.endsWith('.output')) {
return path.join(cwd, '../server/data', filename)
}
return path.join(cwd, 'server/data', filename)
}
const LOG_FILE = getDataPath('password-reset.log.jsonl')
export function normalizeResetEmail(email) {
return String(email || '').trim().toLowerCase()
}
export function maskResetEmail(email) {
const normalized = normalizeResetEmail(email)
const [localPart = '', domain = ''] = normalized.split('@')
if (!domain) return normalized ? `${localPart.slice(0, 2)}***` : ''
const localVisible = localPart.slice(0, Math.min(2, localPart.length))
const domainParts = domain.split('.')
const domainName = domainParts.shift() || ''
const suffix = domainParts.length ? `.${domainParts.join('.')}` : ''
return `${localVisible}***@${domainName.slice(0, 2)}***${suffix}`
}
export function fingerprintResetEmail(email) {
return crypto.createHash('sha256').update(normalizeResetEmail(email)).digest('hex')
}
function safeText(value, max = 160) {
return String(value == null ? '' : value).slice(0, max)
}
function errorLabel(error) {
return safeText(error?.code || error?.name || 'Error', 80)
}
function sanitizedErrorMessage(error) {
return safeText(error?.message || error || '')
.replace(/[^\s<>"']+@[^\s<>"']+/gi, email => maskResetEmail(email))
.replace(/((?:pass(?:word)?|token|secret|authorization|auth)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]')
.replace(/(smtp:\/\/[^:\s/]+:)[^@\s/]+@/gi, '$1[redacted]@')
}
export async function writePasswordResetLog({
requestId,
email,
ip,
step,
status,
userId,
reason,
error
}) {
const normalizedEmail = normalizeResetEmail(email)
const entry = {
ts: new Date().toISOString(),
requestId: safeText(requestId, 80),
emailMasked: maskResetEmail(normalizedEmail),
emailFingerprint: fingerprintResetEmail(normalizedEmail),
ip: safeText(ip, 80),
step: safeText(step, 80),
status: safeText(status, 40)
}
if (userId) entry.userId = safeText(userId, 80)
if (reason) entry.reason = safeText(reason, 100)
if (error) {
entry.errorCode = errorLabel(error)
entry.errorMessage = sanitizedErrorMessage(error)
}
await fs.mkdir(path.dirname(LOG_FILE), { recursive: true })
await fs.appendFile(LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8')
}
export async function cleanupPasswordResetLogs(now = Date.now()) {
let contents
try {
contents = await fs.readFile(LOG_FILE, 'utf8')
} catch (error) {
if (error.code === 'ENOENT') return { retained: 0, removed: 0 }
throw error
}
const threshold = now - RETENTION_MS
const entries = contents
.split('\n')
.filter(Boolean)
.flatMap(line => {
try {
return [JSON.parse(line)]
} catch {
return []
}
})
const retained = entries.filter(entry => new Date(entry.ts).getTime() >= threshold)
const removed = entries.length - retained.length
if (removed > 0) {
const serialized = retained.map(entry => JSON.stringify(entry)).join('\n')
await fs.writeFile(LOG_FILE, serialized ? `${serialized}\n` : '', 'utf8')
}
return { retained: retained.length, removed }
}
export async function readPasswordResetLogs() {
await cleanupPasswordResetLogs()
try {
const contents = await fs.readFile(LOG_FILE, 'utf8')
return contents
.split('\n')
.filter(Boolean)
.flatMap(line => {
try {
return [JSON.parse(line)]
} catch {
return []
}
})
.sort((a, b) => String(b.ts).localeCompare(String(a.ts)))
} catch (error) {
if (error.code === 'ENOENT') return []
throw error
}
}
export const PASSWORD_RESET_LOG_RETENTION_HOURS = RETENTION_MS / (60 * 60 * 1000)