kodingup.camp

Beranda/Next.js Mastery: Full-Stack, Auth & Deploy

Autentikasi

Modul 6 dari 116 menit bacaAuthentication

Isi modul

    Kenapa ini penting

    Hampir semua aplikasi nyata punya bagian yang tidak boleh dibuka sembarang orang: dashboard admin, halaman profil, riwayat pesanan. Untuk itu aplikasi harus menjawab dua pertanyaan berbeda:

    Lalu ada pertanyaan ketiga: bagaimana server mengingat bahwa kamu sudah login, padahal HTTP itu "pelupa" (setiap request berdiri sendiri)? Jawabannya adalah session. Kesalahan kecil di salah satu bagian ini bisa berakibat fatal, misalnya data semua pengguna bisa dibaca orang lain. Karena itu modul ini membahas konsepnya dengan teliti, membangun versi sederhana yang dianjurkan dokumentasi Next.js untuk belajar, lalu menunjukkan cara memakai library auth untuk produksi.

    Analogi: login itu seperti check-in di hotel. Resepsionis memeriksa KTP-mu sekali (autentikasi), lalu memberimu kartu kamar (session). Setiap kali masuk lift atau kamar, kamu cukup menempelkan kartu, tidak perlu menunjukkan KTP lagi. Kartu itu juga menentukan lantai mana yang boleh kamu akses (otorisasi). Kalau kartunya palsu atau kedaluwarsa, pintu tidak terbuka.

    Konsep inti

    Session vs JWT: dua cara menyimpan "kartu kamar"

    Dokumentasi Next.js membagi session menjadi dua jenis:

    Stateless (token di cookie, biasanya JWT) Database session
    Isi cookie Data session yang ditandatangani, misalnya { userId, role, expiresAt } Hanya ID session acak
    Cek di server Verifikasi tanda tangan, tanpa query DB Cari ID di tabel sessions
    Kelebihan Sederhana, cepat, tanpa tabel tambahan Bisa dicabut kapan saja ("logout dari semua perangkat"), lebih aman
    Kekurangan Sulit dicabut sebelum kedaluwarsa Butuh query DB setiap request

    JWT (JSON Web Token) adalah format token berisi data JSON plus tanda tangan digital. Server menandatangani token dengan secret key; kalau ada yang mengubah isinya walau satu huruf, tanda tangannya tidak cocok dan token ditolak. Penting: JWT standar hanya ditandatangani, bukan dienkripsi. Isinya bisa dibaca siapa pun yang memegang token, jadi jangan simpan data sensitif (email, nomor HP, password) di dalamnya. Cukup ID dan peran.

    Token disimpan di cookie dengan opsi berikut (sesuai rekomendasi dokumentasi Next.js):

    Di Next.js 16, cookies() adalah async: const cookieStore = await cookies(). Membaca cookie bisa di Server Component, tapi menulis atau menghapus cookie hanya bisa di Server Action atau Route Handler, karena header Set-Cookie harus dikirim sebelum HTML mulai di-stream.

    Data Access Layer (DAL) dan verifySession

    Dokumentasi Next.js menganjurkan membuat Data Access Layer: satu modul khusus server yang memusatkan pengecekan session dan akses data. Di dalamnya ada function seperti verifySession() yang membaca cookie, memverifikasi token, dan me-redirect('/login') bila tidak valid. Function ini dibungkus cache() dari React supaya dalam satu proses render hanya dijalankan sekali walau dipanggil di banyak komponen. (Catatan dari dokumentasi: Server Action dan Route Handler bukan bagian render pass, jadi di sana cache() tidak berefek dan function berjalan setiap dipanggil. Itu tidak masalah.)

    Lapisan pertahanan yang lengkap:

    1. Proxy → redirect cepat (optimistis), Modul 5.
    2. DAL → verifySession() dipanggil sebelum mengambil data sensitif.
    3. Setiap Server Action → panggil verifySession() di baris pertama.
    4. Setiap Route Handler yang sensitif → cek session atau secret.

    Jangan hanya cek di layout.js. Layout tidak dirender ulang di setiap navigasi, dan Server Action tetap bisa dipanggil langsung tanpa melewati layout.

    Library auth: mana yang dipakai?

    Dokumentasi Next.js menulis contoh username/password manual untuk tujuan edukasi, lalu menyarankan memakai library auth untuk produksi karena sudah menangani login sosial, MFA, rate limiting, reset password, dan banyak detail keamanan lain. Daftar library di dokumentasinya antara lain Auth0, Better Auth, Clerk, Descope, Kinde, Logto, NextAuth.js (Auth.js), Ory, Stack Auth, Supabase, Stytch, dan WorkOS. Dokumentasi tidak memilih satu pemenang. Untuk session manual, ia menyebut jose dan iron-session.

    Keputusan untuk materi ini:

    Contoh kode

    Pertama, buat secret untuk menandatangani token dan simpan di .env.local:

    openssl rand -base64 32
    # → misalnya: 3kP9x...Q= (salin ke .env.local)
    
    # .env.local  (JANGAN di-commit)
    SESSION_SECRET=3kP9x...Q=
    ADMIN_PASSWORD=rahasia-belajar-123
    

    Modul session: membuat, memverifikasi, dan menghapus token.

    // lib/session.js
    import 'server-only'
    import { SignJWT, jwtVerify } from 'jose'
    import { cookies } from 'next/headers'
    
    const encodedKey = new TextEncoder().encode(process.env.SESSION_SECRET)
    const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000
    
    export async function encrypt(payload) {
      return new SignJWT(payload)
        .setProtectedHeader({ alg: 'HS256' })
        .setIssuedAt()
        .setExpirationTime('7d')
        .sign(encodedKey)
    }
    
    export async function decrypt(token) {
      if (!token) return null
      try {
        const { payload } = await jwtVerify(token, encodedKey, { algorithms: ['HS256'] })
        return payload // → { role: 'admin', expiresAt: '...', iat: ..., exp: ... }
      } catch {
        return null // token rusak, dipalsukan, atau kedaluwarsa
      }
    }
    
    export async function createSession(role) {
      const expiresAt = new Date(Date.now() + SEVEN_DAYS)
      const token = await encrypt({ role, expiresAt })
      const cookieStore = await cookies() // async di Next.js 15+
      cookieStore.set('session', token, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        expires: expiresAt,
        sameSite: 'lax',
        path: '/',
      })
    }
    
    export async function deleteSession() {
      const cookieStore = await cookies()
      cookieStore.delete('session')
    }
    

    Server Action untuk login dan logout. Karena proyek akhir hanya punya satu admin, password admin disimpan di environment variable dan dibandingkan dengan waktu konstan (timingSafeEqual) agar penyerang tidak bisa menebak dari lamanya respons.

    // app/actions/auth.js
    'use server'
    
    import { createHash, timingSafeEqual } from 'node:crypto'
    import { redirect } from 'next/navigation'
    import { z } from 'zod'
    import { createSession, deleteSession } from '@/lib/session'
    
    const LoginSchema = z.object({
      password: z.string().min(1, 'Password wajib diisi'),
    })
    
    // Hash dulu supaya panjangnya sama, lalu bandingkan dengan waktu konstan
    function safeEqual(a, b) {
      const ha = createHash('sha256').update(a).digest()
      const hb = createHash('sha256').update(b).digest()
      return timingSafeEqual(ha, hb)
    }
    
    export async function login(prevState, formData) {
      const parsed = LoginSchema.safeParse({ password: formData.get('password') })
      if (!parsed.success) {
        return { error: z.flattenError(parsed.error).fieldErrors.password?.[0] }
      }
      if (!safeEqual(parsed.data.password, process.env.ADMIN_PASSWORD ?? '')) {
        return { error: 'Password salah' } // pesan umum, jangan bocorkan detail
      }
      await createSession('admin')
      redirect('/admin')
    }
    
    export async function logout() {
      await deleteSession()
      redirect('/login')
    }
    

    Form login (Client Component) dan tombol logout:

    // app/login/LoginForm.jsx
    'use client'
    
    import { useActionState } from 'react'
    import { login } from '@/app/actions/auth'
    import { SubmitButton } from '@/components/SubmitButton'
    
    export function LoginForm() {
      const [state, formAction] = useActionState(login, undefined)
      return (
        <form action={formAction}>
          <input type="password" name="password" placeholder="Password admin" required />
          {state?.error && <p aria-live="polite">{state.error}</p>}
          <SubmitButton pendingText="Memeriksa...">Masuk</SubmitButton>
        </form>
      )
    }
    
    // Di halaman admin (Server Component), logout cukup form kecil:
    // <form action={logout}><button>Logout</button></form>
    

    Data Access Layer dengan verifyAdmin yang dipakai halaman dan action:

    // lib/dal.js  (potongan)
    import 'server-only'
    import { cache } from 'react'
    import { cookies } from 'next/headers'
    import { redirect } from 'next/navigation'
    import { decrypt } from '@/lib/session'
    
    export const verifyAdmin = cache(async () => {
      const token = (await cookies()).get('session')?.value
      const session = await decrypt(token)
      if (session?.role !== 'admin') redirect('/login')
      return { role: 'admin' }
    })
    
    export async function getAllPostsForAdmin() {
      await verifyAdmin() // data sensitif → cek dulu
      // ... query DB
    }
    

    Halaman terproteksi. Karena cookies() adalah data request, bagian yang membacanya dibungkus <Suspense>:

    // app/admin/page.js
    import { Suspense } from 'react'
    import { getAllPostsForAdmin } from '@/lib/dal'
    import { logout } from '@/app/actions/auth'
    import { PostRows } from '@/components/PostRows'
    
    async function AdminPosts() {
      const posts = await getAllPostsForAdmin() // redirect ke /login bila bukan admin
      return <PostRows posts={posts} />
    }
    
    export default function AdminPage() {
      return (
        <section>
          <h1>Dashboard</h1>
          <form action={logout}><button>Logout</button></form>
          <Suspense fallback={<p>Memuat daftar artikel...</p>}>
            <AdminPosts />
          </Suspense>
        </section>
      )
    }
    

    Versi produksi dengan Better Auth (gambaran)

    Untuk aplikasi dengan banyak pengguna terdaftar, berikut bentuk dasar Better Auth 1.7 sesuai dokumentasinya. Pasang dengan npm install better-auth, set BETTER_AUTH_SECRET dan BETTER_AUTH_URL di .env.local, lalu jalankan npx auth@latest generate untuk membuat tabel user/session di schema Drizzle-mu.

    // lib/auth.js
    import { betterAuth } from 'better-auth'
    import { drizzleAdapter } from 'better-auth/adapters/drizzle'
    import { nextCookies } from 'better-auth/next-js'
    import { db } from '@/lib/db'
    
    export const auth = betterAuth({
      database: drizzleAdapter(db, { provider: 'sqlite' }),
      emailAndPassword: { enabled: true },
      plugins: [nextCookies()], // wajib plugin terakhir: agar cookie ter-set dari Server Action
    })
    
    // app/api/auth/[...all]/route.js  → semua endpoint auth (login, logout, dsb.)
    import { auth } from '@/lib/auth'
    import { toNextJsHandler } from 'better-auth/next-js'
    
    export const { GET, POST } = toNextJsHandler(auth)
    
    // Di Server Component / Server Action
    import { headers } from 'next/headers'
    import { auth } from '@/lib/auth'
    
    const session = await auth.api.getSession({ headers: await headers() })
    // → null bila belum login, atau { user: { id, name, email, ... }, session: {...} }
    

    Di sisi client, createAuthClient dari better-auth/react menyediakan signIn.email(...), signUp.email(...), dan signOut(). Prinsip DAL tetap sama: bungkus auth.api.getSession di dalam verifySession() dan panggil di setiap action.

    Kesalahan umum

    1. Hanya melindungi di UI atau di layout. Tombol tersembunyi dan cek di layout.js tidak melindungi Server Action yang bisa dipanggil langsung. Panggil verifySession() di setiap action dan sebelum mengambil data sensitif.
    2. Menyimpan data sensitif di JWT. Payload JWT bisa dibaca siapa pun. Simpan hanya ID dan peran; ambil data lain dari database.
    3. Cookie session tanpa httpOnly. Script jahat hasil XSS bisa mencurinya. Selalu httpOnly: true, secure: true di produksi, dan sameSite: 'lax'.
    4. Secret lemah atau ikut di-commit. SESSION_SECRET=123 mudah ditebak. Buat dengan openssl rand -base64 32 dan simpan di .env.local serta pengaturan environment hosting.
    5. Mencoba cookies().set() di Server Component. Tidak didukung, karena cookie harus dikirim lewat header sebelum render di-stream. Set dan hapus cookie hanya di Server Action atau Route Handler.

    Latihan

    1. Mudah, Tampilkan teks "Login sebagai admin" di header situs bila cookie session valid. Gunakan komponen async kecil yang dibungkus <Suspense> di layout.js.
    2. Sedang, Tambahkan fungsi updateSession() yang memperpanjang masa berlaku cookie 7 hari setiap kali admin membuka dashboard, dengan memanggilnya dari Server Action "Tetap login".
    3. Menantang, Ubah sistem login menjadi multi-user: tabel users (id, email, passwordHash, role) dengan password di-hash memakai bcryptjs (3.0.3). Session menyimpan userId dan role, lalu verifySession() mengembalikan keduanya.
    Lihat solusi
    // 1. components/AdminBadge.jsx  (Server Component)
    import { cookies } from 'next/headers'
    import { decrypt } from '@/lib/session'
    
    export async function AdminBadge() {
      const session = await decrypt((await cookies()).get('session')?.value)
      return session?.role === 'admin' ? <span>Login sebagai admin</span> : null
    }
    
    // app/layout.js → di dalam <header>:
    // <Suspense fallback={null}><AdminBadge /></Suspense>
    
    // 2. lib/session.js
    export async function updateSession() {
      const cookieStore = await cookies()
      const payload = await decrypt(cookieStore.get('session')?.value)
      if (!payload) return null
      const expiresAt = new Date(Date.now() + SEVEN_DAYS)
      cookieStore.set('session', await encrypt({ role: payload.role, expiresAt }), {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        expires: expiresAt,
        sameSite: 'lax',
        path: '/',
      })
    }
    
    // app/actions/auth.js
    export async function stayLoggedIn() {
      await verifyAdmin()
      await updateSession()
    }
    
    // 3a. db/schema.js
    export const users = sqliteTable('users', {
      id: integer('id').primaryKey({ autoIncrement: true }),
      email: text('email').notNull().unique(),
      passwordHash: text('password_hash').notNull(),
      role: text('role').notNull().default('user'),
    })
    
    // 3b. app/actions/auth.js
    import bcrypt from 'bcryptjs'
    import { eq } from 'drizzle-orm'
    
    export async function login(prevState, formData) {
      const email = String(formData.get('email') ?? '')
      const password = String(formData.get('password') ?? '')
      const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1)
      const ok = user && (await bcrypt.compare(password, user.passwordHash))
      if (!ok) return { error: 'Email atau password salah' }
      await createSession({ userId: user.id, role: user.role })
      redirect('/dashboard')
    }
    
    // 3c. lib/session.js → createSession(data) menandatangani { ...data, expiresAt }
    // 3d. lib/dal.js
    export const verifySession = cache(async () => {
      const session = await decrypt((await cookies()).get('session')?.value)
      if (!session?.userId) redirect('/login')
      return { userId: session.userId, role: session.role }
    })
    

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Next.js App Router Authentication (Sessions, Cookies, JWTs)leerob · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.