Lewati ke konten utama
Semua artikel
MenengahKeamanan

Autentikasi Modern: JWT & OAuth2

Implementasi auth yang benar — access token, refresh token, revocation, dan integrasi OAuth2 provider.

23 menit baca

"Auth yang buruk bukan yang tidak ada — tapi yang terlihat ada tapi mudah dibobol."


Tentang E-Book Ini

Auth adalah salah satu bagian paling kritis di backend — dan salah satu yang paling sering diimplementasikan dengan setengah-setengah. Tutorial di internet mengajarkan cara membuat JWT, tapi tidak mengajarkan cara mengelolanya dengan benar: bagaimana token dicabut, bagaimana refresh token dirotasi, bagaimana mendeteksi token yang dicuri.

E-book ini mengisi gap itu. Kita akan membangun sistem auth yang benar-benar production-ready — dari schema database, register dan login, access + refresh token pattern, revocation, RBAC, sampai OAuth2 dengan Google dan GitHub.

Prasyarat: Sudah baca e-book #02 (REST API dengan Node.js & Express) dan #03 (Desain Database). Familiar dengan Express middleware dan PostgreSQL.


Daftar Isi

  1. Fondasi: Bagaimana JWT Bekerja
  2. Database Schema untuk Auth
  3. Register & Login
  4. Access Token & Refresh Token Pattern
  5. Token Revocation & Logout
  6. Keamanan JWT
  7. Role-Based Access Control (RBAC)
  8. OAuth2: Login dengan Google & GitHub
  9. Multi-Device Session Management

Bab 1: Fondasi: Bagaimana JWT Bekerja

Anatomi JWT

JWT (JSON Web Token) adalah string dengan tiga bagian yang dipisahkan oleh titik:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Tiga bagian itu adalah:

[Header].[Payload].[Signature]

Header — metadata tentang token:

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload — data yang dibawa token (disebut claims):

{
  "sub": "user-id-123",
  "email": "user@email.com",
  "role": "admin",
  "iat": 1700000000,
  "exp": 1700003600
}

Signature — jaminan integritas:

HMACSHA256(
  base64url(header) + "." + base64url(payload),
  secret
)

Hal Paling Penting yang Harus Dipahami

JWT tidak dienkripsi — hanya di-sign. Artinya siapapun bisa membaca isi payload, tapi tidak bisa memalsukan-nya tanpa tahu secret key.

// Coba decode di jwt.io atau:
const [header, payload] = token.split('.');
console.log(JSON.parse(atob(payload)));
// Kamu bisa baca isinya tanpa secret!

Implikasi langsung:

  • Jangan simpan data sensitif di payload (password, nomor kartu, dsb)
  • Payload hanya berisi data yang aman dibaca, seperti user ID, role, email

Standard Claims

ClaimNamaFungsi
subSubjectID unik user/entity yang token ini mewakili
issIssuerSiapa yang menerbitkan token (nama service/domain)
expExpirationUnix timestamp kapan token expired
iatIssued AtUnix timestamp kapan token dibuat
jtiJWT IDID unik token ini — digunakan untuk blacklisting

HS256 vs RS256

HS256 (HMAC SHA-256) — symmetric:

  • Satu secret key yang sama digunakan untuk sign dan verify
  • Cocok untuk aplikasi monolith atau single service
  • Masalah: semua service yang perlu verify token harus tahu secret key

RS256 (RSA SHA-256) — asymmetric:

  • Private key untuk sign (hanya auth service yang tahu)
  • Public key untuk verify (bisa dibagikan ke semua service)
  • Cocok untuk microservices — service lain hanya butuh public key
  • Lebih aman tapi setup lebih kompleks
// HS256 — satu secret
jwt.sign(payload, process.env.JWT_SECRET, { algorithm: 'HS256' });
jwt.verify(token, process.env.JWT_SECRET);
 
// RS256 — key pair
jwt.sign(payload, privateKey, { algorithm: 'RS256' });
jwt.verify(token, publicKey); // public key bisa di-share

Rekomendasi: Mulai dengan HS256. Migrasi ke RS256 saat kamu membutuhkan multiple service yang perlu verify token secara independen.


Bab 2: Database Schema untuk Auth

Tabel users

CREATE TABLE users (
  id              BIGSERIAL PRIMARY KEY,
  email           VARCHAR(255) NOT NULL UNIQUE,
  password_hash   VARCHAR(255),           -- NULL jika hanya pakai OAuth
  name            VARCHAR(255) NOT NULL,
  role            VARCHAR(50) NOT NULL DEFAULT 'user',
  is_active       BOOLEAN NOT NULL DEFAULT true,
  email_verified  BOOLEAN NOT NULL DEFAULT false,
  email_verified_at TIMESTAMPTZ,
  last_login_at   TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);

Tabel refresh_tokens

