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 }
|
||||
}
|
||||
Reference in New Issue
Block a user