Pertemuan 05: Sistem Autentikasi, Registrasi Anggota, dan Hak Akses Pengguna (Spatie RBAC)

1. Pengantar: Membedakan Siapa yang Masuk dan Apa yang Boleh Dilakukan

Deskripsi Singkat:
Dalam sistem informasi perpustakaan, kita memiliki berbagai jenis pengguna dengan kepentingan berbeda: pengunjung umum, anggota terdaftar (mahasiswa/dosen), petugas perpustakaan (pustakawan), dan administrator sistem. Untuk mengelolanya, kita membedakan dua konsep fundamental:

  • Autentikasi (Authentication): Menjawab pertanyaan "Siapakah Anda?" (proses verifikasi identitas lewat email dan password).

  • Otorisasi (Authorization): Menjawab pertanyaan "Apa saja yang boleh Anda lakukan?" (hak akses berbasis peran / Role-Based Access Control).


2. Keamanan Autentikasi: Password Hashing & Proteksi CSRF

Deskripsi Singkat:
Laravel menyediakan standar keamanan tinggi secara otomatis:

A. Password Hashing (Bcrypt)

Password tidak boleh disimpan dalam bentuk teks biasa (plain text). Di Laravel, password di-hash secara satu arah menggunakan algoritma Bcrypt dengan salt acak melalui Hash::make($password). Hash satu arah artinya password tidak dapat di-dekripsi kembali; verifikasi login dilakukan dengan mencocokkan hash input dengan hash di database menggunakan Hash::check().

B. Proteksi Cross-Site Request Forgery (CSRF)

Setiap kali kita membuat form HTML dengan method POST, PUT, atau DELETE, kita wajib menyematkan direktif @csrf di dalam tag form. Direktif ini menyisipkan token acak rahasia. Saat form dikirim, middleware VerifyCsrfToken di Laravel akan memvalidasi token tersebut untuk memastikan request benar-benar berasal dari website kita sendiri, bukan dari website jahat pihak ketiga.


3. Menyiapkan Paket Spatie Laravel-Permission

Deskripsi Singkat:
Untuk mengelola role dan izin akses secara dinamis dan aman, kita memanfaatkan paket terpercaya spatie/laravel-permission.

A. Jalankan Migrasi Spatie:

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate

Perintah ini akan membuat tabel sistem: roles, permissions, model_has_roles, model_has_permissions, dan role_has_permissions.

B. Menambahkan Trait HasRoles pada Model User (app/Models/User.php):