Jangan simpan refresh token sebagai plain text. Simpan hash-nya — jika database bocor, attacker tidak langsung punya token yang valid.

CREATE TABLE refresh_tokens (
  id              BIGSERIAL PRIMARY KEY,
  user_id         BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash      VARCHAR(255) NOT NULL UNIQUE, -- SHA-256 hash dari token
  device_name     VARCHAR(255),                 -- "Chrome on MacOS"
  device_ip       INET,
  user_agent      TEXT,
  expires_at      TIMESTAMPTZ NOT NULL,
  last_used_at    TIMESTAMPTZ,
  revoked_at      TIMESTAMPTZ,                  -- NULL = masih valid
  revoked_reason  VARCHAR(100),                 -- 'logout', 'password_change', 'suspicious'
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_active
  ON refresh_tokens(user_id, expires_at)
  WHERE revoked_at IS NULL;

Tabel oauth_accounts

Satu user bisa punya beberapa akun OAuth (Google + GitHub sekaligus):

CREATE TABLE oauth_accounts (
  id              BIGSERIAL PRIMARY KEY,
  user_id         BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  provider        VARCHAR(50) NOT NULL,   -- 'google', 'github'
  provider_id     VARCHAR(255) NOT NULL,  -- ID dari provider
  access_token    TEXT,                   -- token dari provider (encrypt ini!)
  refresh_token   TEXT,
  token_expires_at TIMESTAMPTZ,
  profile_data    JSONB,                  -- raw data dari provider
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
 
  UNIQUE(provider, provider_id)           -- satu provider ID hanya untuk satu user
);
 
CREATE INDEX idx_oauth_accounts_user ON oauth_accounts(user_id);
CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider, provider_id);

Tabel email_verifications

