Lewati ke konten

PRAKTIKUM RSI

Auth dan Middleware

Autentikasi login, token/session concept, middleware dasar, parameterized middleware, protected routes.

Konsep Autentikasi

Kamu itu Siapa?

API Review Kantin sudah jalan sejak pertemuan 4. Ada endpoint untuk melihat daftar warung, menambah warung, dan menghapus warung. Pertanyaannya, siapa yang boleh mengakses itu semua?

Kalau dibiarkan terbuka, siapa pun yang tahu alamat endpoint-nya bisa DELETE /api/v1/stalls/1 dan menghapus warung orang lain. Cukup buka Postman, kirim request, beres.

Masalahnya, server HTTP itu buta. Yang diteruskannya ke aplikasi hanya method, path, header, dan body. Tidak ada konsep "user" di dalam HTTP. Orang yang menekan tombol di aplikasi, orang yang mengirim curl, dan bot yang menebak endpoint, semuanya terlihat sama: sekumpulan byte.

Jadi server butuh cara untuk mengenali identitas pengirim request, lalu memutuskan boleh atau tidak aksi tertentu dilakukan. Di pertemuan ini kita belajar keduanya.

Autentikasi vs Otorisasi

Dua hal ini berbeda, tetapi sering tertukar:

  • Autentikasi bertanya "kamu siapa?". Proses login dan verifikasi identitas. Kalau gagal, server belum tahu kamu siapa, dan jawabannya 401.
  • Otorisasi bertanya "kamu boleh apa?". Server sudah tahu kamu siapa, lalu memeriksa hak akses atau role. Kalau gagal, jawabannya 403.

Urutannya wajib autentikasi dulu, baru otorisasi. Tidak mungkin memeriksa hak akses kalau belum tahu siapa user-nya.

Analogi

Autentikasi seperti menunjukkan KTP di pintu masuk. Otorisasi seperti petugas yang memeriksa apakah KTP itu boleh masuk ruang server.

Di JWT, token menjadi bukti autentikasi. Role di dalam token dipakai untuk otorisasi.

Bacaan Lanjutan

Session vs JWT

Ada dua cara server "mengingat" user yang sudah login:

AspekSessionJWT
Data loginDisimpan di server (memori/database)Disimpan di token, dikirim ke client
Yang dikirim clientID session (biasanya cookie)Token lengkap
PemeriksaanID dicocokkan ke penyimpanan serverSignature token diverifikasi
SkalaPerlu penyimpanan bersama antar serverStateless, mudah di-scale

Di pertemuan ini kita menjelaskan keduanya, tetapi mengimplementasikan JWT saja. Alasannya, JWT cocok untuk API: stateless, bisa dipakai frontend web maupun mobile, dan datanya sudah ada di dalam token.

Kekurangan JWT

Token tidak bisa dicabut sebelum kedaluwarsa. Kalau token bocor, token itu tetap sah sampai exp-nya lewat. Karena itu umur token dibuat pendek.

Bacaan Lanjutan

Cara Kerja JWT

JWT itu string panjang berisi tiga bagian yang dipisah titik: header.payload.signature.

  • Header: tipe token dan algoritma, biasanya HS256.
  • Payload: data user (id, name, email, role) plus iat dan exp.
  • Signature: hasil HMAC dari header + payload + secret. Ini yang membuat token tidak bisa dipalsukan.

Payload hanya di-encode (base64url), bukan dienkripsi. Siapa pun yang memegang token bisa membacanya. Jadi jangan pernah menaruh password di payload.

payload JWT (hasil decode)json
{
  "sub": "1",
  "name": "Admin Kantin",
  "email": "admin@kantin.test",
  "role": "admin",
  "iat": 1790679286,
  "exp": 1790686486
}

Alur pakainya: user login, server membuat token, client menyimpan token, lalu setiap request ke endpoint terlindungi mengirim header Authorization: Bearer <token>. Server memverifikasi signature dan exp. Kalau valid, request lanjut. Kalau tidak, 401.

Di kode, dua fungsi inilah inti JWT:

src/services/tokenService.tstypescript
import jwt, { type SignOptions } from 'jsonwebtoken';
import type { UserRole } from '../repositories/userRepository.ts';

/** Bentuk `req.user` nanti diisi oleh middleware auth (hands-on 2). */
export interface AuthUser {
  id: number;
  name: string;
  email: string;
  role: UserRole;
}

/**
 * Rahasia penanda tangani JWT dibaca dari .env.
 * Sengaja TIDAK diberi nilai default: kalau lupa diset, server harus berhenti
 * dengan pesan jelas, bukan diam-diam menandatangani token dengan string tebakan.
 */
export function getJwtSecret(): string {
  const secret = process.env.JWT_SECRET;
  if (!secret) {
    throw new Error('JWT_SECRET belum diset di .env — token tidak bisa dibuat atau diverifikasi');
  }
  return secret;
}

/**
 * Umur token (format '2h', '30m', '7d'). Tipe yang diharapkan jsonwebtoken
 * adalah tipe internal miliknya sendiri, jadi string dari env di-cast lewat
 * SignOptions agar strict TypeScript tidak protes.
 */
function getExpiresIn(): SignOptions['expiresIn'] {
  return (process.env.JWT_EXPIRES_IN ?? '2h') as SignOptions['expiresIn'];
}

/**
 * Membuat token JWT. Isinya (payload):
 * - sub / id : identitas user
 * - name, email, role : data yang dibutuhkan server untuk otorisasi nanti
 * - iat, exp : dibuat kapan & kedaluwarsa kapan (otomatis oleh jsonwebtoken)
 *
 * JANGAN pernah menaruh password di payload — payload hanya di-encode
 * (base64url), bukan dienkripsi, sehingga bisa dibaca siapa pun yang punya token.
 */
export function signToken(user: AuthUser): string {
  return jwt.sign(
    { sub: String(user.id), name: user.name, email: user.email, role: user.role },
    getJwtSecret(),
    {
      algorithm: 'HS256',
      expiresIn: getExpiresIn(),
    },
  );
}

/**
 * Membalik proses `signToken`: memastikan signature cocok dengan JWT_SECRET
 * dan token belum kedaluwarsa, lalu mengembalikan AuthUser.
 *
 * Melempar TokenExpiredError / JsonWebTokenError kalau tidak valid —
 * pemanggil yang memutuskan cara membalas ke client (middleware auth → 401).
 */
export function verifyToken(token: string): AuthUser {
  const payload = jwt.verify(token, getJwtSecret(), { algorithms: ['HS256'] });

  if (typeof payload === 'string') {
    throw new jwt.JsonWebTokenError('Payload token bukan objek JSON');
  }

  const id = Number(payload.sub);
  const { name, email, role } = payload as {
    name?: string;
    email?: string;
    role?: UserRole;
  };

  if (!Number.isInteger(id) || !name || !email || !role) {
    throw new jwt.JsonWebTokenError('Payload token tidak memuat data user yang lengkap');
  }

  return { id, name, email, role };
}
Bacaan Lanjutan

Middleware Dasar

Sejak pertemuan 4 kita sudah kenal middleware: gerbang sebelum controller. Kali ini middleware dipakai untuk hal baru, yaitu memeriksa token.

Sebelum request masuk ke controller, middleware memeriksa: ada token di header atau tidak, dan token-nya valid atau tidak. Kalau tidak ada atau tidak valid, request langsung ditolak, controller tidak pernah kebagian. Kalau valid, data user disimpan ke req.user supaya handler berikutnya tidak perlu mengurai token lagi.

Kenapa di middleware, bukan di controller? Karena sekali ditulis, dipakai di semua endpoint. Kalau cara memeriksa token berubah, cukup ubah satu tempat.

urutan middleware per endpointtypescript
// Satu endpoint, empat pemeriksaan berurutan.
// Urutan pemasangan middleware = urutan pemeriksaan.
router.post(
  '/stalls',
  authenticate,           // 1. token valid?         -> 401 kalau tidak
  authorize('owner'),     // 2. role-nya boleh?      -> 403 kalau tidak
  validate(createSchema), // 3. body sesuai aturan?  -> 400 kalau tidak
  stallController.create, // 4. baru logika bisnis dijalankan
);
Bacaan Lanjutan

Hands-on 1

Ambil Starter

Kita tidak mulai dari nol. Titik awal Hands-on 1 adalah project pertemuan 4 hands-on-2(validasi zod + error handling + Swagger), yang belum punya autentikasi sama sekali. Supaya titik mulainya jelas, titik awal itu disediakan di branch starter/pertemuan-05.

Terminal: Clone starter Pertemuan 05
git clone --single-branch --branch starter/pertemuan-05 https://github.com/rrrambatsigma/praktikum-rsi-2026.git praktikum-pert-5

Hasil clone hanya berisi Pertemuan 5 dalam kondisi awal:

praktikum-pert-5/
└─ pertemuan-05-auth-middleware/
   ├─ db/seed-password-hashes.sql          # jalankan sekali sebelum mulai
   ├─ hands-on-1-autentikasi-jwt/          # titik awal Hands-on 1
   └─ hands-on-2-middleware-otorisasi/     # titik awal Hands-on 2

Masuk ke folder Hands-on 1. Semua langkah berikut dijalankan dari dalam folder ini.

Terminal: Masuk folder Hands-on 1
cd praktikum-pert-5/pertemuan-05-auth-middleware/hands-on-1-autentikasi-jwt
Versi jadi di GitHub (branch feat/pertemuan-5)

Install & Konfigurasi

Terminal: Install dependensi
npm install npm install jsonwebtoken bcryptjs npm install -D @types/jsonwebtoken

bcryptjs v3 sudah membawa tipe sendiri, jadi cukup @types/jsonwebtoken untuk jsonwebtoken.

Salin file .env.example menjadi .env.

Lalu tambahkan tiga variabel autentikasi ke .env dan .env.example:

