Rebrand from Forte (music-store-specific) to LunarFront (any small business): - Package namespace @forte/* → @lunarfront/* - Database forte/forte_test → lunarfront/lunarfront_test - Docker containers, volumes, connection strings - UI branding, localStorage keys, test emails - All documentation and planning docs Generalize music-specific terminology: - instrumentDescription → itemDescription - instrumentCount → itemCount - instrumentType → itemCategory (on service templates) - New migration 0027_generalize_terminology for column renames - Seed data updated with generic examples - RBAC descriptions updated
124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
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<string, string[]> = {
|
|
admin: ['admin', 'edit', 'view'],
|
|
edit: ['edit', 'view'],
|
|
view: ['view'],
|
|
upload: ['upload'],
|
|
delete: ['delete'],
|
|
send: ['send'],
|
|
export: ['export'],
|
|
}
|
|
|
|
function expandPermissions(slugs: string[]): Set<string> {
|
|
const expanded = new Set<string>()
|
|
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<string, { count: number; resetAt: number }>()
|
|
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)
|
|
}
|
|
}
|