CREATE TABLE email_verifications (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash  VARCHAR(255) NOT NULL UNIQUE,
  expires_at  TIMESTAMPTZ NOT NULL,
  used_at     TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Bab 3: Register & Login

Setup Dependencies

npm install bcrypt jsonwebtoken crypto-js
npm install --save-dev @types/bcrypt @types/jsonwebtoken

Helper: Token Generation

// src/utils/token.js
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { config } from '../config/index.js';
 
// Generate access token — short-lived, stateless
export function generateAccessToken(user) {
  return jwt.sign(
    {
      sub: user.id.toString(),
      email: user.email,
      role: user.role,
    },
    config.jwt.accessSecret,
    {
      expiresIn: config.jwt.accessExpiresIn, // '15m'
      algorithm: 'HS256',
      issuer: 'backendlabs-api',
    }
  );
}
 
// Generate refresh token — long-lived, disimpan di database
export function generateRefreshToken() {
  // Cryptographically secure random string
  const token = crypto.randomBytes(40).toString('hex');
  // Hash untuk disimpan di DB — plain token dikirim ke client
  const hash = crypto.createHash('sha256').update(token).digest('hex');
  return { token, hash };
}
 
// Verify access token
export function verifyAccessToken(token) {
  return jwt.verify(token, config.jwt.accessSecret, {
    algorithms: ['HS256'],  // SELALU explicit — mencegah alg:none attack
    issuer: 'backendlabs-api',
  });
}

Register

// src/services/auth.service.js
import bcrypt from 'bcrypt';
import pool from '../db/pool.js';
import { generateAccessToken, generateRefreshToken } from '../utils/token.js';
 
const BCRYPT_ROUNDS = 12; // 10-12 adalah sweet spot security vs performance
 
export const AuthService = {
  async register({ name, email, password }) {
    // Cek apakah email sudah dipakai
    const existing = await pool.query(
      'SELECT id FROM users WHERE email = $1',
      [email]
    );
    if (existing.rows.length > 0) {
      throw Object.assign(new Error('Email sudah terdaftar'), { statusCode: 409 });
    }
 
    // Hash password — jangan pernah simpan plain text
    const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
 
    // Buat user
    const result = await pool.query(
      `INSERT INTO users (name, email, password_hash)
       VALUES ($1, $2, $3)
       RETURNING id, name, email, role, created_at`,
      [name, email, passwordHash]
    );
    const user = result.rows[0];
 
    // Issue tokens
    const accessToken = generateAccessToken(user);
    const { token: refreshToken, hash: refreshTokenHash } = generateRefreshToken();
 
    // Simpan refresh token ke DB
    await pool.query(
      `INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
       VALUES ($1, $2, NOW() + INTERVAL '7 days')`,
      [user.id, refreshTokenHash]
    );
 
    return { user, accessToken, refreshToken };
  },
 
  async login({ email, password, deviceName, deviceIp, userAgent }) {
    // Ambil user — termasuk password_hash
    const result = await pool.query(
      'SELECT id, name, email, role, password_hash, is_active FROM users WHERE email = $1',
      [email]
    );
 
    const user = result.rows[0];
 
    // Pesan error yang sama untuk "user tidak ada" dan "password salah"
    // Jangan bocorkan informasi apakah email terdaftar atau tidak
    const genericError = Object.assign(
      new Error('Email atau password tidak valid'),
      { statusCode: 401 }
    );
 
    if (!user || !user.password_hash) throw genericError;
    if (!user.is_active) {
      throw Object.assign(new Error('Akun ini telah dinonaktifkan'), { statusCode: 403 });
    }
 
    const passwordMatch = await bcrypt.compare(password, user.password_hash);
    if (!passwordMatch) throw genericError;
 
    // Update last login
    await pool.query(
      'UPDATE users SET last_login_at = NOW() WHERE id = $1',
      [user.id]
    );
 
    // Issue tokens
    const accessToken = generateAccessToken(user);
    const { token: refreshToken, hash: refreshTokenHash } = generateRefreshToken();
 
    await pool.query(
      `INSERT INTO refresh_tokens
        (user_id, token_hash, device_name, device_ip, user_agent, expires_at)
       VALUES ($1, $2, $3, $4, $5, NOW() + INTERVAL '7 days')`,
      [user.id, refreshTokenHash, deviceName, deviceIp, userAgent]
    );
 
    // Jangan kembalikan password_hash!
    const { password_hash, ...safeUser } = user;
    return { user: safeUser, accessToken, refreshToken };
  },
};

Controller & Route

// src/controllers/auth.controller.js
import { AuthService } from '../services/auth.service.js';
 
export async function register(req, res, next) {
  try {
    const { name, email, password } = req.body;
    const result = await AuthService.register({ name, email, password });
 
    // Opsi 1: kirim token di body (lebih mudah untuk mobile/SPA)
    res.status(201).json({
      success: true,
      data: {
        user: result.user,
        accessToken: result.accessToken,
        refreshToken: result.refreshToken,
      },
    });
 
    // Opsi 2: kirim refresh token via httpOnly cookie (lebih aman dari XSS)
    // res.cookie('refresh_token', result.refreshToken, {
    //   httpOnly: true,
    //   secure: config.isProd,
    //   sameSite: 'strict',
    //   maxAge: 7 * 24 * 60 * 60 * 1000,
    // });
    // res.status(201).json({ success: true, data: { user: result.user, accessToken: result.accessToken } });
  } catch (error) {
    next(error);
  }
}
 
export async function login(req, res, next) {
  try {
    const { email, password } = req.body;
    const deviceName = req.headers['x-device-name'] || 'Unknown device';
    const deviceIp = req.ip;
    const userAgent = req.headers['user-agent'];
 
    const result = await AuthService.login({ email, password, deviceName, deviceIp, userAgent });
 
    res.json({
      success: true,
      data: {
        user: result.user,
        accessToken: result.accessToken,
        refreshToken: result.refreshToken,
      },
    });
  } catch (error) {
    next(error);
  }
}

Bab 4: Access Token & Refresh Token Pattern

Kenapa Dua Token?

Access token — stateless, cepat diverifikasi:

  • Tidak perlu database lookup setiap request
  • Tapi tidak bisa dicabut sebelum expired → harus short-lived (15 menit)

Refresh token — stateful, disimpan di database:

  • Bisa dicabut kapanpun (logout, password change, suspicious activity)
  • Long-lived (7 hari) karena hanya dipakai untuk minta access token baru
  • Tidak dikirim ke setiap API endpoint — hanya ke /auth/refresh
[Client]                           [Server]
   |                                  |
   |-- POST /auth/login ------------->|
   |<-- accessToken (15m) ------------|
   |    refreshToken (7d)             |
   |                                  |
   |-- GET /api/v1/products --------->| ← pakai accessToken
   |<-- 200 OK ----------------------|
   |                                  |
   |   (15 menit berlalu)             |
   |                                  |
   |-- GET /api/v1/products --------->|
   |<-- 401 Unauthorized ------------|
   |                                  |
   |-- POST /auth/refresh ----------->| ← pakai refreshToken
   |<-- accessToken baru (15m) ------|
   |    refreshToken baru (7d)        | ← token lama direvoke!
   |                                  |
   |-- GET /api/v1/products --------->| ← pakai accessToken baru
   |<-- 200 OK ----------------------|

Refresh Token Rotation

Setiap kali refresh token digunakan, terbitkan yang baru dan revoke yang lama. Ini mencegah refresh token yang dicuri digunakan berkali-kali:

// src/services/auth.service.js (lanjutan)
export const AuthService = {
  // ...
 
  async refreshTokens(oldRefreshToken) {
    const crypto = await import('crypto');
    const tokenHash = crypto
      .createHash('sha256')
      .update(oldRefreshToken)
      .digest('hex');
 
    // Cari refresh token di database
    const result = await pool.query(
      `SELECT rt.*, u.id as user_id, u.email, u.role, u.is_active
       FROM refresh_tokens rt
       JOIN users u ON rt.user_id = u.id
       WHERE rt.token_hash = $1`,
      [tokenHash]
    );
 
    const tokenRecord = result.rows[0];
 
    // Token tidak ditemukan
    if (!tokenRecord) {
      throw Object.assign(new Error('Refresh token tidak valid'), { statusCode: 401 });
    }
 
    // Token sudah direvoke — kemungkinan token theft!
    if (tokenRecord.revoked_at) {
      // Revoke SEMUA token milik user ini (token family invalidation)
      await pool.query(
        `UPDATE refresh_tokens SET revoked_at = NOW(), revoked_reason = 'suspicious'
         WHERE user_id = $1 AND revoked_at IS NULL`,
        [tokenRecord.user_id]
      );
      throw Object.assign(
        new Error('Token tidak valid. Semua sesi telah dihentikan karena aktivitas mencurigakan.'),
        { statusCode: 401 }
      );
    }
 
    // Token sudah expired
    if (new Date(tokenRecord.expires_at) < new Date()) {
      throw Object.assign(new Error('Refresh token sudah kadaluarsa'), { statusCode: 401 });
    }
 
    if (!tokenRecord.is_active) {
      throw Object.assign(new Error('Akun telah dinonaktifkan'), { statusCode: 403 });
    }
 
    // Revoke token lama
    await pool.query(
      `UPDATE refresh_tokens SET revoked_at = NOW(), revoked_reason = 'rotated'
       WHERE id = $1`,
      [tokenRecord.id]
    );
 
    // Issue token baru
    const user = {
      id: tokenRecord.user_id,
      email: tokenRecord.email,
      role: tokenRecord.role,
    };
 
    const accessToken = generateAccessToken(user);
    const { token: newRefreshToken, hash: newRefreshTokenHash } = generateRefreshToken();
 
    await pool.query(
      `INSERT INTO refresh_tokens
        (user_id, token_hash, device_name, device_ip, user_agent, expires_at)
       SELECT $1, $2, device_name, device_ip, user_agent, NOW() + INTERVAL '7 days'
       FROM refresh_tokens WHERE id = $3`,
      [tokenRecord.user_id, newRefreshTokenHash, tokenRecord.id]
    );
 
    return { accessToken, refreshToken: newRefreshToken };
  },
};
// src/controllers/auth.controller.js (lanjutan)
export async function refresh(req, res, next) {
  try {
    const { refreshToken } = req.body;
    if (!refreshToken) {
      return res.status(400).json({ success: false, message: 'Refresh token diperlukan' });
    }
 
    const tokens = await AuthService.refreshTokens(refreshToken);
    res.json({ success: true, data: tokens });
  } catch (error) {
    next(error);
  }
}

Bab 5: Token Revocation & Logout

Logout Single Device

Revoke hanya refresh token untuk sesi ini:

// src/services/auth.service.js (lanjutan)
export const AuthService = {
  // ...
 
  async logout(refreshToken) {
    const crypto = await import('crypto');
    const tokenHash = crypto
      .createHash('sha256')
      .update(refreshToken)
      .digest('hex');
 
    await pool.query(
      `UPDATE refresh_tokens
       SET revoked_at = NOW(), revoked_reason = 'logout'
       WHERE token_hash = $1 AND revoked_at IS NULL`,
      [tokenHash]
    );
    // Tidak perlu error jika token tidak ditemukan — anggap sudah logout
  },
 
  async logoutAllDevices(userId) {
    await pool.query(
      `UPDATE refresh_tokens
       SET revoked_at = NOW(), revoked_reason = 'logout_all'
       WHERE user_id = $1 AND revoked_at IS NULL`,
      [userId]
    );
  },
};

Invalidasi Access Token yang Masih Valid

Access token tidak bisa dicabut secara langsung karena stateless. Tapi ada situasi di mana kamu perlu "paksa logout" user sebelum access token-nya expired — misal saat password diganti atau akun di-suspend.

Solusinya: token blacklist di Redis dengan TTL = sisa waktu token expired.

npm install ioredis
// src/utils/tokenBlacklist.js
import Redis from 'ioredis';
import { config } from '../config/index.js';
 
const redis = new Redis(config.redis.url);
 
export async function blacklistToken(jti, expiresAt) {
  const ttlSeconds = Math.max(0, Math.floor((expiresAt * 1000 - Date.now()) / 1000));
  if (ttlSeconds > 0) {
    await redis.setex(`blacklist:${jti}`, ttlSeconds, '1');
  }
}
 
export async function isTokenBlacklisted(jti) {
  const result = await redis.get(`blacklist:${jti}`);
  return result !== null;
}

Update middleware auth untuk cek blacklist:

// src/middleware/auth.middleware.js
import { verifyAccessToken } from '../utils/token.js';
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js';
 
export async function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ success: false, message: 'Token autentikasi diperlukan' });
  }
 
  const token = authHeader.split(' ')[1];
 
  try {
    const decoded = verifyAccessToken(token);
 
    // Cek blacklist (hanya jika token punya jti)
    if (decoded.jti && await isTokenBlacklisted(decoded.jti)) {
      return res.status(401).json({ success: false, message: 'Token tidak lagi valid' });
    }
 
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ success: false, message: 'Token tidak valid atau kadaluarsa' });
  }
}