.env (tambahan)ini
JWT_SECRET=ganti-dengan-string-acak-minimal-32-karakter
JWT_EXPIRES_IN=2h
BCRYPT_SALT_ROUNDS=10
  • JWT_SECRET: kunci HMAC penanda tangan token. Nilainya bebas, dan wajib diganti di produksi. Kalau bocor, penyerang bisa membuat token palsu.
  • JWT_EXPIRES_IN: umur token (2h, 30m, 7d).
  • BCRYPT_SALT_ROUNDS: jumlah iterasi hashing bcrypt.
Jangan lupa seed password

Seed Pertemuan 03 masih memakai placeholder hash_admin, bukan hash bcrypt. Jalankan skrip pertemuan-05-auth-middleware/db/seed-password-hashes.sql sekali agar akun bisa login:

Terminal: Seed password
# dari folder praktikum-pert-5 (root hasil clone) sqlcmd -S localhost -E -C -i pertemuan-05-auth-middleware/db/seed-password-hashes.sql

Lewat SSMS, buka file itu lalu jalankan. Prasyarat database (SQL Server aktif dan review_kantin sudah di-seed) ada di Pertemuan 03.

Schema Auth

Endpoint baru tetap mengikuti aturan validasi pertemuan 4. Yang perlu dicermati: schema register tidak punya field role. Role ditentukan server (selalu customer), dan .strict() menolak percobaan mengirim role dari body.

src/schemas/authSchema.tstypescript
import { z } from 'zod';

// Field dasar yang dipakai ulang oleh beberapa schema.
// bcrypt hanya memproses 72 byte pertama, jadi password dibatasi di situ.
const passwordSchema = z
  .string({ required_error: 'password wajib diisi' })
  .min(8, 'password minimal 8 karakter')
  .max(72, 'password maksimal 72 karakter');

const emailSchema = z
  .string({ required_error: 'email wajib diisi' })
  .trim()
  .toLowerCase()
  .email('format email tidak valid')
  .max(150, 'email maksimal 150 karakter');

// Body schema.
// CATATAN: schema ini SENGAJA tidak punya field `role`.
// Role diberikan server saat register (selalu 'customer'). Kalau `role`
// diterima dari body, siapa pun bisa mendaftarkan diri sebagai admin.
export const registerSchema = z
  .object({
    name: z
      .string({ required_error: 'name wajib diisi' })
      .trim()
      .min(3, 'name minimal 3 karakter')
      .max(100, 'name maksimal 100 karakter'),
    email: emailSchema,
    password: passwordSchema,
  })
  // .strict() menolak kunci tak dikenal, termasuk percobaan mengirim `role`.
  // zod v3 menerima pesan kustom di sini, jadi pesan default-nya yang bahasa
  // Inggris bisa diganti agar konsisten dengan pesan validasi yang lain.
  .strict({ message: 'body hanya boleh berisi name, email, dan password' });

export const loginSchema = z
  .object({
    email: emailSchema,
    // Di login cukup dicek tidak kosong; aturan panjang hanya berlaku saat register.
    password: z.string({ required_error: 'password wajib diisi' }).min(1, 'password wajib diisi'),
  })
  .strict({ message: 'body hanya boleh berisi email dan password' });

// Tipe turunan dari schema di atas.
export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;

Dua detail kecil yang penting: email di-trim dan di-toLowerCase supaya Budi@Test.COM tidak jadi akun kedua, dan password dibatasi 72 karakter karena bcrypt hanya memproses 72 byte pertama.

Repository & DTO

Repository khusus tabel USERS membaca dan menulis baris, dan tidak tahu apa itu hashing. Bentuk data yang dikirim ke client juga dipisahkan lewat DTO, supaya passwordHash tidak pernah ikut terkirim.

src/repositories/userRepository.tstypescript
import { eq } from 'drizzle-orm';
import { getDb } from '../db/index.ts';
import { users } from '../db/schema.ts';

/** Sama dengan enum kolom `role` di tabel USERS (drizzle sudah meng-infer-nya). */
export type UserRole = 'admin' | 'owner' | 'customer';

export interface CreateUserInput {
  name: string;
  email: string;
  /** Sudah di-hash oleh AuthService — repository tidak pernah hashing sendiri. */
  passwordHash: string;
  role: UserRole;
}

export class UserRepository {
  async findByEmail(email: string) {
    const db = await getDb();
    const rows = await db.select().from(users).where(eq(users.email, email));
    return rows[0];
  }

  async findById(id: number) {
    const db = await getDb();
    const rows = await db.select().from(users).where(eq(users.id, id));
    return rows[0];
  }

  // MSSQL: pengembalian baris hasil insert memakai .output() (bukan .returning()).
  // created_at tidak diisi di sini karena sudah punya DEFAULT SYSDATETIME() di DDL.
  async create(input: CreateUserInput) {
    const db = await getDb();
    const rows = await db
      .insert(users)
      .output()
      .values({
        name: input.name,
        email: input.email,
        passwordHash: input.passwordHash,
        role: input.role,
      });
    return rows[0];
  }
}
src/dtos/authDto.tstypescript
/** Data user yang aman dikirim ke client — TIDAK ikut menyertakan passwordHash. */
export interface AuthUserDto {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'owner' | 'customer';
}

export interface RegisterResponseDto {
  user: AuthUserDto;
}

export interface LoginResponseDto {
  /** Token JWT mentah. Client mengirimnya kembali di header Authorization. */
  token: string;
  /** Selalu 'Bearer' — penanda cara penyebutan token di header. */
  tokenType: 'Bearer';
  /** Umur token yang masih berlaku, mis. '2h'. */
  expiresIn: string;
  user: AuthUserDto;
}

Hash Password & Token

Password tidak pernah disimpan apa adanya. Password di-hash dengan bcrypt, dan saat login input dibandingkan memakai bcrypt.compare(). Token dibuat oleh AuthService lewat signToken().

src/services/authService.tstypescript
import bcrypt from 'bcryptjs';
import { UserRepository } from '../repositories/userRepository.ts';
import { signToken, type AuthUser } from './tokenService.ts';
import { AppError } from '../errors/AppError.ts';
import { UnauthorizedError } from '../errors/UnauthorizedError.ts';
import type { LoginInput, RegisterInput } from '../schemas/authSchema.ts';
import type { AuthUserDto, LoginResponseDto } from '../dtos/authDto.ts';

type UserRow = NonNullable<Awaited<ReturnType<UserRepository['findByEmail']>>>;

/** Biaya bcrypt: jumlah iterasi hashing. Makin besar = makin lambat & aman. */
function getSaltRounds(): number {
  return Number(process.env.BCRYPT_SALT_ROUNDS ?? 10);
}

export class AuthService {
  private userRepository: UserRepository;

  constructor(userRepository: UserRepository = new UserRepository()) {
    this.userRepository = userRepository;
  }

  // Mapping row DB -> DTO. Perhatikan passwordHash SENGAJA tidak ikut,
  // itulah gunanya DTO: bentuk respons API tidak sama dengan bentuk tabel.
  private toDto(row: UserRow): AuthUserDto {
    return { id: row.id, name: row.name, email: row.email, role: row.role };
  }

  async register(input: RegisterInput): Promise<AuthUserDto> {
    const existing = await this.userRepository.findByEmail(input.email);
    if (existing) {
      throw new AppError(409, 'Email sudah terdaftar');
    }

    // Password di-hash dengan salt acak. Password asli tidak pernah disimpan
    // dan tidak pernah dikembalikan ke client.
    const passwordHash = await bcrypt.hash(input.password, getSaltRounds());

    const row = await this.userRepository.create({
      name: input.name,
      email: input.email,
      passwordHash,
      // Role ditentukan server, bukan client. Pendaftaran mandiri = customer.
      role: 'customer',
    });
    if (!row) throw new AppError(500, 'User gagal dibuat');

    return this.toDto(row);
  }

  async login(input: LoginInput): Promise<LoginResponseDto> {
    const row = await this.userRepository.findByEmail(input.email);

    // Pesan sengaja sama untuk "email tidak terdaftar" dan "password salah",
    // supaya penyerang tidak bisa menebak email mana yang memang terdaftar.
    if (!row) throw new UnauthorizedError('Email atau password salah');

    const passwordValid = await bcrypt.compare(input.password, row.passwordHash);
    if (!passwordValid) throw new UnauthorizedError('Email atau password salah');

    const user: AuthUser = this.toDto(row);
    return {
      token: signToken(user),
      tokenType: 'Bearer',
      expiresIn: process.env.JWT_EXPIRES_IN ?? '2h',
      user,
    };
  }
}
Pesan login disamakan

"Email tidak terdaftar" dan "password salah" sengaja dibalas dengan pesan yang sama. Kalau dibedakan, endpoint login berubah jadi alat untuk menebak email mana yang terdaftar.

Bacaan Lanjutan

Register, Login, Controller, Router

Controller membalas hasil service. Register mengembalikan user (tanpa passwordHash), login mengembalikan token. Router memasang validate untuk body-nya. Kedua route ini publik, karena justru di sinilah token dibuat.

src/controllers/authController.tstypescript
import type { Request, Response } from 'express';
import { AuthService } from '../services/authService.ts';
import { getValidated } from '../middlewares/validate.ts';
import type { LoginInput, RegisterInput } from '../schemas/authSchema.ts';

/**
 * Register & login sengaja TIDAK memakai token apa pun — justru di endpoint
 * inilah token dibuat. Error dilempar apa pun (email bentrok, password salah)
 * akan diteruskan Express ke errorHandler.
 */
export class AuthController {
  private authService: AuthService;

  constructor(authService: AuthService = new AuthService()) {
    this.authService = authService;
  }

  register = async (_req: Request, res: Response): Promise<void> => {
    const body = getValidated<RegisterInput>(res, 'body');
    const user = await this.authService.register(body);
    res.status(201).json({ status: 'success', data: { user } });
  };

