262 lines
8.7 KiB
TypeScript
262 lines
8.7 KiB
TypeScript
import { randomBytes, timingSafeEqual } from 'crypto'
|
|
import { getDatabase } from './db'
|
|
import { hashPassword, verifyPassword } from './password'
|
|
|
|
/**
|
|
* Constant-time string comparison to prevent timing attacks.
|
|
*/
|
|
export function safeCompare(a: string, b: string): boolean {
|
|
if (typeof a !== 'string' || typeof b !== 'string') return false
|
|
const bufA = Buffer.from(a)
|
|
const bufB = Buffer.from(b)
|
|
if (bufA.length !== bufB.length) {
|
|
timingSafeEqual(bufA, bufA)
|
|
return false
|
|
}
|
|
return timingSafeEqual(bufA, bufB)
|
|
}
|
|
|
|
export interface User {
|
|
id: number
|
|
username: string
|
|
display_name: string
|
|
role: 'admin' | 'operator' | 'viewer'
|
|
provider?: 'local' | 'google'
|
|
email?: string | null
|
|
avatar_url?: string | null
|
|
is_approved?: number
|
|
created_at: number
|
|
updated_at: number
|
|
last_login_at: number | null
|
|
}
|
|
|
|
export interface UserSession {
|
|
id: number
|
|
token: string
|
|
user_id: number
|
|
expires_at: number
|
|
created_at: number
|
|
ip_address: string | null
|
|
user_agent: string | null
|
|
}
|
|
|
|
// Session management
|
|
const SESSION_DURATION = 7 * 24 * 60 * 60 // 7 days in seconds
|
|
|
|
export function createSession(userId: number, ipAddress?: string, userAgent?: string): { token: string; expiresAt: number } {
|
|
const db = getDatabase()
|
|
const token = randomBytes(32).toString('hex')
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const expiresAt = now + SESSION_DURATION
|
|
|
|
db.prepare(`
|
|
INSERT INTO user_sessions (token, user_id, expires_at, ip_address, user_agent)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`).run(token, userId, expiresAt, ipAddress || null, userAgent || null)
|
|
|
|
// Update user's last login
|
|
db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(now, now, userId)
|
|
|
|
// Clean up expired sessions
|
|
db.prepare('DELETE FROM user_sessions WHERE expires_at < ?').run(now)
|
|
|
|
return { token, expiresAt }
|
|
}
|
|
|
|
export function validateSession(token: string): (User & { sessionId: number }) | null {
|
|
if (!token) return null
|
|
const db = getDatabase()
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
const row = db.prepare(`
|
|
SELECT u.id, u.username, u.display_name, u.role, u.provider, u.email, u.avatar_url, u.is_approved, u.created_at, u.updated_at, u.last_login_at,
|
|
s.id as session_id
|
|
FROM user_sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.token = ? AND s.expires_at > ?
|
|
`).get(token, now) as any
|
|
|
|
if (!row) return null
|
|
|
|
return {
|
|
id: row.id,
|
|
username: row.username,
|
|
display_name: row.display_name,
|
|
role: row.role,
|
|
provider: row.provider || 'local',
|
|
email: row.email ?? null,
|
|
avatar_url: row.avatar_url ?? null,
|
|
is_approved: typeof row.is_approved === 'number' ? row.is_approved : 1,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
last_login_at: row.last_login_at,
|
|
sessionId: row.session_id,
|
|
}
|
|
}
|
|
|
|
export function destroySession(token: string): void {
|
|
const db = getDatabase()
|
|
db.prepare('DELETE FROM user_sessions WHERE token = ?').run(token)
|
|
}
|
|
|
|
export function destroyAllUserSessions(userId: number): void {
|
|
const db = getDatabase()
|
|
db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(userId)
|
|
}
|
|
|
|
// User management
|
|
export function authenticateUser(username: string, password: string): User | null {
|
|
const db = getDatabase()
|
|
const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as any
|
|
if (!row) return null
|
|
if ((row.provider || 'local') !== 'local') return null
|
|
if ((row.is_approved ?? 1) !== 1) return null
|
|
if (!verifyPassword(password, row.password_hash)) return null
|
|
return {
|
|
id: row.id,
|
|
username: row.username,
|
|
display_name: row.display_name,
|
|
role: row.role,
|
|
provider: row.provider || 'local',
|
|
email: row.email ?? null,
|
|
avatar_url: row.avatar_url ?? null,
|
|
is_approved: row.is_approved ?? 1,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
last_login_at: row.last_login_at,
|
|
}
|
|
}
|
|
|
|
export function getUserById(id: number): User | null {
|
|
const db = getDatabase()
|
|
const row = db.prepare('SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at FROM users WHERE id = ?').get(id) as any
|
|
return row || null
|
|
}
|
|
|
|
export function getAllUsers(): User[] {
|
|
const db = getDatabase()
|
|
return db.prepare('SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at FROM users ORDER BY created_at').all() as User[]
|
|
}
|
|
|
|
export function createUser(
|
|
username: string,
|
|
password: string,
|
|
displayName: string,
|
|
role: User['role'] = 'operator',
|
|
options?: { provider?: 'local' | 'google'; provider_user_id?: string | null; email?: string | null; avatar_url?: string | null; is_approved?: 0 | 1; approved_by?: string | null; approved_at?: number | null }
|
|
): User {
|
|
const db = getDatabase()
|
|
const passwordHash = hashPassword(password)
|
|
const provider = options?.provider || 'local'
|
|
const result = db.prepare(`
|
|
INSERT INTO users (username, display_name, password_hash, role, provider, provider_user_id, email, avatar_url, is_approved, approved_by, approved_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
username,
|
|
displayName,
|
|
passwordHash,
|
|
role,
|
|
provider,
|
|
options?.provider_user_id || null,
|
|
options?.email || null,
|
|
options?.avatar_url || null,
|
|
typeof options?.is_approved === 'number' ? options.is_approved : 1,
|
|
options?.approved_by || null,
|
|
options?.approved_at || null,
|
|
)
|
|
|
|
return getUserById(Number(result.lastInsertRowid))!
|
|
}
|
|
|
|
export function updateUser(id: number, updates: { display_name?: string; role?: User['role']; password?: string; email?: string | null; avatar_url?: string | null; is_approved?: 0 | 1 }): User | null {
|
|
const db = getDatabase()
|
|
const fields: string[] = []
|
|
const params: any[] = []
|
|
|
|
if (updates.display_name !== undefined) { fields.push('display_name = ?'); params.push(updates.display_name) }
|
|
if (updates.role !== undefined) { fields.push('role = ?'); params.push(updates.role) }
|
|
if (updates.password !== undefined) { fields.push('password_hash = ?'); params.push(hashPassword(updates.password)) }
|
|
if (updates.email !== undefined) { fields.push('email = ?'); params.push(updates.email) }
|
|
if (updates.avatar_url !== undefined) { fields.push('avatar_url = ?'); params.push(updates.avatar_url) }
|
|
if (updates.is_approved !== undefined) { fields.push('is_approved = ?'); params.push(updates.is_approved) }
|
|
|
|
if (fields.length === 0) return getUserById(id)
|
|
|
|
fields.push('updated_at = ?')
|
|
params.push(Math.floor(Date.now() / 1000))
|
|
params.push(id)
|
|
|
|
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...params)
|
|
return getUserById(id)
|
|
}
|
|
|
|
export function deleteUser(id: number): boolean {
|
|
const db = getDatabase()
|
|
destroyAllUserSessions(id)
|
|
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
|
return result.changes > 0
|
|
}
|
|
|
|
/**
|
|
* Seed admin user from environment variables on first run.
|
|
* If no users exist, creates an admin from AUTH_USER/AUTH_PASS env vars.
|
|
*/
|
|
/**
|
|
* Get user from request - checks session cookie or API key.
|
|
* For API key auth, returns a synthetic "api" user.
|
|
*/
|
|
export function getUserFromRequest(request: Request): User | null {
|
|
// Check session cookie
|
|
const cookieHeader = request.headers.get('cookie') || ''
|
|
const sessionToken = parseCookie(cookieHeader, 'mc-session')
|
|
if (sessionToken) {
|
|
const user = validateSession(sessionToken)
|
|
if (user) return user
|
|
}
|
|
|
|
// Check API key - return synthetic user
|
|
const apiKey = request.headers.get('x-api-key')
|
|
if (apiKey && safeCompare(apiKey, process.env.API_KEY || '')) {
|
|
return {
|
|
id: 0,
|
|
username: 'api',
|
|
display_name: 'API Access',
|
|
role: 'admin',
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
last_login_at: null,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Role hierarchy levels for access control.
|
|
* viewer < operator < admin
|
|
*/
|
|
const ROLE_LEVELS: Record<string, number> = { viewer: 0, operator: 1, admin: 2 }
|
|
|
|
/**
|
|
* Check if a user meets the minimum role requirement.
|
|
* Returns { user } on success, or { error, status } on failure (401 or 403).
|
|
*/
|
|
export function requireRole(
|
|
request: Request,
|
|
minRole: User['role']
|
|
): { user: User; error?: never; status?: never } | { user?: never; error: string; status: 401 | 403 } {
|
|
const user = getUserFromRequest(request)
|
|
if (!user) {
|
|
return { error: 'Authentication required', status: 401 }
|
|
}
|
|
if ((ROLE_LEVELS[user.role] ?? -1) < ROLE_LEVELS[minRole]) {
|
|
return { error: `Requires ${minRole} role or higher`, status: 403 }
|
|
}
|
|
return { user }
|
|
}
|
|
|
|
function parseCookie(cookieHeader: string, name: string): string | null {
|
|
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
|
|
return match ? decodeURIComponent(match[1]) : null
|
|
}
|