Force Logout (Password Change, Akun Suspend)

// src/services/auth.service.js (lanjutan)
import { blacklistToken } from '../utils/tokenBlacklist.js';
 
export const AuthService = {
  // ...
 
  async changePassword(userId, currentPassword, newPassword, currentAccessToken) {
    const user = await pool.query(
      'SELECT password_hash FROM users WHERE id = $1',
      [userId]
    );
 
    const match = await bcrypt.compare(currentPassword, user.rows[0].password_hash);
    if (!match) {
      throw Object.assign(new Error('Password saat ini tidak benar'), { statusCode: 400 });
    }
 
    const newHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
    await pool.query(
      'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
      [newHash, userId]
    );
 
    // Revoke semua refresh token (logout semua device)
    await pool.query(
      `UPDATE refresh_tokens SET revoked_at = NOW(), revoked_reason = 'password_change'
       WHERE user_id = $1 AND revoked_at IS NULL`,
      [userId]
    );
 
    // Blacklist access token yang sedang dipakai
    if (currentAccessToken) {
      const decoded = verifyAccessToken(currentAccessToken);
      if (decoded.jti) {
        await blacklistToken(decoded.jti, decoded.exp);
      }
    }
  },
};

Bab 6: Keamanan JWT

Algorithm Confusion Attack (alg: none)

