"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
- Fondasi: Bagaimana JWT Bekerja
- Database Schema untuk Auth
- Register & Login
- Access Token & Refresh Token Pattern
- Token Revocation & Logout
- Keamanan JWT
- Role-Based Access Control (RBAC)
- OAuth2: Login dengan Google & GitHub
- 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
| Claim | Nama | Fungsi |
|---|---|---|
sub | Subject | ID unik user/entity yang token ini mewakili |
iss | Issuer | Siapa yang menerbitkan token (nama service/domain) |
exp | Expiration | Unix timestamp kapan token expired |
iat | Issued At | Unix timestamp kapan token dibuat |
jti | JWT ID | ID 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-shareRekomendasi: 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/jsonwebtokenHelper: 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
| Storage | Aman dari XSS | Aman dari CSRF | Cocok untuk |
|---|---|---|---|
localStorage | ✗ (bisa dicuri JS) | ✓ | Bukan pilihan untuk token auth |
sessionStorage | ✗ | ✓ | Bukan 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 disimpan | ✓ | Mobile app, API client |
Rekomendasi untuk web app:
- Access token di memory (JavaScript variable) — hilang saat refresh, tapi aman dari XSS
- Refresh token di
httpOnlycookie — 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 expiryJangan 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
- Buka console.cloud.google.com
- Buat project → APIs & Services → Credentials → Create OAuth Client ID
- Application type: Web application
- Authorized redirect URIs:
http://localhost:3000/api/v1/auth/google/callback - Simpan
GOOGLE_CLIENT_IDdanGOOGLE_CLIENT_SECRETke.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 dijwt.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=Strictuntuk 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