Langsung ke konten utama

Membuat Backend API Mode Keras - Part 1

Pada postingan kali ini, kita akan membuat sebuah project backend API mode keras. Project ini kita buat menggunakan framework Laravel 10. Langsung mulai ya!

  1. Persiapan & konfigurasi

    • .env (dev, SPA di Vite :5173)

      APP_URL=http://localhost
      FRONTEND_URL=http://localhost:5173

      # Sanctum (stateful SPA)
      SANCTUM_STATEFUL_DOMAINS=localhost:5173
      SESSION_DOMAIN=localhost
      SESSION_DRIVER=file
      SESSION_SECURE_COOKIE=false

      # Mail (pilih salah satu)
      MAIL_MAILER=log
      # atau SMTP (nanti untuk produksi)
      # MAIL_MAILER=smtp
      # MAIL_HOST=smtp.gmail.com
      # MAIL_PORT=587
      # MAIL_USERNAME=...
      # MAIL_PASSWORD=...
      # MAIL_ENCRYPTION=tls
      # MAIL_FROM_ADDRESS=no-reply@stipjakarta.ac.id
      # MAIL_FROM_NAME="STIP Jakarta"

      # CAPTCHA (pilih reCAPTCHA/hCaptcha)
      CAPTCHA_DRIVER=recaptcha
      CAPTCHA_SECRET=your_recaptcha_secret

      # Root admin whitelist
      ROOT_ADMIN_EMAILS="zulfahmilarazu@gmail.com,bsatuanak@gmail.com" 

    • CORS (config/cors.php)

      Pastikan origin Frontend (Contoh pakai Vue.js) diizinkan:

      'paths' => ['api/*', 'sanctum/csrf-cookie'],
      'allowed_origins' => ['http://localhost:5173'],
      'allowed_methods' => ['*'],
      'allowed_headers' => ['*'],
      'supports_credentials' => true,

  2. Migrasi

    • Tambah field is_admin pada table users

      Jalankan perintah ini pada terminal untuk generate file migrasi baru dengan nama add_is_admin_to_users:

      php artisan make:migration add_is_admin_to_users

      Lalu, pada file database/migrations/xxxx_add_is_admin_to_users.php  buat seperti ini:

      <?
      use Illuminate\Database\Migrations\Migration;
      use Illuminate\Database\Schema\Blueprint;
      use Illuminate\Support\Facades\Schema;

      return new class extends Migration {
        public function up(): void {
          Schema::table('users', function (Blueprint $table) {
            $table->boolean('is_admin')->default(false)->index();
          });
        }
        public function down(): void {
          Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('is_admin');
          });
        }
      };

    • Buat model dan migration untuk table login_otps

      Jalankan perintah ini pada terminal:

      php artisan make:model LoginOtp -m

      Lalu, pada database/migrations/xxxx_create_login_otps_table.php, buat seperti ini:

      <?
      // database/migrations/xxxx_create_login_otps_table.php
      use Illuminate\Database\Migrations\Migration;
      use Illuminate\Database\Schema\Blueprint;
      use Illuminate\Support\Facades\Schema;

      return new class extends Migration {
        public function up(): void {
          Schema::create('login_otps', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('challenge_id', 64)->unique();        // public id untuk sesi
            $table->string('code_hash', 64);                     // sha256
            $table->unsignedSmallInteger('attempts')->default(0);
            $table->unsignedSmallInteger('max_attempts')->default(5);
            $table->timestamp('expires_at');
            $table->timestamp('used_at')->nullable();
            $table->string('ip', 45)->nullable();
            $table->string('ua', 255)->nullable();
            $table->timestamps();
            $table->index(['user_id','expires_at']);
          });
        }
        public function down(): void {
          Schema::dropIfExists('login_otps');
        }
      };

      Jalankan perintah migrate:

      php artisan migrate

  3. Rate limiter (kerasin)

    Pada direktori file app/Providers/RouteServiceProvider.php:

    <?
    // app/Providers/RouteServiceProvider.php
    use Illuminate\Cache\RateLimiting\Limit;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\RateLimiter;

    protected function configureRateLimiting(): void
    {
      RateLimiter::for('admin-login', fn(Request $r) =>
        (app()->isLocal() ? Limit::perMinute(20) : Limit::perMinute(5))
          ->by($r->ip().'|'.$r->input('email'))
      );

      RateLimiter::for('admin-otp-verify', fn(Request $r) =>
        (app()->isLocal() ? Limit::perMinute(60) : Limit::perMinute(10))
          ->by($r->session()->get('otp_challenge_id') ?? $r->ip())
      );

      RateLimiter::for('admin-otp-resend', fn(Request $r) =>
        (app()->isLocal() ? Limit::perMinute(6) : Limit::perMinute(3))
          ->by($r->session()->get('otp_challenge_id') ?? $r->ip())
      );
    }

    php artisan optimize:clear

  4. Middleware

    • Only Admin

      <?
      // app/Http/Middleware/EnsureAdmin.php
      namespace App\Http\Middleware;
      use Closure; use Illuminate\Http\Request;
      class EnsureAdmin {
        public function handle(Request $r, Closure $next) {
          $u = $r->user();
          if (!$u || !$u->is_admin) return response()->json(['message'=>'Forbidden'], 403);
          return $next($r);
        }
      }

    • OTP enforced

      <?
      // app/Http/Middleware/EnsureOtpEnforced.php
      namespace App\Http\Middleware;
      use Closure; use Illuminate\Http\Request;

      class EnsureOtpEnforced {
        public function handle(Request $r, Closure $next) {
          $u = $r->user();
          if (!$u) return response()->json(['message'=>'Unauthenticated'], 401);

          // jalur admin wajib lolos OTP
          if (!$r->session()->get('otp_passed')) {
            return response()->json(['message'=>'OTP required','requires_otp'=>true], 428);
          }
          return $next($r);
        }
      }

    • Kernel alias

      <?
      // app/Http/Kernel.php
      protected $middlewareAliases = [
        // ...
        'admin.only' => \App\Http\Middleware\EnsureAdmin::class,
        'otp.enforced' => \App\Http\Middleware\EnsureOtpEnforced::class,
      ];

  5. Service: verifikasi CAPTCHA

    Buat service CaptchaVerifier.php:

    Karena tidak Laravel 10 belum punya ganarator make:service, maka jalankan perintah terminal berikut ini:

    mkdir app\Services -Force

    ni app\Services\CaptchaVerifier.php -ItemType File

    Selanjutnya, tuliskan baris kode di bawah ini pada app/Services/CaptchaVerifier.php:

    <?php

    namespace App\Services;

    use Illuminate\Support\Facades\Http;

    class CaptchaVerifier
    {
        public function verify(string $token, ?string $ip = null): bool
        {
            // Dev helper: token "bypass" selalu lolos di local
            if (app()->isLocal() && $token === 'bypass') {
                return true;
            }

            $driver = config('services.captcha.driver', env('CAPTCHA_DRIVER', 'recaptcha'));
            $secret = config('services.captcha.secret', env('CAPTCHA_SECRET'));

            $url = $driver === 'hcaptcha'
                ? 'https://hcaptcha.com/siteverify'
                : 'https://www.google.com/recaptcha/api/siteverify';

            $resp = Http::asForm()->post($url, array_filter([
                'secret'   => $secret,
                'response' => $token,
                'remoteip' => $ip,
            ]));

            $data = $resp->json();
            return (bool)($data['success'] ?? false);
            // NOTE: kalau pakai reCAPTCHA v3, bisa cek $data['score'] di sini.
        }
    }

    Tambahkan di config/services.php:

    'captcha' => [
      'driver' => env('CAPTCHA_DRIVER', 'recaptcha'),
      'secret' => env('CAPTCHA_SECRET'),
    ],

  6. Model & Mailable

    • Model OTP

      Tuliskan baris kode di bawah ini pada app/Models/LoginOtp.php:

      <?php

      namespace App\Models;
      use Illuminate\Database\Eloquent\Model;

      class LoginOtp extends Model {
        protected $fillable = [
          'user_id','challenge_id','code_hash','attempts','max_attempts',
          'expires_at','used_at','ip','ua'
        ];
        protected $casts = [
          'expires_at'=>'datetime',
          'used_at'=>'datetime',
        ];
      }

    • Email OTP

      Jalankan perintah ini pada terminal:

      php artisan make:mail AdminLoginOtpMail

      Lalu, pada app/Mail/AdminLoginOtpMail.php tuliskan baris kode berikut ini:

      <?php

      namespace App\Mail;
      use Illuminate\Bus\Queueable;
      use Illuminate\Mail\Mailable;
      use Illuminate\Queue\SerializesModels;

      class AdminLoginOtpMail extends Mailable
      {
        use Queueable, SerializesModels;
        public function __construct(public string $name, public string $code) {}
        public function build() {
          return $this->subject('Your STIP Admin Login Code')
            ->view('emails.admin_login_otp');
        }
      }

      (Opsional) Buat dan isi file blade: resources/views/emails/admin_login_otp.blade.php:

      <!doctype html>
      <html>
        <body style="font-family:Arial,Helvetica,sans-serif">
          <h2>STIP Admin Login Code</h2>
          <p>Hi {{ $name }},</p>
          <p>Use this one-time code to complete your admin sign-in:</p>
          <div style="font-size:28px;font-weight:bold;letter-spacing:4px">{{ $code }}</div>
          <p>This code expires in 5 minutes and can be used once.</p>
          <p>If you didn’t request this, you can ignore this email.</p>
        </body>
      </html>

  7. Auth controller & OTP controller

  8. Jalankan perintah ini pada terminal:

    php artisan make:controller Admin/AuthController

    Lalu, pada app/Http/Controllers/Admin/AuthController.php tulis baris kode berikut ini:

    <?

    namespace App\Http\Controllers\Admin;

    use App\Http\Controllers\Controller;
    use App\Mail\AdminLoginOtpMail;
    use App\Models\LoginOtp;
    use App\Models\User;
    use App\Services\CaptchaVerifier;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Auth;
    use Illuminate\Support\Facades\Hash;
    use Illuminate\Support\Facades\Mail;
    use Illuminate\Support\Str;
    use Illuminate\Validation\ValidationException;

    class AuthController extends Controller
    {
      public function login(Request $request, CaptchaVerifier $captcha)
      {
        $data = $request->validate([
          'email' => ['required','email'],
          'password' => ['required','string'],
          'captcha_token' => ['required','string'],
        ]);

        // throttle
        $this->ensureIsNotRateLimited('admin-login');

        // verify captcha
        if (!$captcha->verify($data['captcha_token'], $request->ip())) {
          throw ValidationException::withMessages(['captcha' => 'Captcha failed.']);
        }

        if (!Auth::attempt(['email'=>$data['email'],'password'=>$data['password']], true)) {
          throw ValidationException::withMessages(['email' => 'Credentials invalid.']);
        }

        $request->session()->regenerate();

        /** @var User $user */
        $user = $request->user();

        // hanya admin boleh masuk domain admin
        if (!$user->is_admin) {
          Auth::logout();
          $request->session()->invalidate();
          $request->session()->regenerateToken();
          return response()->json(['message'=>'Forbidden'], 403);
        }

        // reset flag OTP
        $request->session()->forget('otp_passed');

        // buat OTP challenge
        [$challengeId, $otpCode] = $this->makeOtpFor($user, $request);

        // kirim email OTP
        Mail::to($user->email)->send(new AdminLoginOtpMail($user->name ?? $user->email, $otpCode));

        // simpan challenge id di session
        $request->session()->put('otp_challenge_id', $challengeId);

        return response()->json([
          'message' => 'OTP sent to your email.',
          'requires_otp' => true,
        ], 428); // Precondition Required
      }

      public function logout(Request $request)
      {
        Auth::guard('web')->logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();
        return response()->json(['message' => 'Logged out']);
      }

      private function makeOtpFor(User $user, Request $request): array
      {
        // 6 digit, leading zeros allowed
        $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);

        $challengeId = Str::random(40);
        LoginOtp::where('user_id',$user->id)->whereNull('used_at')->delete(); // revoke active

        LoginOtp::create([
          'user_id' => $user->id,
          'challenge_id' => $challengeId,
          'code_hash' => hash('sha256', $code),
          'attempts' => 0,
          'max_attempts' => 5,
          'expires_at' => now()->addMinutes(5),
          'ip' => $request->ip(),
          'ua' => substr((string)$request->userAgent(), 0, 255),
        ]);

        return [$challengeId, $code];
      }

      private function ensureIsNotRateLimited(string $key)
      {
        // handled by RateLimiter definitions (optional no-op here)
      }
    }

    Jalankan perintah ini pada terminal:

    php artisan make:controller Admin/OtpController

    Lalu, pada app/Http/Controllers/Admin/OtpController.php tulis baris kode berikut ini:

    <?

    namespace App\Http\Controllers\Admin;

    use App\Http\Controllers\Controller;
    use App\Models\LoginOtp;
    use Illuminate\Http\Request;
    use Illuminate\Validation\ValidationException;

    class OtpController extends Controller
    {
      public function status(Request $request)
      {
        return response()->json([
          'otp_required' => !$request->session()->get('otp_passed', false),
        ]);
      }

      public function verify(Request $request)
      {
        $data = $request->validate([
          'code' => ['required','string','size:6','regex:/^\d{6}$/'],
        ]);

        $challengeId = $request->session()->get('otp_challenge_id');
        if (!$challengeId) {
          return response()->json(['message'=>'No active OTP challenge.'], 400);
        }

        /** @var LoginOtp|null $otp */
        $otp = LoginOtp::where('challenge_id', $challengeId)->first();
        if (!$otp) return response()->json(['message'=>'Invalid challenge.'], 400);

        if ($otp->used_at) {
          return response()->json(['message'=>'Code already used.'], 422);
        }
        if (now()->greaterThan($otp->expires_at)) {
          return response()->json(['message'=>'Code expired.'], 422);
        }
        if ($otp->attempts >= $otp->max_attempts) {
          return response()->json(['message'=>'Too many attempts.'], 429);
        }

        $otp->attempts++;
        $otp->save();

        $ok = hash_equals($otp->code_hash, hash('sha256', $data['code']));
        if (!$ok) {
          throw ValidationException::withMessages(['code' => 'Invalid code.']);
        }

        // mark used
        $otp->used_at = now();
        $otp->save();

        // grant access for this session
        $request->session()->put('otp_passed', true);

        return response()->json(['message' => 'OTP verified']);
      }

      public function resend(Request $request)
      {
        // (opsional) implementasi kirim ulang OTP dengan rate limit 'admin-otp-resend'
        return response()->json(['message'=>'Not implemented yet'], 501);
      }
    }

  9. Routes (endpoint)

    Buat endpoint pada file routes/api.php:

    <?

    use App\Http\Controllers\Admin\AuthController;
    use App\Http\Controllers\Admin\OtpController;
    use App\Http\Controllers\Admin\ArticleController;

    Route::prefix('admin')->group(function () {

      Route::post('/login', [AuthController::class,'login'])
        ->middleware(['web','throttle:admin-login']);

      // sesi terautentikasi (admin only) — boleh kelola OTP & logout
      Route::middleware(['web','auth:sanctum','admin.only'])->group(function () {
        Route::get('/otp/status', [OtpController::class,'status']);
        Route::post('/otp/verify', [OtpController::class,'verify'])->middleware('throttle:admin-otp-verify');
        Route::post('/logout', [AuthController::class,'logout']);
      });

      // API ADMIN sebenarnya — wajib admin + OTP enforced
      Route::middleware(['web','auth:sanctum','admin.only','otp.enforced'])->group(function () {
        Route::get('/article', [ArticleController::class,'index']);
        // … tambahkan endpoint admin lain
      });
    });

  10. CLI “admin:create” (admin hanya via terminal)

    Jalankan perintah di bawah ini pada terminal untuk membuat file Console Commands CreateAdmin:

    php artisan make:command CreateAdmin

    Selanjutnya, pada file app/Console/Commands/CreateAdmin.php, tuliskan baris kode berikut ini:

    <?

    namespace App\Console\Commands;
    use Illuminate\Console\Command;
    use App\Models\User;
    use Illuminate\Support\Facades\Hash;

    class CreateAdmin extends Command
    {
      protected $signature = 'admin:create {email} {--name=}';
      protected $description = 'Create an admin user (CLI only)';

      public function handle()
      {
        $email = strtolower(trim($this->argument('email')));
        $name  = $this->option('name') ?? $email;
        $pass  = $this->secret('Set password (min 12 chars)');
        if (strlen($pass) < 12) { $this->error('Too short.'); return 1; }

        $u = User::firstOrCreate(['email'=>$email], [
          'name'=>$name,
          'password'=>Hash::make($pass),
          'is_admin'=>true,
          'email_verified_at'=>now(),
        ]);
        $u->is_admin = true; $u->save();
        $this->info("Admin created: {$u->email}");
        return 0;
      }
    }

  11. Penyesuaian singkat Frontend

    • Interceptor axios:

      • 401/admin/login

      • 428 (requires_otp) → /admin/otp

    • Halaman OTP (/admin/otp) sederhana: input 6 digit → POST /api/admin/otp/verify → OK → redirect /admin.

    Contoh handler status di src/api/index.js:

    api.interceptors.response.use(
      r => r,
      err => {
        const s = err?.response?.status;
        const d = err?.response?.data || {};
        if (s === 401) router.push("/admin/login");
        else if (s === 428 || d.requires_otp) router.push("/admin/otp");
        return Promise.reject(err);
      }
    );