Ini adalah salah satu attack paling berbahaya pada JWT. Jika library tidak memvalidasi algorithm secara eksplisit, attacker bisa membuat token dengan "alg": "none" — tidak ada signature yang diverifikasi.

// ✗ SALAH — tidak specify algorithm
jwt.verify(token, secret);
 
// ✓ BENAR — selalu explicit
jwt.verify(token, secret, { algorithms: ['HS256'] });

Library jsonwebtoken modern sudah melindungi ini secara default, tapi tetap jadikan kebiasaan untuk selalu specify.

Kekuatan Secret Key

// ✗ SALAH — terlalu pendek dan predictable
JWT_SECRET=secret
JWT_SECRET=myapp123
 
// ✓ BENAR — generate dengan crypto
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
// Output: 8f4e2a9d1b6c7e3f0a5d8c9b2e7f4a1d...

Minimum 32 bytes (64 karakter hex). Simpan di .env, jangan pernah commit ke git.

Token Storage: Threat Model

StorageAman dari XSSAman dari CSRFCocok untuk
localStorage✗ (bisa dicuri JS)Bukan pilihan untuk token auth
sessionStorageBukan pilihan untuk token auth
httpOnly cookie✗ (perlu CSRF token)Web app yang punya backend server
Memory (JS variable)SPA — hilang saat refresh page
Authorization header✓ jika tidak disimpanMobile app, API client

Rekomendasi untuk web app:

  • Access token di memory (JavaScript variable) — hilang saat refresh, tapi aman dari XSS
  • Refresh token di httpOnly cookie — tidak bisa diakses JavaScript

Rekomendasi untuk mobile app:

  • Gunakan secure storage platform (iOS Keychain, Android Keystore)

Expiry Strategy

// Access token: pendek — 15 menit adalah sweet spot
// Cukup cepat untuk operasional, cukup singkat untuk limitasi damage jika dicuri
accessExpiresIn: '15m'
 
// Refresh token: 7 hari untuk consumer app, 30 hari untuk mobile yang jarang dipakai
// 1 hari untuk aplikasi keuangan/enterprise
refreshExpiresIn: '7d'
 
// Jangan buat access token yang terlalu panjang:
// expiresIn: '24h' → window 24 jam jika token dicuri
// expiresIn: '30d' → sama saja dengan tidak punya expiry

Jangan Taruh Data Sensitif di Payload

// ✗ JANGAN — payload bisa dibaca siapapun
jwt.sign({
  userId: 123,
  password: 'user_password',     // JANGAN
  creditCard: '4111111111111111', // JANGAN
  internalApiKey: 'sk-...',       // JANGAN
});
 
// ✓ Hanya data yang aman diketahui publik
jwt.sign({
  sub: '123',
  email: 'user@email.com',
  role: 'admin',
});

Bab 7: Role-Based Access Control (RBAC)

