Pada postingan kali ini, kita akan membuat sebuah project backend API mode keras. Project ini kita buat menggunakan framework Laravel 10. Langsung mulai ya!
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,
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
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
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,
];
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'),
],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>
Auth controller & OTP controller
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
});
});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;
}
}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);
}
);
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);
}
}
Lakukan pengujian penambahan User Admin menggunakan Terminal (CLI):
php artisan admin:create contoh.email@gmail.comKalau perintah tidak muncul di daftar php artisan atau ada error, coba jalankan perintah ini pada terminal:
php artisan optimize:clearcomposer dump-autoloadSekian 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
Posting Komentar