122 lines
4.3 KiB
TypeScript
122 lines
4.3 KiB
TypeScript
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.COOKIE_SECURE === 'true',
|
|
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>; accessLevel?: 'admin' | 'write' | 'read' }
|
|
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, accessLevel: 'admin' }
|
|
|
|
// Lazy import to avoid pulling DB into edge contexts
|
|
const { getLibraryAccessLevel } = await import('./users')
|
|
const accessLevel = getLibraryAccessLevel(session.userId, libraryId)
|
|
if (!accessLevel) {
|
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
}
|
|
return { session, accessLevel }
|
|
}
|
|
|
|
export async function requireLibraryWriteAccess(req: NextRequest, libraryId: string): Promise<AuthResult> {
|
|
const result = await requireLibraryAccess(req, libraryId)
|
|
if (result instanceof NextResponse) return result
|
|
if (result.accessLevel === 'read') {
|
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
}
|
|
return result
|
|
}
|