Middleware authorize

// src/middleware/auth.middleware.js (lanjutan)
 
// Gunakan setelah authenticate
export function authorize(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ success: false, message: 'Autentikasi diperlukan' });
    }
 
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        message: 'Kamu tidak memiliki akses untuk melakukan tindakan ini',
      });
    }
 
    next();
  };
}

Penggunaan di routes:

import { authenticate, authorize } from '../middleware/auth.middleware.js';
 
// Hanya admin
router.delete('/:id', authenticate, authorize('admin'), deleteProduct);
 
// Admin atau manager
router.put('/:id', authenticate, authorize('admin', 'manager'), updateProduct);
 
// Semua user yang sudah login
router.get('/me', authenticate, getProfile);

Permission-Based Access Control

Untuk kontrol yang lebih granular dari sekadar role:

-- Schema untuk permissions
CREATE TABLE permissions (
  id    SERIAL PRIMARY KEY,
  name  VARCHAR(100) NOT NULL UNIQUE  -- 'products:read', 'products:delete', 'users:manage'
);
 
CREATE TABLE role_permissions (
  role        VARCHAR(50) NOT NULL,
  permission  VARCHAR(100) NOT NULL REFERENCES permissions(name),
  PRIMARY KEY (role, permission)
);
 
-- Setup default permissions
INSERT INTO role_permissions VALUES
  ('admin', 'products:read'),
  ('admin', 'products:write'),
  ('admin', 'products:delete'),
  ('admin', 'users:manage'),
  ('manager', 'products:read'),
  ('manager', 'products:write'),
  ('user', 'products:read');
// src/middleware/auth.middleware.js
export function can(permission) {
  return async (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ success: false, message: 'Autentikasi diperlukan' });
    }
 
    const result = await pool.query(
      'SELECT 1 FROM role_permissions WHERE role = $1 AND permission = $2',
      [req.user.role, permission]
    );
 
    if (result.rows.length === 0) {
      return res.status(403).json({
        success: false,
        message: `Akses ditolak: membutuhkan permission '${permission}'`,
      });
    }
 
    next();
  };
}
// Routes dengan permission granular
router.delete('/:id', authenticate, can('products:delete'), deleteProduct);
router.get('/users', authenticate, can('users:manage'), listUsers);

Trade-off: Mengecek permission ke database setiap request menambah latency. Alternatif: encode permissions di JWT payload saat login (cepat tapi tidak bisa diubah real-time tanpa force-refresh token).


Bab 8: OAuth2: Login dengan Google & GitHub

Konsep OAuth2 Authorization Code Flow

[User]          [Client App]          [Google]           [Your API]
  |                  |                    |                   |
  |-- Klik Login --->|                    |                   |
  |                  |-- Redirect ------->|                   |
  |                  |   (client_id,      |                   |
  |                  |    redirect_uri,   |                   |
  |                  |    scope, state)   |                   |
  |                  |                    |                   |
  |<---------- Login Google + Consent ----|                   |
  |                  |                    |                   |
  |                  |<-- Authorization --|                   |
  |                  |    Code            |                   |
  |                  |                    |                   |
  |                  |-- Exchange Code ------------------>   |
  |                  |   (code +                             |
  |                  |    client_secret)                     |
  |                  |<-- accessToken + user info ---------|
  |<-- JWT token ----|                    |                   |

Setup Google OAuth

  1. Buka console.cloud.google.com
  2. Buat project → APIs & Services → Credentials → Create OAuth Client ID
  3. Application type: Web application
  4. Authorized redirect URIs: http://localhost:3000/api/v1/auth/google/callback
  5. Simpan GOOGLE_CLIENT_ID dan GOOGLE_CLIENT_SECRET ke .env
npm install axios
// src/services/oauth.service.js
import axios from 'axios';
import pool from '../db/pool.js';
import { generateAccessToken, generateRefreshToken } from '../utils/token.js';
import { config } from '../config/index.js';
 
