130 lines
3.7 KiB
JavaScript
130 lines
3.7 KiB
JavaScript
import crypto from 'crypto'
|
|
import fs from 'fs/promises'
|
|
import path from 'path'
|
|
import { getServerDataPath } from './paths.js'
|
|
|
|
const RETENTION_MS = 72 * 60 * 60 * 1000
|
|
|
|
const LOG_FILE = getServerDataPath('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)
|