Buka file app/Models/User.php dan pastikan menggunakan trait HasRoles:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasFactory, Notifiable, HasRoles;

    protected $fillable = [
        'name',
        'email',
        'password',
        'phone',
        'address',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

4. Seeder Role dan Hak Akses (database/seeders/RoleAndPermissionSeeder.php)

Deskripsi Singkat:
Kita membuat tiga role utama: admin, librarian (pustakawan), dan user (anggota), lengkap dengan izin akses spesifik masing-masing.

Salin kode berikut ke dalam file database/seeders/RoleAndPermissionSeeder.php:

<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;

class RoleAndPermissionSeeder extends Seeder
{
    public function run(): void
    {
        // Reset cache permission Spatie
        app()[PermissionRegistrar::class]->forgetCachedPermissions();

        // 1. Definisikan daftar izin akses operasional perpustakaan
        $permissions = [
            'dashboard.view',
            'dashboard-user.view',
            'books.view',
            'books.manage',
            'ebooks.view',
            'ebooks.read',
            'ebooks.manage',
            'loans.view-own',
            'loans.manage',
            'inventory.stock-opname',
            'memberships.manage',
            'reports.view',
            'settings.manage',
            'roles.manage',
        ];

        foreach ($permissions as $permissionName) {
            Permission::firstOrCreate(['name' => $permissionName, 'guard_name' => 'web']);
        }

        // 2. Buat Role dan kaitkan permission
        $adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
        $adminRole->syncPermissions(Permission::all());

        $librarianRole = Role::firstOrCreate(['name' => 'librarian', 'guard_name' => 'web']);
        $librarianRole->syncPermissions([
            'dashboard.view',
            'books.view',
            'books.manage',
            'ebooks.view',
            'ebooks.read',
            'ebooks.manage',
            'loans.manage',
            'inventory.stock-opname',
            'memberships.manage',
            'reports.view',
        ]);

        $userRole = Role::firstOrCreate(['name' => 'user', 'guard_name' => 'web']);
        $userRole->syncPermissions([
            'dashboard-user.view',
            'books.view',
            'ebooks.view',
            'ebooks.read',
            'loans.view-own',
        ]);
    }
}

Jalankan seeder dengan perintah:

php artisan db:seed --class=RoleAndPermissionSeeder

5. Rute Autentikasi dan Dashboard pada routes/web.php

Deskripsi Singkat:
Buka file routes/web.php dan pastikan rute autentikasi publik dan rute dashboard terproteksi didaftarkan:

use App\Http\Controllers\AuthController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\RoleController;

// Rute Publik untuk Login & Register
Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
Route::post('/login', [AuthController::class, 'login']);
Route::get('/register', [AuthController::class, 'showRegister'])->name('register');
Route::post('/register', [AuthController::class, 'register']);

// Rute Terproteksi Sesi Login
Route::middleware(['auth'])->group(function () {
    Route::post('/logout', [AuthController::class, 'logout'])->name('logout');

    // Dashboard Pengelola / Pustakawan
    Route::get('/dashboard', [DashboardController::class, 'index'])
        ->middleware('permission:dashboard.view')
        ->name('dashboard');

    // Dashboard Anggota
    Route::get('/dashboard-user', [DashboardController::class, 'userDashboard'])
        ->middleware('permission:dashboard-user.view')
        ->name('dashboard-user');

    // Matriks Hak Akses & Role (Khusus Administrator)
    Route::get('/roles', [RoleController::class, 'index'])
        ->middleware('permission:roles.manage')
        ->name('roles.manage');

    Route::match(['put', 'patch'], '/roles/{role}', [RoleController::class, 'update'])
        ->middleware('permission:roles.manage')
        ->name('roles.update');
});

6. Controller Autentikasi (app/Http/Controllers/AuthController.php)

Deskripsi Singkat:
Controller ini menangani logika form login, proses pengecekan kredensial email & password, pendaftaran akun anggota baru, pengalihan dashboard berbasis role, dan proses logout aman.

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;

class AuthController extends Controller
{
    public function showLogin(): View
    {
        return view('auth.login');
    }

    public function login(Request $request): RedirectResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required', 'string'],
        ]);

        if (Auth::attempt($credentials, $request->boolean('remember'))) {
            $request->session()->regenerate();

            /** @var User $user */
            $user = Auth::user();
            if ($user->hasRole('admin') || $user->hasRole('librarian')) {
                return redirect()->intended(route('dashboard'));
            }

            return redirect()->intended(route('dashboard-user'));
        }

        return back()->withErrors([
            'email' => 'Email atau password yang Anda masukkan salah.',
        ])->onlyInput('email');
    }

    public function showRegister(): View
    {
        return view('auth.register');
    }

    public function register(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
            'phone' => ['nullable', 'string', 'max:20'],
            'address' => ['nullable', 'string'],
        ]);

        $user = User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
            'phone' => $validated['phone'] ?? null,
            'address' => $validated['address'] ?? null,
        ]);

        // Otomatis berikan role user (anggota) kepada pendaftar baru
        $user->assignRole('user');

        Auth::login($user);
        $request->session()->regenerate();

        return redirect()->route('dashboard-user')->with('success', 'Pendaftaran akun berhasil!');
    }

    public function logout(Request $request): RedirectResponse
    {
        Auth::logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return redirect()->route('login')->with('success', 'Anda telah berhasil keluar.');
    }
}

7. Controller Matriks Role & Izin Akses (app/Http/Controllers/RoleController.php)

Deskripsi Singkat:
Controller ini memungkinkan administrator sistem mengatur hak akses izin (permission) untuk setiap peran (role) secara visual melalui form centang matriks.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;

class RoleController extends Controller
{
    public function index(): View
    {
        $roles = Role::with('permissions')->get();
        $permissions = Permission::all();

        $groupedPermissions = [
            'Halaman Utama & Dashboard' => [
                'dashboard.view' => 'Dashboard Operasional Perpustakaan',
                'dashboard-user.view' => 'Dashboard Anggota',
            ],
            'Koleksi Buku Fisik' => [
                'books.view' => 'Melihat Katalog & Stok Buku',
                'books.manage' => 'Kelola Data Buku Fisik (Tambah, Ubah, Hapus)',
            ],
            'Koleksi Buku Digital (E-Book)' => [
                'ebooks.view' => 'Melihat Koleksi E-Book',
                'ebooks.read' => 'Membaca Buku Digital di Browser',
                'ebooks.manage' => 'Unggah & Kelola E-Book',
            ],
            'Layanan Peminjaman & Sirkulasi' => [
                'loans.view-own' => 'Melihat Riwayat Pinjaman Pribadi',
                'loans.manage' => 'Proses Pinjam, Pengembalian, & Denda',
            ],
            'Pemeriksaan Fisik & Pengaturan' => [
                'inventory.stock-opname' => 'Pemeriksaan Fisik Buku (Hitung Stok)',
                'memberships.manage' => 'Kelola Jenis & Status Anggota',
                'reports.view' => 'Melihat Laporan Perpustakaan',
                'settings.manage' => 'Pengaturan Informasi Perpustakaan',
                'roles.manage' => 'Pengaturan Hak Akses Pengguna',
            ],
        ];

        return view('roles.index', compact('roles', 'permissions', 'groupedPermissions'));
    }

    public function update(Request $request, Role $role): RedirectResponse
    {
        $permissions = $request->input('permissions', []);
        $role->syncPermissions($permissions);

        app()[PermissionRegistrar::class]->forgetCachedPermissions();

        return redirect()->route('roles.manage')->with('success', "Hak akses role '{$role->name}' berhasil diperbarui.");
    }
}

8. Controller Dashboard Berbasis Peran (app/Http/Controllers/DashboardController.php)