  login = async (_req: Request, res: Response): Promise<void> => {
    const body = getValidated<LoginInput>(res, 'body');
    const data = await this.authService.login(body);
    res.status(200).json({ status: 'success', data });
  };
}
src/routes/authRouter.tstypescript
import { Router } from 'express';
import { AuthController } from '../controllers/authController.ts';
import { validate } from '../middlewares/validate.ts';
import { loginSchema, registerSchema } from '../schemas/authSchema.ts';

const authRouter = Router();
const authController = new AuthController();

// Kedua route ini publik. Belum ada authenticate di sini — dan memang tidak
// boleh ada, karena token justru dibuat oleh endpoint login.
authRouter.post('/register', validate(registerSchema, 'body'), authController.register);
authRouter.post('/login', validate(loginSchema, 'body'), authController.login);

export { authRouter };
src/errors/UnauthorizedError.tstypescript
import { AppError } from './AppError.ts';

/**
 * Dipakai saat request tidak membawa token yang bisa dipercaya — belum login,
 * token tidak ada, format header salah, signature tidak cocok, atau token kedaluwarsa.
 * Memetakan ke HTTP 401.
 *
 * Bedakan dengan 403 (ForbiddenError, ada di hands-on 2): 401 berarti "siapa kamu?"
 * belum terjawab, 403 berarti "kamu tahu, tapi tidak boleh".
 */
export class UnauthorizedError extends AppError {
  constructor(message = 'Token tidak ada atau tidak valid') {
    super(401, message);
    this.name = 'UnauthorizedError';
  }
}

Daftarkan router di src/index.ts, lalu rapikan errorHandler cukup dengan satu cabang if (error instanceof AppError).

// src/index.ts
app.use('/api/v1/auth', authRouter);

app.use('/api/v1/stalls', stallRouter);
src/middlewares/errorHandler.tstypescript
import type { ErrorRequestHandler } from 'express';
import { AppError } from '../errors/AppError.ts';

/**
 * Error handling terpusat: satu tempat untuk mengubah error menjadi
 * respons JSON yang konsisten. Didaftarkan paling akhir (setelah router).
 */
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
  // 1) Body JSON yang rusak (express.json otomatis melempar SyntaxError).
  const bodyError = error as SyntaxError & { status?: number; type?: string };
  if (
    error instanceof SyntaxError &&
    bodyError.status === 400 &&
    bodyError.type === 'entity.parse.failed'
  ) {
    res.status(400).json({
      status: 'fail',
      message: 'JSON pada body tidak valid',
    });
    return;
  }

  // 2) Error aplikasi yang disengaja (UnauthorizedError, NotFoundError, dll).
  //    Semuanya turunan AppError, jadi cukup satu cabang untuk semuanya.
  if (error instanceof AppError) {
    res.status(error.statusCode).json({
      status: 'fail',
      message: error.message,
      ...(error.details !== undefined ? { errors: error.details } : {}),
    });
    return;
  }

  // 3) Konflik constraint database mssql:
  //    2627 = unique violation, 547 = foreign key violation.
  const dbError = error as { number?: number };
  if (dbError.number === 2627 || dbError.number === 547) {
    res.status(409).json({
      status: 'fail',
      message: 'Data bentrok dengan data yang sudah ada',
    });
    return;
  }

  // 4) Fallback: error tak terduga. Detail tidak dibocorkan ke client.
  console.error('Unhandled error:', error);
  res.status(500).json({
    status: 'error',
    message: 'Terjadi kesalahan pada server',
  });
};

Terakhir, tambahkan schema dan path /auth ke Swagger supaya endpoint baru ikut terdokumentasi.

src/docs/openapi.ts (bagian /auth)typescript
// src/docs/openapi.ts (bagian endpoint /auth)
const authUserSchema = registry.register(
  'AuthUser',
  z.object({
    id: z.number().openapi({ example: 1 }),
    name: z.string().openapi({ example: 'Admin Kantin' }),
    email: z.string().openapi({ example: 'admin@kantin.test' }),
    role: z.enum(['admin', 'owner', 'customer']).openapi({ example: 'admin' }),
  }),
);

const registerInput = registry.register('RegisterInput', registerSchema);
const loginInput = registry.register('LoginInput', loginSchema);

registry.registerPath({
  method: 'post',
  path: '/api/v1/auth/register',
  summary: 'Daftar akun baru',
  request: {
    body: {
      description: 'Data pendaftaran',
      content: { 'application/json': { schema: registerInput } },
    },
  },
  responses: {
    201: {
      description: 'Akun berhasil dibuat',
      content: { 'application/json': { schema: registerResponse } },
    },
    400: { description: 'Body tidak valid', content: { 'application/json': { schema: errorSchema } } },
    409: { description: 'Email sudah terdaftar', content: { 'application/json': { schema: errorSchema } } },
  },
});

registry.registerPath({
  method: 'post',
  path: '/api/v1/auth/login',
  summary: 'Login dan terima token JWT',
  request: {
    body: {
      description: 'Kredensial login',
      content: { 'application/json': { schema: loginInput } },
    },
  },
  responses: {
    200: {
      description: 'Login berhasil, token dikembalikan',
      content: { 'application/json': { schema: loginResponse } },
    },
    400: { description: 'Body tidak valid', content: { 'application/json': { schema: errorSchema } } },
    401: { description: 'Email atau password salah', content: { 'application/json': { schema: errorSchema } } },
  },
});

Uji Autentikasi

Terminal: Jalankan server (development)
npm run dev

Register akun baru. Perhatikan role terisi customer padahal kita tidak pernah mengirim role, dan tidak ada passwordHash di respons.

Terminal: Register
curl.exe -X POST http://localhost:3000/api/v1/auth/register -H "Content-Type: application/json" -d '{"name":"Budi Santoso","email":"budi@test.com","password":"rahasia123"}'
{
  "status": "success",
  "data": {
    "user": { "id": 16, "name": "Budi Santoso", "email": "budi@test.com", "role": "customer" }
  }
}

Login dengan akun seed untuk mendapat token:

Terminal: Login
curl.exe -X POST http://localhost:3000/api/v1/auth/login -H "Content-Type: application/json" -d '{"email":"admin@kantin.test","password":"rahasia123"}'
{
  "status": "success",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibmFtZSI6IkFkbWlu...",
    "tokenType": "Bearer",
    "expiresIn": "2h",
    "user": { "id": 1, "name": "Admin Kantin", "email": "admin@kantin.test", "role": "admin" }
  }
}

Rangkuman kasus uji:

KasusHasil
Register valid201 + role customer
Email sudah terdaftar409 Email sudah terdaftar
Kirim role saat register400 ditolak .strict()
Login valid200 + token
Password salah / email tak terdaftar401 pesan sama
Data warung lama tanpa token200 (belum diproteksi)

Konsep Middleware & Otorisasi

Auth Middleware

Setelah Hands-on 1, user sudah bisa login dan memegang token. Tetapi token itu belum dipakai apa-apa, dan endpoint masih terbuka. Di sinilah auth middleware masuk.

Tugasnya satu: memeriksa token sebelum request masuk ke controller. Ambil token dari header Authorization, verifikasi signature dan exp. Kalau tidak ada atau tidak valid, langsung 401. Kalau valid, simpan data user ke req.user.

src/middlewares/auth.tstypescript
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { verifyToken, type AuthUser } from '../services/tokenService.ts';
import { UnauthorizedError } from '../errors/UnauthorizedError.ts';

const BEARER_PREFIX = 'Bearer ';

/**
 * Ambil token dari header `Authorization: Bearer <token>`.
 * Mengembalikan null kalau header tidak ada, tidak memakai skema Bearer, atau
 * tokennya kosong — semua kondisi itu sama-sama berarti "tidak terautentikasi".
 */
function extractToken(req: Request): string | null {
  const header = req.headers.authorization;
  if (!header || !header.startsWith(BEARER_PREFIX)) return null;

  const token = header.slice(BEARER_PREFIX.length).trim();
  return token.length > 0 ? token : null;
}

/**
 * Middleware autentikasi.
 *
 * Tanggung jawabnya satu saja: memastikan request membawa token yang valid,
 * lalu menaruhnya di `req.user` supaya handler berikutnya tidak perlu mengurai
 * token lagi. Kegagalan diteruskan lewat `next(error)` supaya semua error
 * dibalas oleh satu `errorHandler` yang sama seperti di Pertemuan-04.
 */
export const authenticate: RequestHandler = (
  req: Request,
  _res: Response,
  next: NextFunction,
) => {
  const token = extractToken(req);
  if (!token) {
    return next(new UnauthorizedError('Header Authorization dengan token Bearer wajib dikirim'));
  }

  try {
    // verifyToken melempar kalau signature salah atau token kedaluwarsa.
    req.user = verifyToken(token);
    return next();
  } catch {
    // Detail sengaja ditelan: client tidak perlu tahu bedanya "expired" dengan
    // "signature salah" — cukup tahu tokennya tidak valid.
    return next(new UnauthorizedError());
  }
};

/**
 * Pembaca `req.user` yang aman untuk controller dan middleware lain.
 * Melempar 401 (bukan diam-diam `undefined`) supaya kesalahan "lupa pasang
 * authenticate" muncul jelas saat pengembangan, bukan jadi error aneh
 * di baris kode lain yang tidak ada hubungannya.
 */
export function getUser(req: Request): AuthUser {
  if (!req.user) {
    throw new UnauthorizedError('Endpoint ini harus dipakai setelah middleware authenticate');
  }
  return req.user;
}
Kegagalan lewat next(error)

Middleware tidak menulis respons sendiri, tetapi meneruskan error lewat next(error) supaya semua error tetap melewati satu error handler yang sama seperti pertemuan 4.

Parameterized Middleware

Auth middleware sudah menjawab "kamu siapa?". Pertanyaan berikutnya: "kamu boleh ngapain?". Beberapa endpoint butuh role tertentu.

