kodingup.camp

Beranda/Node.js Backend: Database, Auth, Keamanan & Deploy

Autentikasi

Modul 3 dari 107 menit bacaAuthentication

Isi modul

    Kenapa ini penting

    Autentikasi (authentication) menjawab satu pertanyaan: "kamu ini siapa?". Tanpa autentikasi, server tidak bisa membedakan Budi dari Siti, apalagi dari penyerang. Keranjang belanja, riwayat pesanan, dan halaman admin semuanya bergantung pada jawaban pertanyaan itu.

    Autentikasi juga area yang paling sering dibobol. Database bocor itu kejadian rutin di berita. Yang membedakan "bocor tapi password pengguna tetap aman" dari "jutaan password terbongkar" adalah cara password disimpan. Yang membedakan "sesi aman" dari "akun bisa dibajak lewat satu skrip jahat" adalah cara token disimpan di browser. Modul ini membahas keduanya dengan cara yang dipakai di industri.

    Analogi yang akan kita pakai: autentikasi itu seperti resepsionis hotel. Saat check-in kamu menunjukkan KTP (email + password). Resepsionis mencocokkannya, lalu memberimu kartu kamar (session atau token). Setelah itu kamu tidak perlu menunjukkan KTP lagi setiap mau masuk lift. Cukup tempelkan kartunya.

    Konsep inti

    Jangan pernah menyimpan password apa adanya

    Password tidak boleh disimpan sebagai teks asli (plaintext), dan juga tidak boleh dienkripsi (karena yang bisa dienkripsi bisa didekripsi). Yang benar adalah hash: fungsi satu arah yang mengubah rahasia123 menjadi deretan karakter yang tidak bisa dibalik. Saat login, password yang diketik di-hash ulang lalu dicocokkan.

    Tapi tidak sembarang hash. SHA-256 atau MD5 dirancang cepat, dan justru itu masalahnya: GPU modern bisa mencoba miliaran tebakan per detik. Untuk password, kita butuh hash yang sengaja lambat dan memakai salt (data acak unik per password, supaya dua orang dengan password sama punya hash berbeda dan tabel tebakan siap pakai jadi tidak berguna). Dua pilihan utama:

    argon2 (paket argon2 0.45.1) bcrypt (paket bcrypt 6.0.0)
    Status Pemenang Password Hashing Competition 2015, rekomendasi utama OWASP (varian argon2id) Sudah dipakai puluhan tahun, masih aman
    Kelebihan Boros memori, sehingga mahal untuk diserang dengan GPU Didukung di mana-mana
    Batasan Paket native (butuh binary yang cocok platform) Hanya membaca 72 byte pertama password
    API argon2.hash(pw), argon2.verify(hash, pw) bcrypt.hash(pw, 12), bcrypt.compare(pw, hash)

    Kedua library menyimpan salt dan parameternya di dalam string hash, jadi kamu cukup menyimpan satu kolom password_hash.

    Alur register dan login

    Register: validasi input → cek email belum dipakai → hash password → simpan user → langsung loginkan (pasang cookie).

    Login: validasi input → cari user berdasarkan email → verify password → kalau cocok, terbitkan session/token. Dua detail penting:

    Setelah login berhasil, server harus memberi "kartu kamar". Ada dua pendekatan:

    Session (stateful) JWT (stateless)
    Isi kartu ID acak, misalnya sid=8f3a... Data user yang ditandatangani, misalnya { sub: 42, role: 'user', exp: ... }
    Disimpan di server Ya, tabel/penyimpanan session (database, Redis) Tidak, server cukup memverifikasi tanda tangan
    Logout / cabut akses Mudah: hapus session di server Sulit: token tetap valid sampai kedaluwarsa, kecuali kamu membuat daftar blokir
    Skala ke banyak server Semua server harus membaca penyimpanan session yang sama Server mana pun bisa memverifikasi dengan kunci yang sama
    Cocok untuk Aplikasi web biasa, panel admin API yang dipakai banyak layanan, aplikasi mobile

    JWT (JSON Web Token) terdiri dari tiga bagian yang dipisah titik: header.payload.signature. Header dan payload hanya di-encode base64url. Siapa pun bisa membacanya, jadi jangan pernah menaruh data rahasia di payload. Yang membuat JWT bisa dipercaya adalah signature: hasil tanda tangan dengan kunci rahasia server. Ubah satu huruf payload (misalnya role jadi admin), dan tanda tangannya tidak cocok lagi.

    Di materi ini kita memakai library jose 6.2.13 karena ESM-native, tanpa dependency, dan berjalan di Node maupun edge runtime. jsonwebtoken 9.0.3 adalah alternatif lama yang masih banyak dipakai. API-nya ditunjukkan di contoh kode supaya kamu mengenalinya di proyek orang lain.

    Keduanya bukan pilihan "benar vs salah". Yang paling penting adalah di mana kartunya disimpan di browser.

    Banyak tutorial menyimpan JWT di localStorage. Masalahnya, localStorage bisa dibaca semua JavaScript di halaman itu, termasuk skrip jahat hasil serangan XSS (Cross-Site Scripting) atau paket npm yang tersusupi. Sekali token terbaca, akun bisa dibajak dari mana saja.

    Cookie dengan atribut yang tepat jauh lebih aman:

    Untuk membaca cookie di Express, pasang middleware cookie-parser (1.4.7). Isinya akan tersedia di req.cookies.

    Refresh token, singkatnya

    Token yang berumur panjang berbahaya kalau bocor. Token yang berumur pendek (misalnya 15 menit) membuat pengguna harus sering login. Jalan tengahnya adalah pasangan token:

    Setiap kali refresh dipakai, terbitkan refresh token baru (rotasi). Di sistem produksi, refresh token biasanya juga dicatat di database supaya bisa dicabut saat logout atau saat dicurigai bocor. Untuk Proyek Akhir kita memakai satu token 2 jam saja supaya alurnya mudah diikuti. Contoh refresh lengkap ada di Latihan 3.

    Contoh kode

    Hash password dengan argon2 dan bcrypt (output asli):

    // hash.js, hash password dengan argon2 dan bcrypt
    import argon2 from 'argon2';
    import bcrypt from 'bcrypt';
    
    const password = 'rahasia123';
    
    const h1 = await argon2.hash(password); // default: argon2id + salt acak
    const h2 = await argon2.hash(password);
    console.log(h1);
    // → $argon2id$v=19$m=65536,p=4,t=3$wK2NC+Cbwv5Iu6rkzp80Pg$Z6nfeQ5Q0zpCLEFEKmDySA2yLGsYOPq//95Us3067oU
    //   (varian, versi, memori 64 MB, paralelisme 4, 3 iterasi, salt, hash)
    console.log(h1 === h2);                              // → false (salt beda tiap kali)
    console.log(await argon2.verify(h1, password));      // → true
    console.log(await argon2.verify(h1, 'rahasia124'));  // → false
    
    const b = await bcrypt.hash(password, 12); // 12 = cost factor (2^12 putaran)
    console.log(b);                                      // → $2b$12$pOzAlncg9Pt97LVY8XoujeJOq...
    console.log(await bcrypt.compare(password, b));      // → true
    
    // berapa lama satu hash? (sengaja lambat supaya brute force mahal)
    let t = performance.now(); await argon2.hash(password);
    console.log('argon2:', Math.round(performance.now() - t), 'ms'); // → argon2: 26 ms
    t = performance.now(); await bcrypt.hash(password, 12);
    console.log('bcrypt:', Math.round(performance.now() - t), 'ms'); // → bcrypt: 218 ms
    

    Angka waktunya diukur di sebuah Mac mini dan akan berbeda di mesinmu. Intinya: puluhan sampai ratusan milidetik itu tidak terasa bagi pengguna yang login sekali, tapi sangat mahal bagi penyerang yang ingin mencoba miliaran tebakan.

    Membuat dan memverifikasi JWT dengan jose:

    // jwt.js, membuat dan memverifikasi JWT dengan jose 6
    import { SignJWT, jwtVerify, decodeJwt } from 'jose';
    
    const key = new TextEncoder().encode('rahasia-minimal-32-karakter-panjangnya-ya!');
    
    const token = await new SignJWT({ role: 'user' })
      .setProtectedHeader({ alg: 'HS256' })
      .setSubject('42')          // "sub" = id user
      .setIssuedAt()
      .setExpirationTime('15m')
      .sign(key);
    console.log(token.split('.').length); // → 3  (header.payload.signature)
    
    // payload BISA DIBACA siapa saja (cuma base64url), jadi jangan taruh rahasia di sini
    console.log(decodeJwt(token)); // → { role: 'user', sub: '42', iat: 1792779303, exp: 1792780203 }
    
    const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
    console.log(payload.sub, payload.role); // → 42 user
    
    // penyerang mengubah role di payload → tanda tangan tidak cocok
    const [h, p, s] = token.split('.');
    const palsu = Buffer.from(JSON.stringify({ ...decodeJwt(token), role: 'admin' })).toString('base64url');
    try { await jwtVerify(`${h}.${palsu}.${s}`, key); }
    catch (e) { console.log(e.code); } // → ERR_JWS_SIGNATURE_VERIFICATION_FAILED
    
    // token kedaluwarsa
    const basi = await new SignJWT({}).setProtectedHeader({ alg: 'HS256' }).setExpirationTime('-1s').sign(key);
    try { await jwtVerify(basi, key); }
    catch (e) { console.log(e.code); } // → ERR_JWT_EXPIRED
    

    Versi jsonwebtoken, supaya kamu mengenalinya:

    import jwt from 'jsonwebtoken';
    
    const secret = 'rahasia-minimal-32-karakter-panjangnya-ya!';
    const token = jwt.sign({ role: 'user' }, secret, { subject: '42', expiresIn: '15m', algorithm: 'HS256' });
    const data = jwt.verify(token, secret, { algorithms: ['HS256'] });
    console.log(data.sub, data.role); // → 42 user
    try { jwt.verify(token, 'kunci-lain'); } catch (e) { console.log(e.name, e.message); }
    // → JsonWebTokenError invalid signature
    

    Sekarang dirangkai menjadi helper autentikasi untuk Express. File ini dipakai di Proyek Akhir:

    // src/middleware/auth.js, token JWT di cookie httpOnly
    import { SignJWT, jwtVerify } from 'jose';
    
    export const COOKIE_NAME = 'token';
    
    export function makeAuth(config) {
      const key = new TextEncoder().encode(config.JWT_SECRET);
    
      async function signToken(user) {
        return new SignJWT({ role: user.role })
          .setProtectedHeader({ alg: 'HS256' })
          .setSubject(String(user.id))
          .setIssuedAt()
          .setExpirationTime('2h')
          .sign(key);
      }
    
      function setAuthCookie(res, token) {
        res.cookie(COOKIE_NAME, token, {
          httpOnly: true,               // JavaScript di browser tidak bisa membaca
          secure: config.isProd,        // hanya lewat HTTPS di produksi
          sameSite: 'lax',              // mengurangi risiko CSRF
          maxAge: 2 * 60 * 60 * 1000,   // 2 jam, samakan dengan exp token
          path: '/',
        });
      }
    
      // requireAuth & requireRole dibahas di Modul 4
      // ...
      return { signToken, setAuthCookie /*, requireAuth, requireRole */ };
    }
    

    Route register, login, logout, dan me:

    // src/routes/auth.js, register, login, logout, me
    import { Router } from 'express';
    import argon2 from 'argon2';
    import { z } from 'zod';
    import { eq } from 'drizzle-orm';
    import { users } from '../db/schema.js';
    import { validate } from '../middleware/validate.js';
    import { COOKIE_NAME } from '../middleware/auth.js';
    
    const RegisterSchema = z.object({
      email: z.email().transform((e) => e.toLowerCase()),
      name: z.string().trim().min(2).max(60),
      password: z.string().min(8).max(128),
    });
    const LoginSchema = z.object({
      email: z.email().transform((e) => e.toLowerCase()),
      password: z.string().min(1).max(128),
    });
    
    // hash palsu untuk menyamakan waktu respons saat email tidak terdaftar
    const DUMMY_HASH = await argon2.hash('password-palsu-untuk-timing');
    
    const publicUser = (u) => ({ id: u.id, email: u.email, name: u.name, role: u.role });
    
    export function authRouter({ db, auth, authLimiter }) {
      const r = Router();
    
      r.post('/register', authLimiter, validate(RegisterSchema), async (req, res) => {
        const { email, name, password } = req.body;
        const ada = db.select({ id: users.id }).from(users).where(eq(users.email, email)).get();
        if (ada) return res.status(409).json({ error: 'Email sudah terdaftar' });
    
        const passwordHash = await argon2.hash(password); // argon2id, salt otomatis
        // role TIDAK diambil dari body: user tidak boleh mendaftarkan diri jadi admin
        const user = db.insert(users).values({ email, name, passwordHash }).returning().get();
        auth.setAuthCookie(res, await auth.signToken(user));
        res.status(201).json(publicUser(user)); // JANGAN kirim passwordHash ke klien
      });
    
      r.post('/login', authLimiter, validate(LoginSchema), async (req, res) => {
        const { email, password } = req.body;
        const user = db.select().from(users).where(eq(users.email, email)).get();
        const cocok = await argon2.verify(user?.passwordHash ?? DUMMY_HASH, password);
        if (!user || !cocok) {
          return res.status(401).json({ error: 'Email atau password salah' }); // pesan SAMA untuk dua kasus
        }
        auth.setAuthCookie(res, await auth.signToken(user));
        res.json(publicUser(user));
      });
    
      r.post('/logout', (req, res) => {
        res.clearCookie(COOKIE_NAME, { path: '/' });
        res.status(204).end();
      });
    
      r.get('/me', auth.requireAuth, (req, res) => {
        const user = db.select().from(users).where(eq(users.id, req.user.id)).get();
        if (!user) return res.status(401).json({ error: 'User tidak ada' });
        res.json(publicUser(user));
      });
    
      return r;
    }
    

    Diuji dengan curl ke server Proyek Akhir yang sedang berjalan. -c menyimpan cookie ke file, -D - menampilkan header respons:

    curl -s -c admin.txt -X POST http://localhost:3000/api/auth/login \
      -H 'content-type: application/json' -d '{"email":"admin@toko.id","password":"admin12345"}'
    # → {"id":1,"email":"admin@toko.id","name":"Admin","role":"admin"}
    
    curl -s -D - -o /dev/null -X POST http://localhost:3000/api/auth/login \
      -H 'content-type: application/json' -d '{"email":"admin@toko.id","password":"admin12345"}' | grep -i set-cookie
    # → Set-Cookie: token=eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYWRtaW4iLCJzdWIiOiIxIi...; Max-Age=7200; Path=/;
    #   Expires=Fri, 23 Oct 2026 20:12:55 GMT; HttpOnly; SameSite=Lax
    
    curl -s -X POST http://localhost:3000/api/auth/register -H 'content-type: application/json' \
      -d '{"email":"budi@mail.com","name":"Budi","password":"rahasia123"}'
    # → {"id":2,"email":"budi@mail.com","name":"Budi","role":"user"}
    
    curl -s -D - -o /dev/null -b admin.txt -X POST http://localhost:3000/api/auth/logout | grep -iE "^(HTTP|set-cookie)"
    # → HTTP/1.1 204 No Content
    # → Set-Cookie: token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
    

    Perhatikan Secure tidak muncul karena server berjalan dalam mode development. Di produksi (NODE_ENV=production) atribut itu otomatis ikut.

    Kesalahan umum

    1. Menyimpan JWT di localStorage. Skrip XSS bisa mencurinya. Simpan di cookie httpOnly + secure + sameSite.
    2. Memakai hash cepat (MD5, SHA-256) untuk password. Itu bukan hashing password. Pakai argon2id atau bcrypt, yang lambat dan memakai salt.
    3. Mengirim passwordHash ke klien. res.json(user) mengirim semua kolom. Buat function seperti publicUser() yang memilih field aman saja.
    4. Mengambil role dari body register. Kalau kamu melakukan db.insert(users).values(req.body), siapa pun bisa mengirim "role": "admin". Ini disebut mass assignment. Zod z.object() membuang field yang tidak didefinisikan, dan role tidak pernah boleh diisi dari input.
    5. Kunci JWT pendek atau ditulis di kode. JWT_SECRET=rahasia bisa ditebak dengan brute force dalam hitungan menit. Buat string acak minimal 32 byte (node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))") dan simpan di environment variable, bukan di Git.

    Latihan

    1. Mudah, Buat skema Zod PasswordSchema yang mewajibkan minimal 8 karakter, minimal satu angka, dan minimal satu huruf, masing-masing dengan pesan error Bahasa Indonesia.
    2. Sedang, Tambahkan endpoint PATCH /api/auth/password (wajib login) yang menerima oldPassword dan newPassword. Kalau password lama salah, balas 401. Kalau benar, simpan hash baru dan balas 204.
    3. Menantang, Buat alur refresh token: login memasang cookie access (15 menit, path /) dan cookie refresh (7 hari, path /api/auth/refresh, ditandatangani kunci berbeda). Endpoint POST /api/auth/refresh memverifikasi refresh token lalu menerbitkan pasangan token baru.
    Lihat solusi

    1. .regex() bisa dirantai, setiap aturan punya pesannya sendiri.

    import { z } from 'zod';
    
    const PasswordSchema = z.string()
      .min(8, 'Minimal 8 karakter')
      .regex(/[0-9]/, 'Harus ada angka')
      .regex(/[a-zA-Z]/, 'Harus ada huruf');
    
    console.log(PasswordSchema.safeParse('abcdefgh').error.issues.map((i) => i.message)); // → [ 'Harus ada angka' ]
    console.log(PasswordSchema.safeParse('abc1').error.issues.map((i) => i.message));     // → [ 'Minimal 8 karakter' ]
    console.log(PasswordSchema.safeParse('rahasia123').success);                           // → true
    

    2. Tambahkan di dalam authRouter, sebelum return r:

    const ChangePasswordSchema = z.object({
      oldPassword: z.string().min(1).max(128),
      newPassword: z.string().min(8).max(128),
    });
    
    r.patch('/password', auth.requireAuth, validate(ChangePasswordSchema), async (req, res) => {
      const user = db.select().from(users).where(eq(users.id, req.user.id)).get();
      if (!user || !(await argon2.verify(user.passwordHash, req.body.oldPassword))) {
        return res.status(401).json({ error: 'Password lama salah' });
      }
      const passwordHash = await argon2.hash(req.body.newPassword);
      db.update(users).set({ passwordHash }).where(eq(users.id, user.id)).run();
      res.status(204).end();
    });
    // Diuji dengan Supertest:
    // password lama salah → 401 { error: 'Password lama salah' }
    // password lama benar → 204
    // login dengan password lama → 401, dengan password baru → 200
    

    3. Path sempit membuat browser hanya mengirim refresh token ke satu endpoint.

    // refresh.js
    import express from 'express';
    import cookieParser from 'cookie-parser';
    import { SignJWT, jwtVerify } from 'jose';
    
    const ACCESS_KEY = new TextEncoder().encode(process.env.ACCESS_SECRET);
    const REFRESH_KEY = new TextEncoder().encode(process.env.REFRESH_SECRET); // kunci BEDA
    
    const sign = (sub, key, exp, extra = {}) =>
      new SignJWT(extra).setProtectedHeader({ alg: 'HS256' }).setSubject(String(sub))
        .setIssuedAt().setExpirationTime(exp).sign(key);
    
    const cookieBase = { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' };
    
    export async function setTokens(res, user) {
      res.cookie('access', await sign(user.id, ACCESS_KEY, '15m', { role: user.role }),
        { ...cookieBase, maxAge: 15 * 60e3, path: '/' });
      res.cookie('refresh', await sign(user.id, REFRESH_KEY, '7d'),
        { ...cookieBase, maxAge: 7 * 864e5, path: '/api/auth/refresh' });
    }
    
    export function refreshRoute(findUser) {
      return async (req, res) => {
        try {
          const { payload } = await jwtVerify(req.cookies.refresh ?? '', REFRESH_KEY, { algorithms: ['HS256'] });
          const user = findUser(Number(payload.sub)); // ambil role TERBARU dari database
          if (!user) return res.status(401).json({ error: 'User tidak ada' });
          await setTokens(res, user); // rotasi: refresh token lama diganti yang baru
          res.json({ ok: true });
        } catch {
          res.status(401).json({ error: 'Silakan login lagi' });
        }
      };
    }
    // app.post('/api/auth/refresh', refreshRoute(findUser));
    // Hasil uji:
    // cookie login → [' Max-Age=900; Path=/; HttpOnly', ' Max-Age=604800; Path=/api/auth/refresh; HttpOnly']
    // refresh dengan cookie → 200 (2 cookie baru), tanpa cookie → 401 { error: 'Silakan login lagi' }
    

    Ringkasan

    Video pendamping

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

    JWT Authentication | Node JS and Express tutorials for BeginnersDave Gray · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

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