feat(auth): phase 3 — replace Supabase GoTrue with bcryptjs+jose
Custom auth stack: bcryptjs password hashing (cost 10, GoTrue-compatible), jose JWT session cookies (edge-safe, 8hr TTL), new API routes for login/logout/reset/change-password, middleware rewritten to JWT-only verification with no DB access. All 38 protected pages and API routes migrated from supabase.auth.getUser() to getSession(). Supabase .from() queries retained for Phase 4. lib/db/index.ts refactored to lazy Proxy singleton to avoid module-level throw during Next.js build. tsc: clean, build: clean, tests: 4/4 passed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import 'server-only'
|
||||
import { cookies } from 'next/headers'
|
||||
import { verifySession, type SessionPayload } from './session'
|
||||
|
||||
export async function getSession(): Promise<SessionPayload | null> {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('ims_session')?.value
|
||||
if (!token) return null
|
||||
return verifySession(token)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import bcryptjs from 'bcryptjs'
|
||||
|
||||
const BCRYPT_ROUNDS = 10 // matches Supabase GoTrue default
|
||||
|
||||
export async function hashPassword(plaintext: string): Promise<string> {
|
||||
return bcryptjs.hash(plaintext, BCRYPT_ROUNDS)
|
||||
}
|
||||
|
||||
export async function verifyPassword(plaintext: string, hash: string): Promise<boolean> {
|
||||
return bcryptjs.compare(plaintext, hash)
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
import { getSession } from '@/lib/auth/get-session'
|
||||
import type { SessionPayload } from '@/lib/auth/session'
|
||||
|
||||
// Shared guard for /api/admin/* routes: resolves the session and requires
|
||||
// the admin role. Returns user: null when the caller must respond 403.
|
||||
// the admin role. Returns session: null when the caller must respond 403.
|
||||
export async function requireAdmin(): Promise<{
|
||||
supabase: SupabaseClient
|
||||
user: User | null
|
||||
session: SessionPayload | null
|
||||
}> {
|
||||
const supabase = await createClient()
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
if (error || !user) return { supabase, user: null }
|
||||
const { data: profile } = await supabase
|
||||
.from('users').select('role').eq('id', user.id).single()
|
||||
if (!profile || profile.role !== 'admin') return { supabase, user: null }
|
||||
return { supabase, user }
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'admin') return { session: null }
|
||||
return { session }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SignJWT, jwtVerify, type JWTPayload } from 'jose'
|
||||
|
||||
const SESSION_COOKIE = 'ims_session'
|
||||
const SESSION_TTL_SECONDS = 8 * 60 * 60 // 8 hours
|
||||
|
||||
export { SESSION_COOKIE }
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.JWT_SECRET
|
||||
if (!secret) throw new Error('JWT_SECRET not configured')
|
||||
return new TextEncoder().encode(secret)
|
||||
}
|
||||
|
||||
export interface SessionPayload {
|
||||
sub: string // user id (UUID)
|
||||
role: string // user_role enum value
|
||||
siteId: string | null
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function createSession(payload: SessionPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload } as JWTPayload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${SESSION_TTL_SECONDS}s`)
|
||||
.sign(getSecret())
|
||||
}
|
||||
|
||||
/** Verify and decode a JWT. Returns null on invalid/expired. */
|
||||
export async function verifySession(token: string): Promise<SessionPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getSecret())
|
||||
return payload as unknown as SessionPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
+43
-18
@@ -3,27 +3,52 @@ import { Pool } from 'pg'
|
||||
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||
import * as schema from './schema'
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL not configured')
|
||||
}
|
||||
if (!process.env.DATABASE_URL_ADMIN) {
|
||||
throw new Error('DATABASE_URL_ADMIN not configured')
|
||||
function getDb() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL not configured')
|
||||
}
|
||||
if (!process.env.DATABASE_URL_ADMIN) {
|
||||
throw new Error('DATABASE_URL_ADMIN not configured')
|
||||
}
|
||||
|
||||
// app_user pool: RLS enforced. Used for all normal user operations.
|
||||
const userPool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 10,
|
||||
})
|
||||
|
||||
// app_admin pool: BYPASSRLS. Used for admin user CRUD and service operations.
|
||||
const adminPool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL_ADMIN,
|
||||
max: 5,
|
||||
})
|
||||
|
||||
return {
|
||||
userDb: drizzle(userPool, { schema }),
|
||||
adminDb: drizzle(adminPool, { schema }),
|
||||
}
|
||||
}
|
||||
|
||||
// app_user pool: RLS enforced. Used for all normal user operations.
|
||||
const userPool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 10,
|
||||
// Lazy singleton — pools are created on first access, not at module load time.
|
||||
// This prevents Next.js build from throwing during static analysis when env vars
|
||||
// are not present in the build environment.
|
||||
let _dbs: ReturnType<typeof getDb> | null = null
|
||||
function dbs() {
|
||||
if (!_dbs) _dbs = getDb()
|
||||
return _dbs
|
||||
}
|
||||
|
||||
export const userDb = new Proxy({} as ReturnType<typeof getDb>['userDb'], {
|
||||
get(_target, prop) {
|
||||
return (dbs().userDb as unknown as Record<string | symbol, unknown>)[prop]
|
||||
},
|
||||
})
|
||||
|
||||
// app_admin pool: BYPASSRLS. Used for admin user CRUD and service operations.
|
||||
const adminPool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL_ADMIN,
|
||||
max: 5,
|
||||
export const adminDb = new Proxy({} as ReturnType<typeof getDb>['adminDb'], {
|
||||
get(_target, prop) {
|
||||
return (dbs().adminDb as unknown as Record<string | symbol, unknown>)[prop]
|
||||
},
|
||||
})
|
||||
|
||||
export const userDb = drizzle(userPool, { schema })
|
||||
export const adminDb = drizzle(adminPool, { schema })
|
||||
|
||||
export type UserDb = typeof userDb
|
||||
export type AdminDb = typeof adminDb
|
||||
export type UserDb = ReturnType<typeof getDb>['userDb']
|
||||
export type AdminDb = ReturnType<typeof getDb>['adminDb']
|
||||
|
||||
@@ -232,3 +232,14 @@ export const incidentAddenda = pgTable('incident_addenda', {
|
||||
body: text('body').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
// password_reset_tokens: added in Phase 3 for custom auth
|
||||
// accessed via app_admin only (BYPASSRLS); no user-facing policies needed
|
||||
export const passwordResetTokens = pgTable('password_reset_tokens', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
tokenHash: text('token_hash').notNull().unique(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
interface SendEmailParams {
|
||||
to: string
|
||||
subject: string
|
||||
html: string
|
||||
}
|
||||
|
||||
async function sendEmail({ to, subject, html }: SendEmailParams): Promise<void> {
|
||||
const apiKey = process.env.BREVO_API_KEY
|
||||
if (!apiKey) throw new Error('BREVO_API_KEY not configured')
|
||||
|
||||
const from = process.env.BREVO_FROM_EMAIL ?? 'noreply@setia.com.my'
|
||||
|
||||
const res = await fetch('https://api.brevo.com/v3/smtp/email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'api-key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sender: { email: from },
|
||||
to: [{ email: to }],
|
||||
subject,
|
||||
htmlContent: html,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`Brevo API error ${res.status}: ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail({
|
||||
to,
|
||||
name,
|
||||
resetLink,
|
||||
}: {
|
||||
to: string
|
||||
name: string
|
||||
resetLink: string
|
||||
}): Promise<void> {
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: 'IMS — Reset your password',
|
||||
html: `
|
||||
<p>Hi ${name},</p>
|
||||
<p>Click the link below to reset your IMS password. The link expires in 1 hour.</p>
|
||||
<p><a href="${resetLink}">${resetLink}</a></p>
|
||||
<p>If you did not request a password reset, ignore this email.</p>
|
||||
`,
|
||||
})
|
||||
}
|
||||
@@ -18,11 +18,8 @@ export async function uploadEvidenceFile(
|
||||
file: File,
|
||||
incidentId: string,
|
||||
stage: EvidenceStage,
|
||||
userId: string,
|
||||
): Promise<{ path: string; publicUrl: string; hash: string }> {
|
||||
const { data, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !data?.user) throw new Error('Not authenticated')
|
||||
const user = data.user
|
||||
|
||||
// Server-side size limit before processing
|
||||
const isVideo = file.type.startsWith('video/')
|
||||
const maxBytes = isVideo ? 200 * 1024 * 1024 : 10 * 1024 * 1024
|
||||
@@ -40,7 +37,7 @@ export async function uploadEvidenceFile(
|
||||
|
||||
const ext = detected?.ext ?? file.name.split('.').pop() ?? 'bin'
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
|
||||
const path = `${user.id}/${incidentId}/${stage}/${filename}`
|
||||
const path = `${userId}/${incidentId}/${stage}/${filename}`
|
||||
|
||||
const hash = await computeHashFromBuffer(buffer)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user