Kalau tiap role dibuatkan middleware sendiri (isAdmin, isOwner, isCustomer), kodenya berulang. Solusinya parameterized middleware: satu middleware yang menerima daftar role.

authorize('admin');            // hanya admin
authorize('owner', 'admin');   // owner atau admin
src/middlewares/authorize.tstypescript
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { ForbiddenError } from '../errors/ForbiddenError.ts';
import { getUser } from './auth.ts';
import type { UserRole } from '../repositories/userRepository.ts';

/**
 * Middleware otorisasi BERPARAMETER.
 *
 * `authorize(...roles)` mengembalikan RequestHandler baru, jadi pola yang
 * dipakai di router nanti cukup menulis role-nya di dalam kurung:
 *
 *   authenticate, authorize('admin')            -> hanya admin
 *   authenticate, authorize('owner', 'admin')   -> owner atau admin
 *
 * Wajib dipasang SETELAH `authenticate`, karena `req.user` baru ada setelah
 * token diverifikasi. Memasangnya terbalik akan menghasilkan 401, bukan 403.
 */
export function authorize(...allowedRoles: UserRole[]): RequestHandler {
  return (req: Request, _res: Response, next: NextFunction) => {
    // Menangkap kesalahan saat development: middleware dijalankan tanpa
    // satu pun role, artinya rute ini tidak pernah bisa diakses.
    if (allowedRoles.length === 0) {
      return next(new Error('authorize() harus menerima minimal satu role'));
    }

    // getUser melempar UnauthorizedError (401) kalau req.user belum diisi —
    // inilah penjaga kalau urutan middleware terbalik.
    const user = getUser(req);

    if (!allowedRoles.includes(user.role)) {
      return next(
        new ForbiddenError(`Endpoint ini hanya untuk role: ${allowedRoles.join(', ')}`),
      );
    }

    return next();
  };
}
src/errors/ForbiddenError.tstypescript
import { AppError } from './AppError.ts';

/**
 * Dipakai saat user SUDAH berhasil terautentikasi (token valid) tetapi role-nya
 * tidak memenuhi syarat untuk endpoint tersebut. Memetakan ke HTTP 403.
 *
 * Bedakan dari 401 (UnauthorizedError):
 * - 401 = "siapa kamu?"  -> token hilang, salah tanda tangan, atau kedaluwarsa.
 * - 403 = "kamu tahu, tapi tidak boleh." -> token valid, role tidak cukup.
 */
export class ForbiddenError extends AppError {
  constructor(message = 'Kamu tidak punya akses ke resource ini') {
    super(403, message);
    this.name = 'ForbiddenError';
  }
}
401 vs 403

Auth middleware gagal berarti 401 (belum login / token invalid). Role middleware gagal berarti 403 (sudah login, tetapi tidak punya akses).

Bacaan Lanjutan

Protected Routes

Dua middleware sudah siap. Tinggal dipasang di route. Route terbagi tiga jenis:

  1. Route publik: tidak butuh token. Contoh: register, login, daftar warung.
  2. Route terlindungi: butuh token, semua user login boleh. Contoh: profil, tambah review.
  3. Route khusus role: butuh token dan role tertentu. Contoh: hapus warung hanya admin.

Ringkasan otorisasi tiap endpoint di project ini:

EndpointAksesCatatan
GET /api/v1/stallspublikdaftar warung (filter + pagination)
GET /api/v1/stalls/:idpublikdetail warung
GET /api/v1/stalls/:id/menuspublikmenu warung
POST /api/v1/auth/registerpublikrole ditentukan server (customer)
POST /api/v1/auth/loginpublikmengembalikan token
GET /health, /docspublikhealth check & dokumentasi
GET /api/v1/auth/melogin (semua role)data dari token
GET /api/v1/auth/profilelogin (semua role)data terbaru dari database
POST /api/v1/stalls/:id/reviewslogin (semua role)userId dari token
POST /api/v1/stallsowner, adminownerId dari token
PUT /api/v1/stalls/:idowner (miliknya), admincek kepemilikan di service
DELETE /api/v1/stalls/:idadminowner yang mencoba mendapat 403
GET /api/v1/admin/reportsadminringkasan laporan

Urutannya penting: authenticate dulu, baru authorize, baru validate. Dengan begitu, request tanpa token selalu dibalas 401 apa pun isi body-nya, dan aturan validasi tidak terbocor ke pengguna anonim.

src/routes/stallRouter.tstypescript
import { Router } from 'express';
import { StallController } from '../controllers/stallController.ts';
import { ReviewController } from '../controllers/reviewController.ts';
import { validate } from '../middlewares/validate.ts';
import { authenticate } from '../middlewares/auth.ts';
import { authorize } from '../middlewares/authorize.ts';
import {
  createStallSchema,
  idParamSchema,
  stallQuerySchema,
  updateStallSchema,
} from '../schemas/stallSchema.ts';
import { createReviewSchema } from '../schemas/reviewSchema.ts';

const stallRouter = Router();
const stallController = new StallController();
const reviewController = new ReviewController();

// Route publik: daftar, detail, dan menu warung. Tanpa middleware auth apa
// pun — memang harus bisa dibaca tamu yang belum login.
stallRouter.get('/', validate(stallQuerySchema, 'query'), stallController.getStalls);
stallRouter.get('/:id', validate(idParamSchema, 'params'), stallController.getStallById);
stallRouter.get('/:id/menus', validate(idParamSchema, 'params'), stallController.getStallMenus);

// Route terlindungi. Urutannya: authenticate (token valid?) -> authorize (role
// cukup?) -> validate (body/params benar?). Menaruh authenticate paling depan
// membuat jawaban untuk request tanpa token SELALU 401, apa pun isi body-nya —
// jadi tidak ada celah yang membocorkan aturan validasi ke pengguna anonim.
stallRouter.post(
  '/',
  authenticate,
  authorize('owner', 'admin'),
  validate(createStallSchema, 'body'),
  stallController.createStall,
);
stallRouter.put(
  '/:id',
  authenticate,
  authorize('owner', 'admin'),
  validate(idParamSchema, 'params'),
  validate(updateStallSchema, 'body'),
  stallController.updateStall,
);
stallRouter.post(
  '/:id/reviews',
  authenticate,
  validate(idParamSchema, 'params'),
  validate(createReviewSchema, 'body'),
  reviewController.createReview,
);

// Hapus warung hanya untuk admin. Satu authorize() dengan satu role sudah
// cukup, tanpa perlu if-else di dalam controller.
stallRouter.delete(
  '/:id',
  authenticate,
  authorize('admin'),
  validate(idParamSchema, 'params'),
  stallController.deleteStall,
);

export { stallRouter };

Supaya TypeScript tahu req.user itu ada, kita perluas tipe Express secara global. Setelah itu casting di controller bisa dihapus.

src/types/express.d.tstypescript
import type { AuthUser } from '../services/tokenService.ts';

/**
 * Augmentasi tipe Express secara global.
 *
 * Menambah properti `user` ke `Express.Request` berarti SETIAP handler,
 * service, dan middleware di proyek ini bisa menulis `req.user` tanpa
 * casting dan tanpa membuat interface `AuthRequest` sendiri di tiap modul.
 *
 * `user` sengaja opsional (`?`): pada route publik `authenticate` tidak
 * dijalankan, jadi nilainya memang belum ada. Pembacaannya selalu lewat
 * `getUser()` yang melempar 401 kalau belum diisi.
 */
declare global {
  namespace Express {
    interface Request {
      user?: AuthUser;
    }
  }
}

export {};
Bacaan Lanjutan
  • OWASP ASVS: daftar pemeriksaan keamanan API yang bisa dipakai untuk audit sendiri.

Mengelola Ketergantungan (Dependency Injection)

Menutup rangkaian ini, ada satu pertanyaan arsitektural: tiap lapisan butuh lapisan lain. Controller butuh Service, Service butuh Repository. Bagaimana menyambungnya tanpa membuat kode kaku?

Cara polos: tiap komponen membuat sendiri kebutuhannya. Akibatnya komponen terikat keras ke implementasi beton dan sulit diganti saat testing. Solusinya Dependency Injection: komponen menerima ketergantungannya dari luar lewat constructor.

pola Dependency Injectiontypescript
// Tanpa DI: komponen membuat sendiri kebutuhannya.
// AuthService terikat keras ke UserRepository, sulit diganti saat testing.
class AuthServiceTerikat {
  private users = new UserRepository();
}

// Dengan DI: kebutuhan diterima dari luar lewat constructor.
export class AuthService {
  constructor(
    private users: UserRepository = new UserRepository(),
  ) {}

  async login(email: string) {
    return this.users.findByEmail(email);
  }
}

// Pemakaian normal cukup pakai default, tidak perlu mengisi apa pun:
new AuthService();

// Saat testing, suntik tiruan tanpa mengubah isi AuthService:
new AuthService(new FakeUserRepository());
  • Nilai default mengisi implementasi asli, jadi pemakaian normal tidak perlu menulis apa-apa.
  • Saat testing, cukup suntik tiruan: new AuthService(fakeRepo), tanpa mengubah AuthService.
  • Alurnya: Controller, Service, Repository, disambung dari luar, bukan dikunci di dalam.
Analogi

Seperti perangkat yang tinggal dicolok ke stopkontak. Kebutuhannya disediakan dari luar, jadi sumbernya bebas diganti tanpa membongkar perangkat.

Bacaan Lanjutan

Hands-on 2

Ambil Starter

Kalau belum clone branch starter, lakukan sekali:

Terminal: Clone starter Pertemuan 05
git clone --single-branch --branch starter/pertemuan-05 https://github.com/rrrambatsigma/praktikum-rsi-2026.git praktikum-pert-5

Hands-on 2 memakai folder hands-on-2-middleware-otorisasi. Titik awalnya adalah hasil Hands-on 1: sudah ada register, login, dan token JWT, tetapi token itu belum dipakai.

Terminal: Siapkan Hands-on 2
cd praktikum-pert-5/pertemuan-05-auth-middleware/hands-on-2-middleware-otorisasi npm install