Lakukan pengujian penambahan User Admin menggunakan Terminal (CLI):

php artisan admin:create contoh.email@gmail.com

Kalau perintah tidak muncul di daftar php artisan atau ada error, coba jalankan perintah ini pada terminal:

php artisan optimize:clear
composer dump-autoload

Sekian dulu tulisan "Membuat Backend API Mode Keras - Part 1", ini belum selesai ya, nanti kita lanjut ke Part 2! Yang akan kita kerjakan pada Part 2 adalah Keamanan tambahan (opsional tapi kuat):

  • Step-up re-auth (password + OTP) sebelum aksi kritis (buat/hapus admin, ubah kredensial).
  • Activity log (Spatie) untuk audit: login gagal/berhasil, OTP gagal/berhasil, perubahan user/article.
  • Resend OTP: batasi 1–2x/menit, revoke OTP lama saat kirim baru.
  • Lockout sementara jika attempts > max (mis. block 10 menit).
  • Hanya root email yang boleh CRUD admin (kita implementasikan nanti saat masuk modul User Admin).
  • Disable /register publik total.

Komentar

Postingan populer dari blog ini

Instalasi Vue 3 menggunakan Vue CLI & Vite

Instalasi Vue 3 menggunakan Vue CLI Vue CLI adalah alat untuk membuat proyek Vue secara otomatis dengan konfigurasi yang sudah diatur sebelumnya. Ini adalah pilihan yang bagus jika kamu ingin memiliki kontrol lebih atas pengaturan proyek dan konfigurasi build. Langkah-langkah: Install Vue CLI secara global (jika belum terpasang) : Buka terminal dan jalankan perintah berikut untuk menginstal Vue CLI secara global di komputer kamu: npm install -g @vue/cli Buat proyek baru menggunakan Vue CLI : Setelah Vue CLI terpasang, kamu bisa membuat proyek Vue 3 baru dengan perintah berikut: // Pilih salah satu vue create nama-proyek npm create vue@latest nama-proyek // Rekomendasi karena sesuai Official Docs Gantilah nama-proyek dengan nama yang kamu inginkan untuk proyekmu. Pilih konfigurasi : Ketika perintah di atas dijalankan, Vue CLI akan menanyakan beberapa pilihan konfigurasi. Pilihlah opsi yang sesuai (misalnya, memilih preset default yang sudah menyertakan Babe...