Deskripsi Singkat:
Controller ini menyajikan metrik statistik untuk pengelola perpustakaan (pustakawan & admin) dan ringkasan aktivitas peminjaman untuk anggota umum:

<?php

namespace App\Http\Controllers;

use App\Models\Book;
use App\Models\Ebook;
use App\Models\Loan;
use App\Models\SiteSetting;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;

class DashboardController extends Controller
{
    /**
     * Dashboard Utama Operasional Perpustakaan (Admin & Pustakawan).
     */
    public function index(): View
    {
        $hasBooks = Schema::hasTable('books');
        $hasLoans = Schema::hasTable('loans');
        $hasEbooks = Schema::hasTable('ebooks');
        $hasSettings = Schema::hasTable('site_settings');

        $totalBooks = $hasBooks ? Book::count() : 0;
        $availableStock = 0;
        if ($hasBooks) {
            $availableStock = Schema::hasColumn('books', 'available_stock')
                ? (int) Book::sum('available_stock')
                : (int) Book::sum('stock');
        }

        $activeLoansCount = $hasLoans ? Loan::where('status', 'BORROWED')->count() : 0;
        $overdueLoansCount = $hasLoans
            ? Loan::where('status', 'BORROWED')->where('due_date', '<', Carbon::today())->count()
            : 0;
        $totalEbooks = $hasEbooks ? Ebook::where('status', 'ACTIVE')->count() : 0;
        $unpaidFines = $hasLoans ? (float) Loan::where('fine_status', 'UNPAID')->sum('fine_amount') : 0.0;
        $recentLoans = $hasLoans ? Loan::with(['user', 'book'])->latest()->take(6)->get() : collect();
        $finePerDay = ($hasSettings && class_exists(SiteSetting::class))
            ? (SiteSetting::first()?->fine_per_day ?? 1000)
            : 1000;

        return view('dashboard.index', compact(
            'totalBooks',
            'availableStock',
            'activeLoansCount',
            'overdueLoansCount',
            'totalEbooks',
            'unpaidFines',
            'recentLoans',
            'finePerDay'
        ));
    }

    /**
     * Dashboard Khusus Anggota / Mahasiswa.
     */
    public function userDashboard(): View
    {
        $user = Auth::user();
        $hasLoans = Schema::hasTable('loans');
        $hasEbooks = Schema::hasTable('ebooks');

        $activeLoans = $hasLoans
            ? Loan::with('book')->where('user_id', $user->id)->where('status', 'BORROWED')->latest()->get()
            : collect();

        $loanHistory = $hasLoans
            ? Loan::with('book')->where('user_id', $user->id)->where('status', '!=', 'BORROWED')->latest()->take(5)->get()
            : collect();

        $unpaidFines = $hasLoans
            ? (float) Loan::where('user_id', $user->id)->where('fine_status', 'UNPAID')->sum('fine_amount')
            : 0.0;

        $ebooks = $hasEbooks
            ? Ebook::with('category')->where('status', 'ACTIVE')->latest()->take(4)->get()
            : collect();

        return view('dashboard.user', compact(
            'user',
            'activeLoans',
            'loanHistory',
            'unpaidFines',
            'ebooks'
        ));
    }
}

9. Pengujian Otomatis

A. Pengujian Backend (Pest)

Verifikasi logika autentikasi, registrasi, pembatasan akses, dan password hashing:

php artisan test --filter=AuthTest

B. Pengujian End-to-End (Playwright)

Validasi interaksi pengguna di peramban (registrasi anggota baru, login admin, proteksi route 403 untuk anggota):

npx playwright test tests/e2e/pertemuan-05.spec.ts

10. Catatan Penting untuk Evaluasi Akhir dan Tanya Jawab Teknis

  1. Pertanyaan: Apa perbedaan mendasar antara Authentication dan Authorization?

    • Jawaban Ideal: Authentication adalah proses membuktikan identitas pengguna ("Siapakah Anda?", diverifikasi via email & password), sedangkan Authorization adalah proses menentukan hak dan batas kewenangan tindakan yang boleh dilakukan oleh pengguna tersebut setelah berhasil masuk ("Apa saja yang boleh Anda akses?", dikelola via Spatie RBAC Role & Permission).

  2. Pertanyaan: Mengapa kita tidak boleh menyimpan password dalam bentuk MD5 atau teks biasa?

    • Jawaban Ideal: MD5 rentan terhadap serangan kamus (dictionary attack) dan rainbow table karena tidak memiliki salt acak dan komputasinya terlalu cepat. Laravel menggunakan algoritma Bcrypt (Hash::make()) yang menerapkan work factor (pemberat komputasi) dan salt unik sehingga sangat aman terhadap brute force.

  3. Pertanyaan: Bagaimana cara Spatie Permission mencegah query database berulang setiap kali route diakses?

    • Jawaban Ideal: Spatie menyimpan seluruh peta role dan permission di dalam cache memori aplikasi (PermissionRegistrar). Ketika ada perubahan hak akses (seperti di RoleController@update), kita memanggil app()[PermissionRegistrar::class]->forgetCachedPermissions() untuk menyinkronkan kembali cache secara instan.