Salin file .env.example menjadi .env.

Versi jadi di GitHub (branch feat/pertemuan-5)

Auth & Authorize

Tambahkan dua middleware: authenticate(cek token) dan authorize(cek role). Jangan lupa ForbiddenError dan perluasan tipe req.user.

src/middlewares/auth.tstypescript
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { verifyToken, type AuthUser } from '../services/tokenService.ts';
import { UnauthorizedError } from '../errors/UnauthorizedError.ts';

const BEARER_PREFIX = 'Bearer ';

/**
 * Ambil token dari header `Authorization: Bearer <token>`.
 * Mengembalikan null kalau header tidak ada, tidak memakai skema Bearer, atau
 * tokennya kosong — semua kondisi itu sama-sama berarti "tidak terautentikasi".
 */
function extractToken(req: Request): string | null {
  const header = req.headers.authorization;
  if (!header || !header.startsWith(BEARER_PREFIX)) return null;

  const token = header.slice(BEARER_PREFIX.length).trim();
  return token.length > 0 ? token : null;
}

/**
 * Middleware autentikasi.
 *
 * Tanggung jawabnya satu saja: memastikan request membawa token yang valid,
 * lalu menaruhnya di `req.user` supaya handler berikutnya tidak perlu mengurai
 * token lagi. Kegagalan diteruskan lewat `next(error)` supaya semua error
 * dibalas oleh satu `errorHandler` yang sama seperti di Pertemuan-04.
 */
export const authenticate: RequestHandler = (
  req: Request,
  _res: Response,
  next: NextFunction,
) => {
  const token = extractToken(req);
  if (!token) {
    return next(new UnauthorizedError('Header Authorization dengan token Bearer wajib dikirim'));
  }

  try {
    // verifyToken melempar kalau signature salah atau token kedaluwarsa.
    req.user = verifyToken(token);
    return next();
  } catch {
    // Detail sengaja ditelan: client tidak perlu tahu bedanya "expired" dengan
    // "signature salah" — cukup tahu tokennya tidak valid.
    return next(new UnauthorizedError());
  }
};

/**
 * Pembaca `req.user` yang aman untuk controller dan middleware lain.
 * Melempar 401 (bukan diam-diam `undefined`) supaya kesalahan "lupa pasang
 * authenticate" muncul jelas saat pengembangan, bukan jadi error aneh
 * di baris kode lain yang tidak ada hubungannya.
 */
export function getUser(req: Request): AuthUser {
  if (!req.user) {
    throw new UnauthorizedError('Endpoint ini harus dipakai setelah middleware authenticate');
  }
  return req.user;
}
src/middlewares/authorize.tstypescript
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { ForbiddenError } from '../errors/ForbiddenError.ts';
import { getUser } from './auth.ts';
import type { UserRole } from '../repositories/userRepository.ts';

/**
 * Middleware otorisasi BERPARAMETER.
 *
 * `authorize(...roles)` mengembalikan RequestHandler baru, jadi pola yang
 * dipakai di router nanti cukup menulis role-nya di dalam kurung:
 *
 *   authenticate, authorize('admin')            -> hanya admin
 *   authenticate, authorize('owner', 'admin')   -> owner atau admin
 *
 * Wajib dipasang SETELAH `authenticate`, karena `req.user` baru ada setelah
 * token diverifikasi. Memasangnya terbalik akan menghasilkan 401, bukan 403.
 */
export function authorize(...allowedRoles: UserRole[]): RequestHandler {
  return (req: Request, _res: Response, next: NextFunction) => {
    // Menangkap kesalahan saat development: middleware dijalankan tanpa
    // satu pun role, artinya rute ini tidak pernah bisa diakses.
    if (allowedRoles.length === 0) {
      return next(new Error('authorize() harus menerima minimal satu role'));
    }

    // getUser melempar UnauthorizedError (401) kalau req.user belum diisi —
    // inilah penjaga kalau urutan middleware terbalik.
    const user = getUser(req);

    if (!allowedRoles.includes(user.role)) {
      return next(
        new ForbiddenError(`Endpoint ini hanya untuk role: ${allowedRoles.join(', ')}`),
      );
    }

    return next();
  };
}
src/errors/ForbiddenError.tstypescript
import { AppError } from './AppError.ts';

/**
 * Dipakai saat user SUDAH berhasil terautentikasi (token valid) tetapi role-nya
 * tidak memenuhi syarat untuk endpoint tersebut. Memetakan ke HTTP 403.
 *
 * Bedakan dari 401 (UnauthorizedError):
 * - 401 = "siapa kamu?"  -> token hilang, salah tanda tangan, atau kedaluwarsa.
 * - 403 = "kamu tahu, tapi tidak boleh." -> token valid, role tidak cukup.
 */
export class ForbiddenError extends AppError {
  constructor(message = 'Kamu tidak punya akses ke resource ini') {
    super(403, message);
    this.name = 'ForbiddenError';
  }
}
src/types/express.d.tstypescript
import type { AuthUser } from '../services/tokenService.ts';

/**
 * Augmentasi tipe Express secara global.
 *
 * Menambah properti `user` ke `Express.Request` berarti SETIAP handler,
 * service, dan middleware di proyek ini bisa menulis `req.user` tanpa
 * casting dan tanpa membuat interface `AuthRequest` sendiri di tiap modul.
 *
 * `user` sengaja opsional (`?`): pada route publik `authenticate` tidak
 * dijalankan, jadi nilainya memang belum ada. Pembacaannya selalu lewat
 * `getUser()` yang melempar 401 kalau belum diisi.
 */
declare global {
  namespace Express {
    interface Request {
      user?: AuthUser;
    }
  }
}

export {};

Pasang ke Route

Pasang middleware per route. Route baca tetap publik, route tulis dilindungi, dan hapus warung hanya untuk admin. Identitas tidak boleh datang dari body, jadi ownerId dihapus dari schema body dan diambil dari token.

src/schemas/stallSchema.tstypescript
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';

// Menambahkan method `.openapi()` ke semua type zod.
// Wajib dipanggil SEBELUM schema memakai `.openapi({ ... })`.
extendZodWithOpenApi(z);

// Parameter path.
// id pada /:id selalu coerce dari string query/path menjadi angka.
export const idParamSchema = z.object({
  id: z.coerce
    .number({ invalid_type_error: 'id harus berupa angka' })
    .int('id harus bilangan bulat')
    .positive('id harus lebih dari 0')
    .openapi({ example: 3, description: 'ID warung' }),
});

// Query parameter.
// Search & category opsional; page/limit punya default (zod.default = bila absent).
export const stallQuerySchema = z.object({
  search: z
    .string()
    .trim()
    .max(100)
    .optional()
    .openapi({ description: 'Cari berdasarkan nama warung' }),
  category: z
    .string()
    .trim()
    .max(50)
    .optional()
    .openapi({ description: 'Filter kategori warung', example: 'Minuman' }),
  page: z.coerce
    .number({ invalid_type_error: 'page harus berupa angka' })
    .int('page harus bilangan bulat')
    .min(1, 'page minimal 1')
    .default(1)
    .openapi({ description: 'Nomor halaman (default 1)', example: 1 }),
  limit: z.coerce
    .number({ invalid_type_error: 'limit harus berupa angka' })
    .int('limit harus bilangan bulat')
    .min(1, 'limit minimal 1')
    .max(100, 'limit maksimal 100')
    .default(10)
    .openapi({ description: 'Jumlah data per halaman (maks 100)', example: 10 }),
});

// Field bantu: helper untuk field teks opsional.
const optionalNullableText = (max: number) =>
  z.string().trim().max(max).nullable().optional();

// Body schema.
// CATATAN PENTING: schema ini TIDAK punya field `ownerId`.
// Pemilik warung ditentukan server dari `req.user.id` (lihat stallController).
// Kalau `ownerId` diterima dari body, satu akun owner bisa membuat warung
// atas nama owner lain.
export const createStallSchema = z
  .object({
    name: z
      .string({ required_error: 'name wajib diisi' })
      .trim()
      .min(3, 'name minimal 3 karakter')
      .max(100, 'name maksimal 100 karakter')
      .openapi({ example: 'Warung Baru', description: 'Nama warung' }),
    category: optionalNullableText(50).openapi({
      example: 'Nasi',
      description: 'Kategori warung (mis. Nasi, Bakso, Minuman)',
    }),
    location: optionalNullableText(100).openapi({ example: 'Kantin FK', description: 'Lokasi warung' }),
    description: optionalNullableText(1000).openapi({
      example: 'Nasi goreng dadakan',
      description: 'Deskripsi warung',
    }),
  })
  // .strict() menolak (atau membuang) kunci yang tidak dikenal. Pesan kustom
  // dipakai supaya tidak ada pesan bawaan zod yang bahasa Inggris lolos ke user.
  .strict({ message: 'body hanya boleh berisi name, category, location, dan description' });

// Semua kolom opsional untuk PUT (update sebagian).
export const updateStallSchema = createStallSchema.partial();

// Tipe turunan dari schema di atas.
export type IdParam = z.infer<typeof idParamSchema>;
export type StallQuery = z.infer<typeof stallQuerySchema>;
export type CreateStallInput = z.infer<typeof createStallSchema>;
export type UpdateStallInput = z.infer<typeof updateStallSchema>;
src/routes/stallRouter.tstypescript
import { Router } from 'express';
import { StallController } from '../controllers/stallController.ts';
import { ReviewController } from '../controllers/reviewController.ts';
import { validate } from '../middlewares/validate.ts';
import { authenticate } from '../middlewares/auth.ts';
import { authorize } from '../middlewares/authorize.ts';
import {
  createStallSchema,
  idParamSchema,
  stallQuerySchema,
  updateStallSchema,
} from '../schemas/stallSchema.ts';
import { createReviewSchema } from '../schemas/reviewSchema.ts';

