Panduan Autentikasi JWT dan Role-Based Access di Node.js
Admin
Penulis Artikel
Panduan Autentikasi JWT dan Role-Based Access di Node.js
Sampai artikel sebelumnya, Todo List API kita sudah rapi, aman dari error tak terduga, dan mengembalikan status code yang benar. Tapi ada satu lubang besar: siapa saja bisa mengakses semua endpoint. Tidak ada yang tahu siapa yang membuat todo, dan siapa pun bisa mengubah atau menghapus todo milik orang lain.
Artikel ini akan membahas konsep autentikasi (authentication) dan otorisasi (authorization), cara mengamankan password dengan bcrypt, cara kerja JWT (JSON Web Token), dan cara membatasi akses berdasarkan role menggunakan Role-Based Access Control (RBAC) — semuanya dipraktikkan langsung di Todo List API.
Artikel ini melanjutkan project Todo List API dari artikel pertama, kedua, dan ketiga. Setelah ini, kita akan lanjut ke Cara Melindungi API dengan API Key.
1. Authentication vs Authorization
Analogi: bayangkan gerbang sekolah dengan dua pos berbeda.
Pos 1 — Authentication (autentikasi): satpam mengecek kartu pelajarmu untuk memastikan kamu benar-benar terdaftar sebagai siswa di sekolah itu. Pertanyaannya: “Kamu ini siapa?”
Pos 2 — Authorization (otorisasi): setelah dipastikan kamu siswa terdaftar, satpam mengecek apakah kamu boleh masuk ke ruang guru atau tidak. Pertanyaannya: “Kamu boleh akses ini atau tidak?”
Di backend, autentikasi biasanya dilakukan lewat proses login (email + password) yang menghasilkan token, sedangkan otorisasi dilakukan dengan mengecek role/hak akses user tersebut (misalnya admin vs user biasa) sebelum mengizinkan akses ke suatu endpoint.
2. Setup: Install Dependency
npm install bcryptjs jsonwebtoken
bcryptjs— untuk mengenkripsi (hash) password.jsonwebtoken— untuk membuat dan memverifikasi JWT.
Tambahkan juga secret key JWT ke file .env:
.env (tambahkan baris berikut)
JWT_SECRET=rahasia-jwt-yang-sangat-panjang-dan-sulit-ditebak
JWT_EXPIRES_IN=1d
Analogi: JWT_SECRET ini seperti stempel resmi kepala sekolah. Hanya sekolah yang punya stempel asli yang bisa menerbitkan surat keterangan yang sah. Kalau stempel ini bocor ke orang lain, mereka bisa memalsukan surat/token seolah-olah dari sekolahmu. Makanya JWT_SECRET wajib disimpan di .env, tidak boleh hardcode atau ikut ter-commit ke Git.
Jangan lupa update juga .env.example agar temanmu tahu variabel ini dibutuhkan (tanpa nilai aslinya):
PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/todo_api_db
NODE_ENV=development
JWT_SECRET=isi-dengan-secret-kamu-sendiri
JWT_EXPIRES_IN=1d
3. Membuat Model User
src/models/user.model.js
const mongoose = require("mongoose");
const bcrypt = require("bcryptjs");
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
trim: true,
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: true,
minlength: 6,
select: false, // password tidak akan ikut dikirim di query find() secara default
},
role: {
type: String,
enum: ["user", "admin"],
default: "user",
},
},
{
timestamps: true,
}
);
// Hash password otomatis sebelum data disimpan ke database
userSchema.pre("save", async function () {
if (!this.isModified("password")) return;
this.password = await bcrypt.hash(this.password, 10);
});
// Method untuk membandingkan password saat login
userSchema.methods.comparePassword = async function (candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model("User", userSchema);
Analogi soal hashing: menyimpan password asli di database itu seperti menulis jawaban ujian di papan pengumuman — kalau papan itu bocor, semua orang tahu jawabannya. Hashing dengan bcrypt ibarat mengubah jawaban itu jadi kode acak yang tidak bisa dibalikkan ke bentuk aslinya. Saat siswa login, sistem tidak “membuka” kode itu kembali jadi password asli — sistem hanya mencocokkan apakah password yang diketik, kalau di-acak dengan cara yang sama, hasilnya sama dengan kode yang tersimpan.
4. Membuat Service Autentikasi
mkdir -p src/services
src/services/auth.service.js
const jwt = require("jsonwebtoken");
const User = require("../models/user.model");
const AppError = require("../utils/AppError");
function generateToken(userId) {
return jwt.sign({ id: userId }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN,
});
}
async function register({ name, email, password }) {
const existingUser = await User.findOne({ email });
if (existingUser) {
throw new AppError("Email is already registered", 409);
}
const user = await User.create({ name, email, password });
const token = generateToken(user._id);
return { user, token };
}
async function login({ email, password }) {
// .select("+password") diperlukan karena di schema, password diset select: false
const user = await User.findOne({ email }).select("+password");
if (!user) {
throw new AppError("Invalid email or password", 401);
}
const isPasswordCorrect = await user.comparePassword(password);
if (!isPasswordCorrect) {
throw new AppError("Invalid email or password", 401);
}
const token = generateToken(user._id);
return { user, token };
}
module.exports = { register, login };
Catatan keamanan: pesan error saat login sengaja dibuat generik (“Invalid email or password”), bukan “Email tidak ditemukan” atau “Password salah” secara terpisah. Ini untuk mencegah orang lain menebak-nebak email mana saja yang terdaftar di sistem.
5. Membuat Controller dan Route Auth
src/controllers/auth.controller.js
const authService = require("../services/auth.service");
const catchAsync = require("../utils/catchAsync");
const AppError = require("../utils/AppError");
const register = catchAsync(async (req, res, next) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return next(new AppError("Name, email, and password are required", 400));
}
const { user, token } = await authService.register({ name, email, password });
res.status(201).json({
success: true,
message: "User registered successfully",
data: {
user: { id: user._id, name: user.name, email: user.email, role: user.role },
token,
},
});
});
const login = catchAsync(async (req, res, next) => {
const { email, password } = req.body;
if (!email || !password) {
return next(new AppError("Email and password are required", 400));
}
const { user, token } = await authService.login({ email, password });
res.status(200).json({
success: true,
message: "Login successful",
data: {
user: { id: user._id, name: user.name, email: user.email, role: user.role },
token,
},
});
});
module.exports = { register, login };
src/routes/auth.routes.js
const express = require("express");
const router = express.Router();
const authController = require("../controllers/auth.controller");
router.post("/register", authController.register);
router.post("/login", authController.login);
module.exports = router;
6. Membuat Middleware Proteksi (protect)
Analogi: middleware ini seperti satpam di pos gerbang yang mengecek kartu pelajarmu (token JWT) sebelum kamu boleh lanjut ke area sekolah. Kalau kartu tidak ada, palsu, atau sudah kedaluwarsa, kamu tidak diizinkan masuk sama sekali.
mkdir -p src/middlewares
src/middlewares/auth.middleware.js
const jwt = require("jsonwebtoken");
const User = require("../models/user.model");
const AppError = require("../utils/AppError");
const catchAsync = require("../utils/catchAsync");
const protect = catchAsync(async (req, res, next) => {
let token;
// Token dikirim lewat header: Authorization: Bearer
if (req.headers.authorization && req.headers.authorization.startsWith("Bearer")) {
token = req.headers.authorization.split(" ")[1];
}
if (!token) {
return next(new AppError("You are not logged in. Please log in to get access", 401));
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
return next(new AppError("Invalid or expired token", 401));
}
const currentUser = await User.findById(decoded.id);
if (!currentUser) {
return next(new AppError("The user belonging to this token no longer exists", 401));
}
// Simpan data user di req.user, supaya bisa dipakai di controller/middleware berikutnya
req.user = currentUser;
next();
});
module.exports = { protect };
7. Membuat Middleware Otorisasi (restrictTo)
Analogi: kalau protect adalah satpam gerbang utama, restrictTo ini seperti satpam khusus di depan ruang guru — dia tidak peduli kamu siswa terdaftar atau bukan, yang dia cek cuma satu hal: apakah kamu berstatus “guru” atau bukan. Kalau kamu siswa biasa, meskipun kartu pelajarmu asli, kamu tetap tidak boleh masuk ke ruang guru.
src/middlewares/restrictTo.middleware.js
const AppError = require("../utils/AppError");
function restrictTo(...allowedRoles) {
return (req, res, next) => {
if (!allowedRoles.includes(req.user.role)) {
return next(new AppError("You do not have permission to perform this action", 403));
}
next();
};
}
module.exports = restrictTo;
Middleware ini dibuat sebagai function generator supaya fleksibel dipakai di route mana pun dengan role berbeda-beda, misalnya restrictTo("admin") atau restrictTo("admin", "editor").
8. Update Model Todo: Menyimpan Pemilik Data
Supaya kita tahu todo itu milik siapa, tambahkan field owner yang merujuk ke User.
src/models/todo.model.js (update penuh)
const mongoose = require("mongoose");
const todoSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true,
},
description: {
type: String,
default: "",
},
completed: {
type: Boolean,
default: false,
},
owner: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
},
},
{
timestamps: true,
}
);
module.exports = mongoose.model("Todo", todoSchema);
8.1 Update Service Todo
src/services/todo.service.js (update penuh)
const Todo = require("../models/todo.model");
async function createTodo(data) {
const todo = new Todo({
title: data.title,
description: data.description,
owner: data.owner,
});
return await todo.save();
}
async function getAllTodos(ownerId) {
return await Todo.find({ owner: ownerId }).sort({ createdAt: -1 });
}
async function getAllTodosForAdmin() {
return await Todo.find().sort({ createdAt: -1 }).populate("owner", "name email");
}
async function getTodoById(id) {
return await Todo.findById(id);
}
async function updateTodo(id, data) {
return await Todo.findByIdAndUpdate(
id,
{
title: data.title,
description: data.description,
completed: data.completed,
},
{ new: true, runValidators: true }
);
}
async function deleteTodo(id) {
return await Todo.findByIdAndDelete(id);
}
module.exports = {
createTodo,
getAllTodos,
getAllTodosForAdmin,
getTodoById,
updateTodo,
deleteTodo,
};
8.2 Update Controller Todo: Cek Kepemilikan Data
Analogi: bagian ini seperti aturan “loker sekolah” — setiap siswa boleh membuka lokernya sendiri, tapi tidak boleh membuka loker siswa lain, kecuali dia adalah admin/kepala sekolah yang punya kunci cadangan untuk semua loker.
src/controllers/todo.controller.js (update penuh)
const todoService = require("../services/todo.service");
const AppError = require("../utils/AppError");
const catchAsync = require("../utils/catchAsync");
const createTodo = catchAsync(async (req, res, next) => {
const { title, description } = req.body;
if (!title) {
return next(new AppError("Title is required", 400));
}
const todo = await todoService.createTodo({
title,
description,
owner: req.user._id,
});
res.status(201).json({
success: true,
message: "Todo created successfully",
data: todo,
});
});
const getAllTodos = catchAsync(async (req, res, next) => {
// Admin bisa lihat semua todo dari semua user, user biasa hanya lihat miliknya sendiri
const todos =
req.user.role === "admin"
? await todoService.getAllTodosForAdmin()
: await todoService.getAllTodos(req.user._id);
res.status(200).json({
success: true,
message: "Todos retrieved successfully",
data: todos,
});
});
const getTodoById = catchAsync(async (req, res, next) => {
const { id } = req.params;
const todo = await todoService.getTodoById(id);
if (!todo) {
return next(new AppError("Todo not found", 404));
}
const isOwner = todo.owner.toString() === req.user._id.toString();
if (!isOwner && req.user.role !== "admin") {
return next(new AppError("You do not have permission to access this todo", 403));
}
res.status(200).json({
success: true,
message: "Todo retrieved successfully",
data: todo,
});
});
const updateTodo = catchAsync(async (req, res, next) => {
const { id } = req.params;
const { title, description, completed } = req.body;
const existingTodo = await todoService.getTodoById(id);
if (!existingTodo) {
return next(new AppError("Todo not found", 404));
}
const isOwner = existingTodo.owner.toString() === req.user._id.toString();
if (!isOwner && req.user.role !== "admin") {
return next(new AppError("You do not have permission to update this todo", 403));
}
const updatedTodo = await todoService.updateTodo(id, { title, description, completed });
res.status(200).json({
success: true,
message: "Todo updated successfully",
data: updatedTodo,
});
});
const deleteTodo = catchAsync(async (req, res, next) => {
const { id } = req.params;
const existingTodo = await todoService.getTodoById(id);
if (!existingTodo) {
return next(new AppError("Todo not found", 404));
}
const isOwner = existingTodo.owner.toString() === req.user._id.toString();
if (!isOwner && req.user.role !== "admin") {
return next(new AppError("You do not have permission to delete this todo", 403));
}
await todoService.deleteTodo(id);
res.status(200).json({
success: true,
message: "Todo deleted successfully",
data: existingTodo,
});
});
module.exports = {
createTodo,
getAllTodos,
getTodoById,
updateTodo,
deleteTodo,
};
9. Melindungi Route Todo dengan protect
Sekarang pasang middleware protect di semua route todo, supaya hanya user yang sudah login yang bisa mengaksesnya.
src/routes/todo.routes.js (update penuh)
const express = require("express");
const router = express.Router();
const todoController = require("../controllers/todo.controller");
const { protect } = require("../middlewares/auth.middleware");
// Semua route di bawah ini wajib login terlebih dahulu
router.use(protect);
router.post("/", todoController.createTodo);
router.get("/", todoController.getAllTodos);
router.get("/:id", todoController.getTodoById);
router.put("/:id", todoController.updateTodo);
router.delete("/:id", todoController.deleteTodo);
module.exports = router;
10. Mendaftarkan Route Auth di app.js
src/app.js (update penuh)
const express = require("express");
const todoRoutes = require("./routes/todo.routes");
const authRoutes = require("./routes/auth.routes");
const logger = require("./middlewares/logger.middleware");
const notFound = require("./middlewares/notFound.middleware");
const errorHandler = require("./middlewares/errorHandler.middleware");
const app = express();
app.use(logger);
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "Todo API is running" });
});
app.use("/api/auth", authRoutes);
app.use("/api/todos", todoRoutes);
app.use(notFound);
app.use(errorHandler);
module.exports = app;
11. Struktur Folder Setelah Artikel Ini
todo-api/
├── src/
│ ├── config/
│ │ └── db.js
│ ├── middlewares/
│ │ ├── logger.middleware.js
│ │ ├── notFound.middleware.js
│ │ ├── errorHandler.middleware.js
│ │ ├── auth.middleware.js
│ │ └── restrictTo.middleware.js
│ ├── models/
│ │ ├── todo.model.js
│ │ └── user.model.js
│ ├── services/
│ │ ├── todo.service.js
│ │ └── auth.service.js
│ ├── controllers/
│ │ ├── todo.controller.js
│ │ └── auth.controller.js
│ ├── routes/
│ │ ├── todo.routes.js
│ │ └── auth.routes.js
│ ├── utils/
│ │ ├── AppError.js
│ │ └── catchAsync.js
│ ├── app.js
│ └── server.js
├── .env
├── .env.example
├── .gitignore
└── package.json
12. Menguji Alur Auth dan RBAC
npm run dev
Tes 1 — Register user baru.
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name": "Budi", "email": "budi@example.com", "password": "rahasia123"}'{
"success": true,
"message": "User registered successfully",
"data": {
"user": { "id": "665f...", "name": "Budi", "email": "budi@example.com", "role": "user" },
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
Tes 2 — Login.
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "budi@example.com", "password": "rahasia123"}'
Tes 3 — Akses endpoint todo tanpa token (harus ditolak).
curl http://localhost:3000/api/todos
{
"success": false,
"message": "You are not logged in. Please log in to get access"
}Tes 4 — Akses endpoint todo dengan token (harus berhasil). Ganti <TOKEN> dengan token dari hasil login:
curl http://localhost:3000/api/todos \
-H "Authorization: Bearer <TOKEN>"Tes 5 — User A mencoba menghapus todo milik User B (harus ditolak dengan 403). Buat dua akun berbeda, buat todo dengan akun A, lalu coba hapus dengan token akun B:
curl -X DELETE http://localhost:3000/api/todos/<ID_TODO_MILIK_A> \
-H "Authorization: Bearer <TOKEN_USER_B>"
{
"success": false,
"message": "You do not have permission to delete this todo"
}
13. Perbandingan Sebelum dan Sesudah
Aspek | Sebelum | Sesudah |
|---|---|---|
Akses endpoint | Siapa saja bisa akses tanpa batasan | Wajib login (token JWT valid) |
Kepemilikan data | Tidak ada konsep pemilik todo | Setiap todo terikat ke satu user (owner) |
Password | — (belum ada sistem user) | Di-hash dengan bcrypt, tidak pernah disimpan mentah |
Hak akses admin vs user | Tidak ada perbedaan | Admin bisa akses semua data, user hanya miliknya sendiri |
14. Kesimpulan
Sekarang Todo List API kita sudah punya sistem autentikasi dan otorisasi yang layak: user harus login untuk mengakses data, password tersimpan aman lewat hashing, dan setiap user hanya bisa mengelola datanya sendiri kecuali dia admin. Konsep protect (autentikasi) dan restrictTo (otorisasi berbasis role) ini adalah pola yang akan terus kamu pakai di hampir semua project backend nantinya.
Tapi ada satu skenario yang belum kita tangani: bagaimana kalau yang mengakses API bukan user manusia lewat login, tapi aplikasi/servis lain (misalnya integrasi pihak ketiga)? Untuk kasus ini, kita butuh lapisan proteksi tambahan yang disebut API Key, yang akan kita bahas di artikel selanjutnya.
Lanjutkan ke artikel berikutnya: Cara Melindungi API dengan API Key: Proteksi Endpoint dari Akses Tidak Dikenal
Artikel Terkait
Panduan Membuat Animasi Animated on Scroll di Website Anda
Pelajari cara membuat animasi menarik pada scroll di website Anda dengan tutorial ini. Tampilkan konten dengan cara yang lebih interaktif!
Cara Menangani Error dan HTTP Status Code yang Benar
cara menentukan HTTP status code yang benar untuk tiap jenis error, dan cara membuat custom Error
Tutorial Membuat REST API dengan Laravel yang Mudah dan Praktis
Pelajari langkah demi langkah cara membuat REST API menggunakan Laravel dengan mudah. Cocok untuk pemula dan pengembang berpengalaman.