Membuat code block/ syntax highlighter pada Blogger

Untuk membuat code block (blok kode) di Blogger, Anda dapat menggunakan tag HTML <pre><code> ... </code></pre> . Dengan ini, Anda dapat menampilkan kode dengan format yang sesuai dan mencegah interpretasi tag HTML di dalamnya. Langkah-langkah membuat code block/ syntax highlighter: Buka editor postingan Blogger Masuk ke Blogger, lalu pilih postingan yang ingin Anda edit atau buat postingan baru Beralih ke mode HTML Di editor postingan, klik tombol "HTML" (biasanya di sebelah kiri) untuk beralih ke tampilan HTML Tambahkan tag <pre><code>...</code></pre> Posisikan kursor di tempat di mana Anda ingin menampilkan blok kode. Kemudian, ketik atau tempelkan kode Anda di antara tag <pre><code> dan </pre> . Contoh: <pre><code> console.log("Hello, world!"); </code></pre> Sembunyikan tag <p> Jika Anda menambahkan blok kode di dalam paragraf, hilangkan tag <p...

Mengatasi Error: Module Apache pada XAMPP Control Panel tidak dapat digunakan

Module Apache XAMPP tidak bisa di Start?  Apakah kamu sedang mengalami kendala module Apache di XAMPP control panel tidak bisa digunakan? Mungkin saja, yang sedang kamu alami saat ini, sama dengan yang pernah saya alami. Gambar 1: Pesan error module Apache di XAMPP Control Panel Bila pesan error yang kamu dapatkan ketika klik Start module Apache sama seperti gambar diatas, penyebabnya adalah port yang digunakan oleh module Apache XAMPP telah digunakan oleh layanan / aplikasi lain di komputer kamu. Ya, mungkin hal seperti ini yang disebut dengan konflik port. Cari tahu penyebab konflik port Pertama-tama, cari tahu layanan / aplikasi apa yang sedang menggunakan port yang sama dengan yang digunakan oleh module Apache XAMPP. Untuk itu, buka command prompt (cmd), lalu ketikkan perintah dibawah ini: netstat -ano | find "80" Bagaimana mengatasinya? Setelah mengetahui penyebab konflik port tersebut, apa yang harus dilakukan untuk mengatasi masalah tersebut? Ada 2 cara untuk mengatasi...