const stallRouter = Router();
const stallController = new StallController();
const reviewController = new ReviewController();

// Route publik: daftar, detail, dan menu warung. Tanpa middleware auth apa
// pun — memang harus bisa dibaca tamu yang belum login.
stallRouter.get('/', validate(stallQuerySchema, 'query'), stallController.getStalls);
stallRouter.get('/:id', validate(idParamSchema, 'params'), stallController.getStallById);
stallRouter.get('/:id/menus', validate(idParamSchema, 'params'), stallController.getStallMenus);

// Route terlindungi. Urutannya: authenticate (token valid?) -> authorize (role
// cukup?) -> validate (body/params benar?). Menaruh authenticate paling depan
// membuat jawaban untuk request tanpa token SELALU 401, apa pun isi body-nya —
// jadi tidak ada celah yang membocorkan aturan validasi ke pengguna anonim.
stallRouter.post(
  '/',
  authenticate,
  authorize('owner', 'admin'),
  validate(createStallSchema, 'body'),
  stallController.createStall,
);
stallRouter.put(
  '/:id',
  authenticate,
  authorize('owner', 'admin'),
  validate(idParamSchema, 'params'),
  validate(updateStallSchema, 'body'),
  stallController.updateStall,
);
stallRouter.post(
  '/:id/reviews',
  authenticate,
  validate(idParamSchema, 'params'),
  validate(createReviewSchema, 'body'),
  reviewController.createReview,
);

// Hapus warung hanya untuk admin. Satu authorize() dengan satu role sudah
// cukup, tanpa perlu if-else di dalam controller.
stallRouter.delete(
  '/:id',
  authenticate,
  authorize('admin'),
  validate(idParamSchema, 'params'),
  stallController.deleteStall,
);

export { stallRouter };
src/controllers/stallController.tstypescript
import type { Request, Response } from 'express';
import { StallService } from '../services/stallService.ts';
import { getUser } from '../middlewares/auth.ts';
import { getValidated } from '../middlewares/validate.ts';
import type {
  CreateStallInput,
  IdParam,
  StallQuery,
  UpdateStallInput,
} from '../schemas/stallSchema.ts';
import type { AuthUser } from '../services/tokenService.ts';

/**
 * Handler tanpa try/catch: data sudah divalidasi middleware `validate`
 * (diambil lewat `getValidated`), dan error apa pun yang dilempar akan
 * diteruskan Express ke `errorHandler` (Express 5 menangkap promise-reject
 * dari async handler secara otomatis).
 */
export class StallController {
  private stallService: StallService;

  constructor(stallService: StallService = new StallService()) {
    this.stallService = stallService;
  }

  getStalls = async (_req: Request, res: Response): Promise<void> => {
    const query = getValidated<StallQuery>(res, 'query');
    const { data, total } = await this.stallService.getAllStalls({
      search: query.search,
      category: query.category,
      page: query.page,
      limit: query.limit,
    });
    res.status(200).json({
      status: 'success',
      meta: { page: query.page, limit: query.limit, total },
      data,
    });
  };

  getStallById = async (_req: Request, res: Response): Promise<void> => {
    const { id } = getValidated<IdParam>(res, 'params');
    const stall = await this.stallService.getStallById(id);
    res.status(200).json({ status: 'success', data: stall });
  };

  getStallMenus = async (_req: Request, res: Response): Promise<void> => {
    const { id } = getValidated<IdParam>(res, 'params');
    const menus = await this.stallService.getStallMenus(id);
    res.status(200).json({ status: 'success', data: menus });
  };

  createStall = async (req: Request, res: Response): Promise<void> => {
    const body = getValidated<CreateStallInput>(res, 'body');
    const { id: ownerId } = getUser(req);

    // `ownerId` tidak pernah datang dari body — pemilik selalu user dari token.
    const stall = await this.stallService.createStall(body, ownerId);
    res.status(201).json({ status: 'success', data: stall });
  };

  updateStall = async (req: Request, res: Response): Promise<void> => {
    const { id } = getValidated<IdParam>(res, 'params');
    const body = getValidated<UpdateStallInput>(res, 'body');
    const user = getUser(req);

    // Service juga mengecek warung ini milik user tersebut atau bukan.
    const stall = await this.stallService.updateStall(id, body, user);
    res.status(200).json({ status: 'success', data: stall });
  };

  deleteStall = async (_req: Request, res: Response): Promise<void> => {
    const { id } = getValidated<IdParam>(res, 'params');
    const stall = await this.stallService.deleteStall(id);
    res.status(200).json({ status: 'success', data: stall });
  };
}

Role saja belum cukup. Owner hanya boleh mengubah warung miliknya, dan pemeriksaan itu dilakukan di service, bukan middleware. Prinsipnya: middleware menjawab "role apa?", service menjawab "milik siapa?".

src/services/stallService.tstypescript
import {
  StallRepository,
  type FindAllParams,
} from '../repositories/stallRepository.ts';
import { MenuItemRepository } from '../repositories/menuItemRepository.ts';
import type { MenuItemDto, StallResponseDto } from '../dtos/stallDto.ts';
import { ForbiddenError } from '../errors/ForbiddenError.ts';
import { NotFoundError } from '../errors/NotFoundError.ts';
import type { AuthUser } from './tokenService.ts';
import type { CreateStallInput, UpdateStallInput } from '../schemas/stallSchema.ts';

type StallRow = NonNullable<Awaited<ReturnType<StallRepository['findById']>>>;

export class StallService {
  private stallRepository: StallRepository;
  private menuItemRepository: MenuItemRepository;

  constructor(
    stallRepository: StallRepository = new StallRepository(),
    menuItemRepository: MenuItemRepository = new MenuItemRepository(),
  ) {
    this.stallRepository = stallRepository;
    this.menuItemRepository = menuItemRepository;
  }

  // Mapping row DB -> DTO API (sekaligus logika bisnis isPopular).
  private toDto(row: StallRow): StallResponseDto {
    const avgRating = Number(row.avgRating);
    return {
      id: row.id,
      ownerId: row.ownerId,
      name: row.name,
      category: row.category,
      location: row.location,
      description: row.description,
      avgRating,
      reviewCount: row.reviewCount,
      isPopular: avgRating >= 4.7,
    };
  }

  async getAllStalls(params: FindAllParams) {
    const { rows, total } = await this.stallRepository.findAll(params);
    return { data: rows.map((row) => this.toDto(row)), total };
  }

  async getStallById(id: number): Promise<StallResponseDto> {
    const row = await this.stallRepository.findById(id);
    if (!row) throw new NotFoundError('Warung tidak ditemukan');
    return this.toDto(row);
  }

  async getStallMenus(id: number): Promise<MenuItemDto[]> {
    const row = await this.stallRepository.findById(id);
    if (!row) throw new NotFoundError('Warung tidak ditemukan');
    return this.menuItemRepository.findByStallId(id);
  }

  /**
   * `ownerId` diterima sebagai parameter terpisah, bukan dari `input`.
   * Pemanggilnya mengambilnya dari `req.user` sehingga client tidak pernah
   * bisa menentukan sendiri pemilik warung.
   */
  async createStall(input: CreateStallInput, ownerId: number): Promise<StallResponseDto> {
    const row = await this.stallRepository.create({ ...input, ownerId });
    if (!row) throw new NotFoundError('Warung gagal dibuat');
    return this.toDto(row);
  }

  async updateStall(
    id: number,
    input: UpdateStallInput,
    user: AuthUser,
  ): Promise<StallResponseDto> {
    // Otorisasi tingkat objek: role "owner" saja belum cukup, warung yang
    // diubah juga harus milik user tersebut. Admin boleh semua.
    if (user.role !== 'admin') {
      const current = await this.stallRepository.findById(id);
      if (!current) throw new NotFoundError('Warung tidak ditemukan');
      if (current.ownerId !== user.id) {
        throw new ForbiddenError('Kamu hanya bisa mengubah warung milikmu sendiri');
      }
    }

    const row = await this.stallRepository.update(id, input);
    if (!row) throw new NotFoundError('Warung tidak ditemukan');
    return this.toDto(row);
  }

  async deleteStall(id: number): Promise<StallResponseDto> {
    const row = await this.stallRepository.remove(id);
    if (!row) throw new NotFoundError('Warung tidak ditemukan');
    return this.toDto(row);
  }
}
src/index.tstypescript
import express, { type Application } from 'express';
import { sql } from 'drizzle-orm';
import swaggerUi from 'swagger-ui-express';
import { getDb } from './db/index.ts';
import { authRouter } from './routes/authRouter.ts';
import { stallRouter } from './routes/stallRouter.ts';
import { adminRouter } from './routes/adminRouter.ts';
import { notFoundHandler } from './middlewares/notFound.ts';
import { errorHandler } from './middlewares/errorHandler.ts';
import { openApiDocument } from './docs/openapi.ts';

const app: Application = express();
const PORT: number = 3000;

app.use(express.json());

// Raw spek OpenAPI 3.0 (hasil generate di memori) — untuk import ke Postman/dll.
// Wajib didaftarkan SEBELUM app.use('/docs', swaggerUi.serve, ...) karena
// swagger-ui-express menangkap setiap path di bawah /docs dan mengembalikan index.html.
app.get('/docs/swagger.json', (_req, res) => {
  res.status(200).json(openApiDocument);
});

// Dokumentasi API OpenAPI 3.0 (dibangkitkan dari schema zod di memori).
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));

// Tanpa try/catch: Express 5 meneruskan handler async yang reject ke errorHandler.
app.get('/health', async (_req, res) => {
  const db = await getDb();
  await db.execute(sql`SELECT 1 AS ok`);
  res.status(200).json({ status: 'success', message: 'Server dan database terhubung' });
});

// Autentikasi: register & login. Dua-duanya route publik.
app.use('/api/v1/auth', authRouter);

// Stall tidak semuanya publik lagi — middleware dipasang per-route di dalam
// stallRouter, sehingga yang tetap publik masih bisa dibaca tanpa token.
app.use('/api/v1/stalls', stallRouter);