export const OAuthService = {
 
  // Step 1: Generate authorization URL
  getGoogleAuthUrl(state) {
    const params = new URLSearchParams({
      client_id: config.google.clientId,
      redirect_uri: config.google.redirectUri,
      response_type: 'code',
      scope: 'openid email profile',
      state,           // random string untuk mencegah CSRF
      access_type: 'offline',  // untuk dapat refresh token dari Google
    });
    return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
  },
 
  // Step 2: Exchange code untuk token
  async handleGoogleCallback(code, deviceName, deviceIp) {
    // Exchange authorization code untuk access token
    const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', {
      code,
      client_id: config.google.clientId,
      client_secret: config.google.clientSecret,
      redirect_uri: config.google.redirectUri,
      grant_type: 'authorization_code',
    });
 
    const { access_token } = tokenResponse.data;
 
    // Ambil data user dari Google
    const userInfo = await axios.get('https://www.googleapis.com/oauth2/v3/userinfo', {
      headers: { Authorization: `Bearer ${access_token}` },
    });
 
    const { sub: googleId, email, name, picture } = userInfo.data;
 
    return this._findOrCreateOAuthUser({
      provider: 'google',
      providerId: googleId,
      email,
      name,
      profileData: userInfo.data,
      deviceName,
      deviceIp,
    });
  },
 
  async _findOrCreateOAuthUser({ provider, providerId, email, name, profileData, deviceName, deviceIp }) {
    const client = await pool.connect();
 
    try {
      await client.query('BEGIN');
 
      // Cek apakah oauth account sudah ada
      let oauthAccount = await client.query(
        'SELECT oa.*, u.id as user_id FROM oauth_accounts oa JOIN users u ON oa.user_id = u.id WHERE oa.provider = $1 AND oa.provider_id = $2',
        [provider, providerId]
      );
 
      let userId;
 
      if (oauthAccount.rows.length > 0) {
        // User sudah pernah login dengan provider ini
        userId = oauthAccount.rows[0].user_id;
        await client.query(
          'UPDATE oauth_accounts SET profile_data = $1, updated_at = NOW() WHERE provider = $2 AND provider_id = $3',
          [profileData, provider, providerId]
        );
      } else {
        // Cek apakah email sudah ada (link ke akun existing)
        let existingUser = await client.query(
          'SELECT id FROM users WHERE email = $1',
          [email]
        );
 
        if (existingUser.rows.length > 0) {
          userId = existingUser.rows[0].id;
        } else {
          // Buat user baru
          const newUser = await client.query(
            `INSERT INTO users (name, email, email_verified, email_verified_at)
             VALUES ($1, $2, true, NOW())
             RETURNING id`,
            [name, email]
          );
          userId = newUser.rows[0].id;
        }
 
        // Hubungkan OAuth account ke user
        await client.query(
          `INSERT INTO oauth_accounts (user_id, provider, provider_id, profile_data)
           VALUES ($1, $2, $3, $4)`,
          [userId, provider, providerId, profileData]
        );
      }
 
      await client.query('COMMIT');
 
      // Ambil data user untuk token
      const user = await pool.query(
        'SELECT id, name, email, role FROM users WHERE id = $1',
        [userId]
      );
 
      const accessToken = generateAccessToken(user.rows[0]);
      const { token: refreshToken, hash: refreshTokenHash } = generateRefreshToken();
 
      await pool.query(
        `INSERT INTO refresh_tokens (user_id, token_hash, device_name, device_ip, expires_at)
         VALUES ($1, $2, $3, $4, NOW() + INTERVAL '7 days')`,
        [userId, refreshTokenHash, deviceName, deviceIp]
      );
 
      return { user: user.rows[0], accessToken, refreshToken };
 
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      client.release();
    }
  },
};

Routes OAuth

// src/routes/auth.routes.js
import crypto from 'crypto';
import { OAuthService } from '../services/oauth.service.js';
 
// Step 1: Redirect ke Google
router.get('/google', (req, res) => {
  const state = crypto.randomBytes(16).toString('hex');
  // Simpan state di session/cookie untuk verifikasi callback
  res.cookie('oauth_state', state, { httpOnly: true, maxAge: 10 * 60 * 1000 });
  res.redirect(OAuthService.getGoogleAuthUrl(state));
});
 
// Step 2: Handle callback dari Google
router.get('/google/callback', async (req, res, next) => {
  try {
    const { code, state } = req.query;
    const savedState = req.cookies.oauth_state;
 
    // Verifikasi state untuk mencegah CSRF
    if (!state || state !== savedState) {
      return res.status(400).json({ success: false, message: 'State tidak valid' });
    }
 
    res.clearCookie('oauth_state');
 
    const result = await OAuthService.handleGoogleCallback(
      code,
      req.headers['x-device-name'],
      req.ip
    );
 
    // Redirect ke frontend dengan token
    // Atau return JSON jika mobile app
    res.json({ success: true, data: result });
  } catch (error) {
    next(error);
  }
});

GitHub OAuth

Polanya sama dengan Google — hanya URL dan endpoint yang berbeda:

getGitHubAuthUrl(state) {
  const params = new URLSearchParams({
    client_id: config.github.clientId,
    redirect_uri: config.github.redirectUri,
    scope: 'user:email',
    state,
  });
  return `https://github.com/login/oauth/authorize?${params}`;
},
 
