add auth
This commit is contained in:
112
src/lib/auth.ts
Normal file
112
src/lib/auth.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { getIronSession, type IronSession, type SessionOptions } from 'iron-session'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import crypto from 'crypto'
|
||||
|
||||
export interface SessionData {
|
||||
userId: string
|
||||
username: string
|
||||
role: 'admin' | 'user'
|
||||
}
|
||||
|
||||
export function getSessionOptions(): SessionOptions {
|
||||
const secret = process.env.SESSION_SECRET
|
||||
if (!secret) throw new Error('SESSION_SECRET is not set')
|
||||
return {
|
||||
password: secret,
|
||||
cookieName: 'ml_session',
|
||||
ttl: 60 * 60 * 24 * 30, // 30 days
|
||||
cookieOptions: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// For use in server components and layouts (Node.js runtime, read-only)
|
||||
export async function getServerSession(): Promise<IronSession<SessionData>> {
|
||||
const cookieStore = await cookies()
|
||||
return getIronSession<SessionData>(cookieStore, getSessionOptions())
|
||||
}
|
||||
|
||||
// For use in API routes where the session must be written (login/logout)
|
||||
// The caller is responsible for returning the response so Set-Cookie headers are sent
|
||||
export async function getWritableSession(
|
||||
req: NextRequest,
|
||||
res: NextResponse
|
||||
): Promise<IronSession<SessionData>> {
|
||||
return getIronSession<SessionData>(req, res, getSessionOptions())
|
||||
}
|
||||
|
||||
// Password hashing using Node.js built-in crypto (Node.js runtime only)
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.randomBytes(16)
|
||||
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, key) => {
|
||||
if (err) reject(err)
|
||||
else resolve(key)
|
||||
})
|
||||
})
|
||||
return `scrypt:${salt.toString('hex')}:${derivedKey.toString('hex')}`
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
const parts = hash.split(':')
|
||||
if (parts.length !== 3 || parts[0] !== 'scrypt') return false
|
||||
const salt = Buffer.from(parts[1], 'hex')
|
||||
const storedKey = Buffer.from(parts[2], 'hex')
|
||||
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, key) => {
|
||||
if (err) reject(err)
|
||||
else resolve(key)
|
||||
})
|
||||
})
|
||||
if (derivedKey.length !== storedKey.length) return false
|
||||
return crypto.timingSafeEqual(derivedKey, storedKey)
|
||||
}
|
||||
|
||||
// Auth guard result type
|
||||
type AuthSuccess = { session: IronSession<SessionData> }
|
||||
type AuthResult = AuthSuccess | NextResponse
|
||||
|
||||
// Read-only session from an API route request (throwaway response)
|
||||
async function getReadonlySession(req: NextRequest): Promise<IronSession<SessionData>> {
|
||||
const res = new NextResponse()
|
||||
return getIronSession<SessionData>(req, res, getSessionOptions())
|
||||
}
|
||||
|
||||
export async function requireAuth(req: NextRequest): Promise<AuthResult> {
|
||||
const session = await getReadonlySession(req)
|
||||
if (!session.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return { session }
|
||||
}
|
||||
|
||||
export async function requireAdmin(req: NextRequest): Promise<AuthResult> {
|
||||
const session = await getReadonlySession(req)
|
||||
if (!session.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
return { session }
|
||||
}
|
||||
|
||||
export async function requireLibraryAccess(req: NextRequest, libraryId: string): Promise<AuthResult> {
|
||||
const session = await getReadonlySession(req)
|
||||
if (!session.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (session.role === 'admin') return { session }
|
||||
|
||||
// Lazy import to avoid pulling DB into edge contexts
|
||||
const { getPermittedLibraryIds } = await import('./users')
|
||||
const permitted = getPermittedLibraryIds(session.userId)
|
||||
if (!permitted.includes(libraryId)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
return { session }
|
||||
}
|
||||
@@ -44,6 +44,20 @@ function initDb(db: Database.Database): void {
|
||||
type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')),
|
||||
cover_ext TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS library_permissions (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, library_id)
|
||||
);
|
||||
`)
|
||||
|
||||
migrateLibrariesType(db)
|
||||
|
||||
25
src/lib/secret.ts
Normal file
25
src/lib/secret.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const CONFIG_PATH = process.env.CONFIG_PATH ?? process.cwd()
|
||||
const SECRET_FILE = path.resolve(CONFIG_PATH, '.session_secret')
|
||||
|
||||
export function initializeSecret(): void {
|
||||
if (process.env.SESSION_SECRET) return
|
||||
|
||||
if (fs.existsSync(SECRET_FILE)) {
|
||||
process.env.SESSION_SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim()
|
||||
return
|
||||
}
|
||||
|
||||
const secret = crypto.randomBytes(32).toString('hex')
|
||||
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 })
|
||||
process.env.SESSION_SECRET = secret
|
||||
}
|
||||
|
||||
export function getSessionSecret(): string {
|
||||
const secret = process.env.SESSION_SECRET
|
||||
if (!secret) throw new Error('SESSION_SECRET is not set — call initializeSecret() at startup')
|
||||
return secret
|
||||
}
|
||||
119
src/lib/users.ts
Normal file
119
src/lib/users.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { getDb } from './db'
|
||||
import { getLibraries } from './libraries'
|
||||
import type { Library } from '@/types'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
role: 'admin' | 'user'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface UserWithHash extends User {
|
||||
passwordHash: string
|
||||
}
|
||||
|
||||
export function getUserCount(): number {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }
|
||||
return row.count
|
||||
}
|
||||
|
||||
export function getUserByUsername(username: string): UserWithHash | undefined {
|
||||
const db = getDb()
|
||||
const row = db
|
||||
.prepare('SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?')
|
||||
.get(username) as { id: string; username: string; password_hash: string; role: string; created_at: number } | undefined
|
||||
if (!row) return undefined
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
role: row.role as 'admin' | 'user',
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserById(id: string): User | undefined {
|
||||
const db = getDb()
|
||||
const row = db
|
||||
.prepare('SELECT id, username, role, created_at FROM users WHERE id = ?')
|
||||
.get(id) as { id: string; username: string; role: string; created_at: number } | undefined
|
||||
if (!row) return undefined
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
role: row.role as 'admin' | 'user',
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function createUser(username: string, passwordHash: string, role: 'admin' | 'user'): User {
|
||||
const db = getDb()
|
||||
const id = crypto.randomUUID()
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
db.prepare(
|
||||
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(id, username, passwordHash, role, now)
|
||||
return { id, username, role, createdAt: now }
|
||||
}
|
||||
|
||||
export function deleteUser(id: string): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function listUsers(): User[] {
|
||||
const db = getDb()
|
||||
const rows = db
|
||||
.prepare('SELECT id, username, role, created_at FROM users ORDER BY created_at ASC')
|
||||
.all() as { id: string; username: string; role: string; created_at: number }[]
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
role: r.role as 'admin' | 'user',
|
||||
createdAt: r.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getPermittedLibraryIds(userId: string): string[] {
|
||||
const db = getDb()
|
||||
const rows = db
|
||||
.prepare('SELECT library_id FROM library_permissions WHERE user_id = ?')
|
||||
.all(userId) as { library_id: string }[]
|
||||
return rows.map((r) => r.library_id)
|
||||
}
|
||||
|
||||
export function setLibraryPermissions(userId: string, libraryIds: string[]): void {
|
||||
const db = getDb()
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId)
|
||||
const insert = db.prepare('INSERT INTO library_permissions (user_id, library_id) VALUES (?, ?)')
|
||||
for (const libraryId of libraryIds) {
|
||||
insert.run(userId, libraryId)
|
||||
}
|
||||
})
|
||||
tx()
|
||||
}
|
||||
|
||||
export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] {
|
||||
if (role === 'admin') return getLibraries()
|
||||
const db = getDb()
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT l.id, l.name, l.path, l.type, l.cover_ext
|
||||
FROM libraries l
|
||||
INNER JOIN library_permissions lp ON lp.library_id = l.id
|
||||
WHERE lp.user_id = ?
|
||||
ORDER BY l.name ASC`
|
||||
)
|
||||
.all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null }[]
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
path: r.path,
|
||||
type: r.type as Library['type'],
|
||||
coverExt: r.cover_ext,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user