// Router khusus admin. Seluruh route di dalamnya dijaga authorize('admin'),
// jadi menambah route baru di sini otomatis ikut terlindungi selama
// middleware-nya tidak lupa ditulis.
app.use('/api/v1/admin', adminRouter);

// 404 untuk rute yang tidak dikenal, lalu error handling terpusat (paling akhir).
app.use(notFoundHandler);
app.use(errorHandler);

app.listen(PORT, () => {
  console.log(`Server berjalan di http://localhost:${PORT}`);
});

Profil & Review

Tambahkan endpoint baru yang memakai token. getMe membalas isi token, sedangkan /auth/profile memuat ulang data terbaru dari database.

src/controllers/authController.tstypescript
import type { Request, Response } from 'express';
import { AuthService } from '../services/authService.ts';
import { getUser } from '../middlewares/auth.ts';
import { getValidated } from '../middlewares/validate.ts';
import type { LoginInput, RegisterInput } from '../schemas/authSchema.ts';

/**
 * Register & login sengaja TIDAK memakai token apa pun — justru di endpoint
 * inilah token dibuat. Error dilempar apa pun (email bentrok, password salah)
 * akan diteruskan Express ke errorHandler.
 */
export class AuthController {
  private authService: AuthService;

  constructor(authService: AuthService = new AuthService()) {
    this.authService = authService;
  }

  register = async (_req: Request, res: Response): Promise<void> => {
    const body = getValidated<RegisterInput>(res, 'body');
    const user = await this.authService.register(body);
    res.status(201).json({ status: 'success', data: { user } });
  };

  login = async (_req: Request, res: Response): Promise<void> => {
    const body = getValidated<LoginInput>(res, 'body');
    const data = await this.authService.login(body);
    res.status(200).json({ status: 'success', data });
  };

  /**
   * Endpoint terlindungi paling sederhana: membalas apa yang ada di token.
   * Tidak ada query database sama sekali — inilah kekuatan dan sekaligus
   * kelemahan JWT (lihat `ProfileController.getProfile` untuk versi yang
   * memuat ulang data dari database).
   */
  getMe = async (req: Request, res: Response): Promise<void> => {
    const user = getUser(req);
    res.status(200).json({ status: 'success', data: user });
  };
}
src/routes/authRouter.tstypescript
import { Router } from 'express';
import { AuthController } from '../controllers/authController.ts';
import { ProfileController } from '../controllers/profileController.ts';
import { validate } from '../middlewares/validate.ts';
import { authenticate } from '../middlewares/auth.ts';
import { loginSchema, registerSchema } from '../schemas/authSchema.ts';

const authRouter = Router();
const authController = new AuthController();
const profileController = new ProfileController();

// Route publik. Belum ada authenticate di sini — dan memang tidak boleh ada,
// karena token justru dibuat oleh endpoint login.
authRouter.post('/register', validate(registerSchema, 'body'), authController.register);
authRouter.post('/login', validate(loginSchema, 'body'), authController.login);

// Route terlindungi. Satu token valid dipakai untuk endpoint apa pun yang butuh
// "siapa kamu", termasuk yang tidak butuh role khusus.
authRouter.get('/me', authenticate, authController.getMe);
authRouter.get('/profile', authenticate, profileController.getProfile);

export { authRouter };
src/controllers/profileController.tstypescript
import type { Request, Response } from 'express';
import { UserRepository } from '../repositories/userRepository.ts';
import { getUser } from '../middlewares/auth.ts';
import type { AuthUserDto } from '../dtos/authDto.ts';
import { NotFoundError } from '../errors/NotFoundError.ts';

type UserRow = NonNullable<Awaited<ReturnType<UserRepository['findById']>>>;

/**
 * Endpoint profil untuk menunjukkan perbedaan antara "data dari token" dan
 * "data dari database".
 *
 * `getMe` (di AuthController) membalas persis isi payload token tanpa menyentuh
 * database — cepat, tapi bisa basi kalau role berubah atau akun dihapus.
 * `getProfile` di sini memuat ulang user dari database memakai `req.user.id`,
 * jadi selalu mencerminkan kondisi terbaru.
 */
export class ProfileController {
  private userRepository: UserRepository;

  constructor(userRepository: UserRepository = new UserRepository()) {
    this.userRepository = userRepository;
  }

  getProfile = async (req: Request, res: Response): Promise<void> => {
    // Identitas dari token; data terbaru dari database.
    const { id } = getUser(req);

    const row: UserRow | undefined = await this.userRepository.findById(id);
    if (!row) {
      // Token-nya masih valid secara kriptografi, tapi user-nya sudah tidak ada.
      throw new NotFoundError('User tidak ditemukan');
    }

    const data: AuthUserDto = {
      id: row.id,
      name: row.name,
      email: row.email,
      role: row.role,
    };
    res.status(200).json({ status: 'success', data });
  };
}

Endpoint review juga dilindungi. Identitas penulis selalu diambil dari req.user, jadi schema review sengaja tidak punya userId.

src/schemas/reviewSchema.tstypescript
import { z } from 'zod';

/**
 * Body untuk membuat review.
 *
 * `userId` SENGAJA tidak ada di sini. Identitas selalu diambil dari
 * `req.user` — kalau berasal dari body, siapa pun bisa menulis review
 * seolah-olah dia orang lain.
 */
export const createReviewSchema = z
  .object({
    rating: z.coerce
      .number({ invalid_type_error: 'rating harus berupa angka' })
      .int('rating harus bilangan bulat')
      .min(1, 'rating minimal 1')
      .max(5, 'rating maksimal 5')
      .openapi({ example: 4, description: 'Rating 1 sampai 5' }),
    comment: z
      .string()
      .trim()
      .max(1000, 'comment maksimal 1000 karakter')
      .nullable()
      .optional()
      .openapi({ example: 'Kwetiaunya enak banget!', description: 'Komentar opsional' }),
  })
  .strict({ message: 'body hanya boleh berisi rating dan comment' });

export type CreateReviewInput = z.infer<typeof createReviewSchema>;
src/dtos/reviewDto.tstypescript
/** Bentuk review yang dikirim ke client (tanpa kolom internal yang tidak perlu). */
export interface ReviewResponseDto {
  id: number;
  stallId: number;
  userId: number;
  rating: number;
  comment: string | null;
  likeCount: number;
  createdAt: string | null;
}
src/repositories/reviewRepository.tstypescript
import { and, avg, count, eq, sql } from 'drizzle-orm';
import { getDb } from '../db/index.ts';
import { reviews, stalls } from '../db/schema.ts';

export interface CreateReviewParams {
  stallId: number;
  userId: number;
  rating: number;
  comment: string | null;
}

export class ReviewRepository {
  /**
   * Satu user hanya boleh punya satu review per warung — dijaga unique
   * constraint di database, tapi dicek di sini juga supaya pesannya jelas
   * (409) alih-alih error constraint mentah dari driver.
   */
  async findByStallAndUser(stallId: number, userId: number) {
    const db = await getDb();
    const rows = await db
      .select()
      .from(reviews)
      .where(and(eq(reviews.stallId, stallId), eq(reviews.userId, userId)));
    return rows[0];
  }

  async create(params: CreateReviewParams) {
    const db = await getDb();
    const rows = await db
      .insert(reviews)
      .output()
      .values({
        stallId: params.stallId,
        userId: params.userId,
        rating: params.rating,
        comment: params.comment,
        likeCount: 0,
      });
    return rows[0];
  }

  /**
   * Hitung ulang ringkasan warung (avg_rating & review_count) dari tabel REVIEWS.
   *
   * `rating` di-cast ke DECIMAL dulu: kolomnya INT, jadi tanpa cast SQL Server
   * memotong bagian desimal AVG()-nya dan (4 + 5) / 2 tersimpan sebagai 4.
   */
  async refreshStallSummary(stallId: number) {
    const db = await getDb();

    const stats = await db
      .select({
        total: count(),
        average: avg(sql<number>`cast(${reviews.rating} as decimal(10, 4))`),
      })
      .from(reviews)
      .where(eq(reviews.stallId, stallId));

    const total = Number(stats[0]?.total ?? 0);
    const average = Number(stats[0]?.average ?? 0);

    const updated = await db
      .update(stalls)
      .set({ reviewCount: total, avgRating: average.toFixed(2) })
      .where(eq(stalls.id, stallId))
      .output();

    return updated[0];
  }
}
src/services/reviewService.tstypescript
import { ReviewRepository } from '../repositories/reviewRepository.ts';
import { StallRepository } from '../repositories/stallRepository.ts';
import type { ReviewResponseDto } from '../dtos/reviewDto.ts';
import { AppError } from '../errors/AppError.ts';
import { NotFoundError } from '../errors/NotFoundError.ts';
import type { CreateReviewInput } from '../schemas/reviewSchema.ts';

type ReviewRow = NonNullable<Awaited<ReturnType<ReviewRepository['create']>>>;

export class ReviewService {
  private reviewRepository: ReviewRepository;
  private stallRepository: StallRepository;

  constructor(
    reviewRepository: ReviewRepository = new ReviewRepository(),
    stallRepository: StallRepository = new StallRepository(),
  ) {
    this.reviewRepository = reviewRepository;
    this.stallRepository = stallRepository;
  }

  private toDto(row: ReviewRow): ReviewResponseDto {
    return {
      id: row.id,
      stallId: row.stallId,
      userId: row.userId,
      rating: row.rating,
      comment: row.comment,
      likeCount: row.likeCount,
      createdAt: row.createdAt ? row.createdAt.toISOString() : null,
    };
  }

