import { eq } from 'drizzle-orm' import bcrypt from 'bcrypt' import { users } from '../db/schema/users.js' import { RbacService } from '../services/rbac.service.js' import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify' /** * Permission inheritance — same logic as auth.ts */ const ACTION_HIERARCHY: Record = { admin: ['admin', 'edit', 'view'], edit: ['edit', 'view'], view: ['view'], upload: ['upload'], delete: ['delete'], send: ['send'], export: ['export'], } function expandPermissions(slugs: string[]): Set { const expanded = new Set() for (const slug of slugs) { expanded.add(slug) const [domain, action] = slug.split('.') const implied = ACTION_HIERARCHY[action] if (implied && domain) { for (const a of implied) expanded.add(`${domain}.${a}`) } } return expanded } // Simple rate limiter: track failed attempts per IP const failedAttempts = new Map() const MAX_FAILED_ATTEMPTS = 10 const LOCKOUT_DURATION = 5 * 60 * 1000 // 5 minutes function checkRateLimit(ip: string): boolean { const now = Date.now() const entry = failedAttempts.get(ip) if (!entry || entry.resetAt < now) return true // allowed return entry.count < MAX_FAILED_ATTEMPTS } function recordFailure(ip: string) { const now = Date.now() const entry = failedAttempts.get(ip) if (!entry || entry.resetAt < now) { failedAttempts.set(ip, { count: 1, resetAt: now + LOCKOUT_DURATION }) } else { entry.count++ } } function clearFailures(ip: string) { failedAttempts.delete(ip) } // Periodic cleanup of old entries setInterval(() => { const now = Date.now() for (const [ip, entry] of failedAttempts) { if (entry.resetAt < now) failedAttempts.delete(ip) } }, 60_000) /** * WebDAV Basic Auth pre-handler. * Verifies HTTP Basic Auth credentials against the users table * and attaches request.user / request.permissions just like JWT auth. */ export function webdavBasicAuth(app: FastifyInstance) { return async function (request: FastifyRequest, reply: FastifyReply) { const ip = request.ip if (!checkRateLimit(ip)) { reply.header('Retry-After', '300') return reply.status(429).send('Too many failed attempts. Try again later.') } const authHeader = request.headers.authorization if (!authHeader || !authHeader.startsWith('Basic ')) { reply.header('WWW-Authenticate', 'Basic realm="LunarFront WebDAV"') return reply.status(401).send('Authentication required') } const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8') const colonIndex = decoded.indexOf(':') if (colonIndex === -1) { recordFailure(ip) reply.header('WWW-Authenticate', 'Basic realm="LunarFront WebDAV"') return reply.status(401).send('Invalid credentials') } const email = decoded.slice(0, colonIndex) const password = decoded.slice(colonIndex + 1) const [user] = await app.db .select({ id: users.id, passwordHash: users.passwordHash, isActive: users.isActive, role: users.role }) .from(users) .where(eq(users.email, email.toLowerCase())) .limit(1) if (!user || !user.isActive) { recordFailure(ip) reply.header('WWW-Authenticate', 'Basic realm="LunarFront WebDAV"') return reply.status(401).send('Invalid credentials') } const valid = await bcrypt.compare(password, user.passwordHash) if (!valid) { recordFailure(ip) reply.header('WWW-Authenticate', 'Basic realm="LunarFront WebDAV"') return reply.status(401).send('Invalid credentials') } // Success — clear failure counter clearFailures(ip) request.user = { id: user.id, role: user.role } const permSlugs = await RbacService.getUserPermissions(app.db, user.id) request.permissions = expandPermissions(permSlugs) } }