async handleGitHubCallback(code, deviceName, deviceIp) {
  // Exchange code
  const tokenResponse = await axios.post(
    'https://github.com/login/oauth/access_token',
    {
      client_id: config.github.clientId,
      client_secret: config.github.clientSecret,
      code,
    },
    { headers: { Accept: 'application/json' } }
  );
 
  const { access_token } = tokenResponse.data;
 
  // Ambil data user
  const [userInfo, emailsInfo] = await Promise.all([
    axios.get('https://api.github.com/user', {
      headers: { Authorization: `Bearer ${access_token}` },
    }),
    axios.get('https://api.github.com/user/emails', {
      headers: { Authorization: `Bearer ${access_token}` },
    }),
  ]);
 
  // Ambil primary email (GitHub bisa sembunyikan email di profile)
  const primaryEmail = emailsInfo.data.find(e => e.primary)?.email;
 
  return this._findOrCreateOAuthUser({
    provider: 'github',
    providerId: userInfo.data.id.toString(),
    email: primaryEmail || userInfo.data.email,
    name: userInfo.data.name || userInfo.data.login,
    profileData: userInfo.data,
    deviceName,
    deviceIp,
  });
},

Bab 9: Multi-Device Session Management

Lihat Semua Sesi Aktif

// src/services/auth.service.js (lanjutan)
export const AuthService = {
  // ...
 
  async getActiveSessions(userId) {
    const result = await pool.query(
      `SELECT
        id,
        device_name,
        device_ip,
        user_agent,
        last_used_at,
        created_at,
        expires_at
       FROM refresh_tokens
       WHERE user_id = $1
         AND revoked_at IS NULL
         AND expires_at > NOW()
       ORDER BY last_used_at DESC NULLS LAST`,
      [userId]
    );
    return result.rows;
  },
 
  async revokeSession(userId, sessionId) {
    const result = await pool.query(
      `UPDATE refresh_tokens
       SET revoked_at = NOW(), revoked_reason = 'manual_revoke'
       WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
       RETURNING id`,
      [sessionId, userId]
    );
 
    if (result.rows.length === 0) {
      throw Object.assign(new Error('Sesi tidak ditemukan'), { statusCode: 404 });
    }
  },
};
// src/controllers/auth.controller.js (lanjutan)
 
export async function getSessions(req, res, next) {
  try {
    const sessions = await AuthService.getActiveSessions(req.user.sub);
    res.json({ success: true, data: sessions });
  } catch (error) {
    next(error);
  }
}
 
export async function revokeSession(req, res, next) {
  try {
    await AuthService.revokeSession(req.user.sub, req.params.sessionId);
    res.json({ success: true, message: 'Sesi berhasil dihentikan' });
  } catch (error) {
    next(error);
  }
}

Endpoint Summary

// src/routes/auth.routes.js
import { authenticate } from '../middleware/auth.middleware.js';
import * as AuthController from '../controllers/auth.controller.js';
 
// Public
router.post('/register', validate(registerSchema), AuthController.register);
router.post('/login', authLimiter, validate(loginSchema), AuthController.login);
router.post('/refresh', AuthController.refresh);
router.get('/google', AuthController.googleRedirect);
router.get('/google/callback', AuthController.googleCallback);
router.get('/github', AuthController.githubRedirect);
router.get('/github/callback', AuthController.githubCallback);
 
// Protected
router.post('/logout', authenticate, AuthController.logout);
router.post('/logout-all', authenticate, AuthController.logoutAll);
router.put('/password', authenticate, AuthController.changePassword);
router.get('/sessions', authenticate, AuthController.getSessions);
router.delete('/sessions/:sessionId', authenticate, AuthController.revokeSession);

Penutup

Sistem auth yang kamu bangun di e-book ini mencakup semua yang dibutuhkan untuk production: token yang benar-benar bisa dicabut, deteksi token theft, OAuth2 provider, dan manajemen sesi multi-device.

Production Checklist

  • JWT secret minimal 64 karakter, di-generate dengan crypto.randomBytes
  • algorithms: ['HS256'] selalu di-specify di jwt.verify
  • Access token expiry maksimal 15 menit
  • Refresh token disimpan sebagai hash, bukan plain text
  • Refresh token rotation aktif — setiap refresh terbitkan token baru
  • Token family invalidation untuk deteksi token theft
  • Redis blacklist untuk force-logout access token
  • Rate limiting di /auth/login (max 10 attempt per jam)
  • Password minimal 8 karakter, hashed dengan bcrypt cost 12
  • Pesan error login tidak membedakan "email tidak ada" vs "password salah"
  • OAuth state parameter diverifikasi untuk mencegah CSRF
  • httpOnly + Secure + SameSite=Strict untuk cookie yang menyimpan token

Langkah selanjutnya:

  • Tambahkan email verification flow (e-book ini sudah siapkan tabelnya)
  • Implementasikan 2FA dengan TOTP (Google Authenticator)
  • Eksplorasi Passport.js untuk abstraksi OAuth yang lebih mudah di-maintain

Lanjutkan ke

Arsitektur AI Agent dari Sisi BackendSegera