  /**
   * `userId` datang dari parameter, bukan dari body — pemanggilnya (controller)
   * mengambilnya dari `req.user`. Service tidak pernah mempercayai input client
   * untuk keputusan soal siapa yang sedang acting.
   */
  async createReview(
    stallId: number,
    userId: number,
    input: CreateReviewInput,
  ): Promise<ReviewResponseDto> {
    const stall = await this.stallRepository.findById(stallId);
    if (!stall) throw new NotFoundError('Warung tidak ditemukan');

    const existing = await this.reviewRepository.findByStallAndUser(stallId, userId);
    if (existing) {
      throw new AppError(409, 'Kamu sudah pernah memberi review untuk warung ini');
    }

    const row = await this.reviewRepository.create({
      stallId,
      userId,
      rating: input.rating,
      comment: input.comment ?? null,
    });
    if (!row) throw new AppError(500, 'Review gagal disimpan');

    // Rating warung ikut diperbarui supaya angka di STALLS tidak basi.
    await this.reviewRepository.refreshStallSummary(stallId);

    return this.toDto(row);
  }
}
src/controllers/reviewController.tstypescript
import type { Request, Response } from 'express';
import { ReviewService } from '../services/reviewService.ts';
import { getUser } from '../middlewares/auth.ts';
import { getValidated } from '../middlewares/validate.ts';
import type { IdParam } from '../schemas/stallSchema.ts';
import type { CreateReviewInput } from '../schemas/reviewSchema.ts';

/**
 * `req.user` dibaca lewat `getUser()` — bukan `req.user!.id`.
 * Kalau `authenticate` somehow tidak terpasang, yang muncul adalah 401 yang
 * jelas, bukan error `undefined` yang sulit ditelusuri.
 */
export class ReviewController {
  private reviewService: ReviewService;

  constructor(reviewService: ReviewService = new ReviewService()) {
    this.reviewService = reviewService;
  }

  createReview = async (req: Request, res: Response): Promise<void> => {
    const { id: stallId } = getValidated<IdParam>(res, 'params');
    const body = getValidated<CreateReviewInput>(res, 'body');

    // Identitas penulis SELALU dari token, tidak pernah dari body.
    const { id: userId } = getUser(req);

    const data = await this.reviewService.createReview(stallId, userId, body);
    res.status(201).json({ status: 'success', data });
  };
}

Laporan Admin

Endpoint khusus admin: ringkasan laporan. Hanya role admin yang boleh, sehingga middleware otorisasi dipasang di router.

src/dtos/reportDto.tstypescript
/** Angka-angka ringkas untuk halaman laporan admin. */
export interface ReportSummaryDto {
  totalUsers: number;
  totalStalls: number;
  totalReviews: number;
  averageRating: number;
  stallsByCategory: Array<{ category: string | null; total: number }>;
}
src/services/reportService.tstypescript
import { avg, count, sql } from 'drizzle-orm';
import { getDb } from '../db/index.ts';
import { reviews, stalls, users } from '../db/schema.ts';
import type { ReportSummaryDto } from '../dtos/reportDto.ts';

/**
 * Query agregat untuk laporan admin.
 *
 * Router dan service yang memanggil method ini sudah dijaga `authorize('admin')`
 * oleh middleware — cara paling aman tetap dengan membatasi di lapisan route,
 * bukan sekadar bertanya "siapa saja yang boleh memanggil file ini?".
 */
export class ReportService {
  async getSummary(): Promise<ReportSummaryDto> {
    const db = await getDb();

    // Empat agregat yang tidak saling bergantung, jadi dikirim bersamaan.
    // Cast ke DECIMAL dulu, sama alasannya seperti di ReviewRepository.
    const [userRows, stallRows, reviewRows, averageRows] = await Promise.all([
      db.select({ total: count() }).from(users),
      db.select({ total: count() }).from(stalls),
      db.select({ total: count() }).from(reviews),
      db
        .select({ average: avg(sql<number>`cast(${reviews.rating} as decimal(10, 4))`) })
        .from(reviews),
    ]);

    // Pengelompokan per kategori sekaligus menghitung jumlahnya.
    const byCategory = await db
      .select({ category: stalls.category, total: count() })
      .from(stalls)
      .groupBy(stalls.category)
      .orderBy(stalls.category);

    return {
      totalUsers: Number(userRows[0]?.total ?? 0),
      totalStalls: Number(stallRows[0]?.total ?? 0),
      totalReviews: Number(reviewRows[0]?.total ?? 0),
      averageRating: Number(averageRows[0]?.average ?? 0),
      stallsByCategory: byCategory.map((row) => ({
        category: row.category,
        total: Number(row.total),
      })),
    };
  }
}
src/controllers/adminController.tstypescript
import type { Request, Response } from 'express';
import { ReportService } from '../services/reportService.ts';
import { getUser } from '../middlewares/auth.ts';

export class AdminController {
  private reportService: ReportService;

  constructor(reportService: ReportService = new ReportService()) {
    this.reportService = reportService;
  }

  getReports = async (req: Request, res: Response): Promise<void> => {
    // Middleware sudah menjamin role admin, jadi di sini tidak perlu
    // pengecekan role lagi — cukup mencatat siapa yang meminta laporan.
    const { email } = getUser(req);
    const data = await this.reportService.getSummary();

    res.status(200).json({
      status: 'success',
      meta: { requestedBy: email },
      data,
    });
  };
}
src/routes/adminRouter.tstypescript
import { Router } from 'express';
import { AdminController } from '../controllers/adminController.ts';
import { authenticate } from '../middlewares/auth.ts';
import { authorize } from '../middlewares/authorize.ts';

const adminRouter = Router();
const adminController = new AdminController();

// authenticate (401) diletakkan lebih dahulu daripada authorize (403): tanpa
// token, server bahkan belum tahu rolenya siapa.
adminRouter.get('/reports', authenticate, authorize('admin'), adminController.getReports);

export { adminRouter };

Terakhir, dokumentasikan keamanan di Swagger: tambahkan security scheme dan respons 401/403.

src/docs/openapi.ts (bagian keamanan)typescript
// src/docs/openapi.ts (bagian keamanan)
registry.registerComponent('securitySchemes', 'bearerAuth', {
  type: 'http',
  scheme: 'bearer',
  bearerFormat: 'JWT',
  description: 'Token JWT dari endpoint POST /api/v1/auth/login.',
});

// Route terlindungi memakai daftar security ini.
const secured = [{ bearerAuth: [] }];

const unauthorizedResponse = {
  401: {
    description: 'Token tidak ada, kedaluwarsa, atau signature tidak cocok',
    content: { 'application/json': { schema: errorSchema } },
  },
};

const forbiddenResponse = {
  403: {
    description: 'Role dari token tidak punya izin untuk endpoint ini',
    content: { 'application/json': { schema: errorSchema } },
  },
};

registry.registerPath({
  method: 'get',
  path: '/api/v1/admin/reports',
  summary: 'Ringkasan laporan (khusus admin)',
  security: secured,
  responses: {
    200: {
      description: 'Ringkasan data',
      content: { 'application/json': { schema: reportSchema } },
    },
    ...unauthorizedResponse,
    ...forbiddenResponse,
  },
});

Uji 401 vs 403

Terminal: Jalankan server (development)
npm run dev

Login tiga role, lalu bandingkan hasilnya. Akun seed memakai password rahasia123.

admin@kantin.test    -> admin
tini@kantin.test     -> owner  (pemilik warung)
bagas@student.test   -> customer

Tanpa token, endpoint terlindungi membalas 401:

{
  "status": "fail",
  "message": "Header Authorization dengan token Bearer wajib dikirim"
}

Dengan token customer ke endpoint admin, jawabannya 403:

{
  "status": "fail",
  "message": "Endpoint ini hanya untuk role: admin"
}

Owner yang mencoba mengubah warung milik owner lain juga 403, tetapi pesannya soal kepemilikan:

{
  "status": "fail",
  "message": "Kamu hanya bisa mengubah warung milikmu sendiri"
}

Sedangkan GET /api/v1/auth/me hanya membutuhkan token yang valid:

{
  "status": "success",
  "data": { "id": 12, "name": "Bagas Pratama", "email": "bagas@student.test", "role": "customer" }
}

Rangkuman kasus uji:

KasusTokenHasil
Daftar warunganon200 route publik
/auth/me tanpa headertidak ada401
/admin/reportscustomer / owner403
POST /stallscustomer403
POST /stalls + ownerIdowner400 ditolak .strict()
PUT /stalls/3 milik orang lainowner403 kepemilikan
PUT /stalls/3admin200
DELETE /stalls/:idowner / admin403 / 200
Review warung yang sama dua kalicustomer201 lalu 409

Troubleshooting:

GejalaKemungkinan penyebab & solusi
Semua endpoint terlindungi balas 401Header harus persis Bearer <token>, huruf besar B dan satu spasi.
403 padahal seharusnya 200Urutan middleware terbalik. authorize harus dipasang setelah authenticate.
JWT_SECRET belum diset di .envIsi JWT_SECRET, lalu login ulang. Token lama dengan secret berbeda jadi tidak valid.
401 Email atau password salah padahal benarSeed Pertemuan 03 masih berisi hash_admin. Jalankan db/seed-password-hashes.sql.
Cannot read properties of undefined (reading 'role')Ada handler yang membaca req.user langsung. Pakai pembaca aman yang melempar 401 bila belum ada.

Penutup

Recap

User register, password di-hash, lalu login. Server membuat token JWT berisi identitas dan role. Token dikirim client di header Authorization: Bearer <token> setiap kali mengakses endpoint terlindungi.

LapisanTugasGagal berarti
auth middlewareMembuktikan identitas dari token401
role middlewareMemeriksa role dari req.user403
serviceMemeriksa kepemilikan objek403
controllerMenjalankan logika bisnis400 / 404

Lapisan-lapisan ini disambung dengan Dependency Injection: setiap komponen menerima kebutuhannya dari luar, sehingga mudah diuji dan diganti.

Intinya: autentikasi mengenal siapa kamu, otorisasi menentukan boleh apa, middleware yang menjaga pintunya.

Diagram
Diagram (perbesar)
100%