Autentikasi manual berarti menulis sendiri controller register, login, dan logout tanpa starter kit, dengan mengandalkan fasilitas inti Laravel seperti facade Auth dan middleware auth. Topik ini membangun ketiganya pada aplikasi catatan beserta rute dan tampilannya, kemudian menguji seluruh alurnya menggunakan pengujian fitur. Setelah topik ini selesai, aplikasi catatan memiliki halaman daftar, halaman masuk, proses keluar, dan pembatasan akses yang berfungsi penuh.
Rute dan tampilan autentikasi#
Rute autentikasi dideklarasikan pada berkas routes/web.php dengan dua pengelompokan middleware. Halaman dan aksi register serta login dibungkus middleware guest sehingga hanya dijangkau pengguna yang belum login, sedangkan rute logout dan dashboard dibungkus middleware auth karena keduanya membutuhkan status login. Rute yang ditambahkan ke berkas tersebut berbentuk berikut.
// tidak-dijalankan
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\RegisterController;
Route::middleware('guest')->group(function () {
Route::get('register', [RegisterController::class, 'create'])->name('register');
Route::post('register', [RegisterController::class, 'store']);
Route::get('login', [LoginController::class, 'create'])->name('login');
Route::post('login', [LoginController::class, 'store']);
});
Route::post('logout', [LoginController::class, 'destroy'])
->middleware('auth')
->name('logout');
Route::view('dashboard', 'dashboard')
->middleware('auth')
->name('dashboard');Setiap pasangan halaman dan aksi menggunakan satu nama rute, yaitu register, login, logout, dan dashboard. Controller menggunakan nama rute untuk mengarahkan pengalihan, sehingga perubahan URI cukup dilakukan pada satu tempat. Tampilan halaman login disimpan pada resources/views/auth/login.blade.php dan berbentuk berikut.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<title>Masuk</title>
</head>
<body>
<h1>Masuk</h1>
<form method="POST" action="{{ url('login') }}">
@csrf
<label for="email">Email</label>
<input id="email" name="email" type="email" value="{{ old('email') }}" required>
<label for="password">Kata sandi</label>
<input id="password" name="password" type="password" required>
<button type="submit">Masuk</button>
</form>
<p>Belum punya akun? <a href="{{ route('register') }}">Daftar</a></p>
</body>
</html>Direktif @csrf menyisipkan token yang mencegah pemalsuan permintaan, sedangkan helper old('email') mengembalikan alamat email yang diketik pengguna apabila percobaan login sebelumnya gagal. Setelah controller dibuat, seluruh rute tersebut terdaftar dan dapat diperiksa dengan route:list. Keluarannya disaring ke baris rute autentikasi, dan deretan titik pemisahnya dirapatkan agar tampilannya pendek serta konsisten di lingkungan mana pun. Karena berkas rute dan controller contoh sengaja dibuat sementara pada lingkungan uji, perintah ini dicatat sebagai hasil eksekusi nyata dan tidak dijalankan ulang pada keadaan bersih.
# tidak-dijalankan
# menampilkan rute autentikasi yang sudah terdaftar
cd ~/aplikasi-catatan
COLUMNS=100 php artisan route:list --except-vendor | grep -E "register|login|logout|dashboard" | sed 's/[. ]\{2,\}/ /g'Hasil perintah tersebut pada lingkungan contoh, ketika berkas rute dan controller autentikasi masih terpasang, adalah sebagai berikut.
GET|HEAD dashboard dashboard
GET|HEAD login login › Auth\LoginController@create
POST login Auth\LoginController@store
POST logout logout › Auth\LoginController@destroy
GET|HEAD register register › Auth\RegisterController@create
POST register Auth\RegisterController@storeController register#
Controller register menangani dua aksi, yaitu menampilkan formulir daftar dan memproses pendaftarannya. Metode store() memvalidasi masukan, membuat pengguna baru, lalu langsung memasukkannya ke dalam session agar pengguna tidak perlu login ulang setelah mendaftar. Berkas app/Http/Controllers/Auth/RegisterController.php berisi kelas berikut.
// tidak-dijalankan
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
public function create(): View
{
return view('auth.register');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Password::min(8)],
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
Auth::login($user);
return redirect()->route('dashboard');
}
}Aturan confirmed menyamakan kolom password dengan kolom password_confirmation, sedangkan Password::min(8) menuntut panjang minimal delapan karakter. Kata sandi tidak pernah disimpan apa adanya karena Hash::make() mengubahnya menjadi hash, yaitu representasi satu arah yang tidak dapat dibalik menjadi teks aslinya. Metode Auth::login() menerima model pengguna yang telah lolos validasi, lalu menuliskan pengenalnya ke session tanpa memeriksa kredensial lagi.
Controller login dan logout#
Controller login menampilkan formulir, memproses percobaan masuk, dan mengakhiri session. Pemrosesan percobaan masuk dilakukan oleh Auth::attempt(), yaitu metode yang mengoperasikan guard web dan provider users yang sudah dibahas pada topik konsep autentikasi. Berkas app/Http/Controllers/Auth/LoginController.php berisi kelas berikut.
// tidak-dijalankan
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
public function create(): View
{
return view('auth.login');
}
public function store(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (Auth::attempt($credentials)) {
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
return back()
->withErrors(['email' => 'Kredensial yang diberikan tidak cocok dengan catatan kami.'])
->onlyInput('email');
}
public function destroy(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}Metode attempt() mencari pengguna berdasarkan email, membandingkan kata sandinya dengan hash di kolom password, lalu mengembalikan nilai true apabila keduanya cocok. Percobaan yang berhasil memanggil session()->regenerate() untuk mengganti pengenal session yang lama. Pergantian ini menutup celah session fixation, yaitu penyerangan yang memanfaatkan pengenal session yang sudah diketahui pihak lain. Metode intended() mengembalikan pengguna ke URL yang dituju sebelum dialihkan ke halaman login, atau ke dashboard apabila tidak ada URL tersimpan.
attempt() dapat diamati langsung tanpa HTTP. Perintah berikut menyiapkan pengguna contoh, mencoba kredensial yang benar, menampilkan identitas yang aktif, melakukan logout, lalu mencoba kredensial yang salah.
# menyiapkan pengguna contoh lalu mencoba Auth::attempt
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Auth;
User::updateOrCreate(
["email" => "[email protected]"],
["name" => "Dewi", "password" => Hash::make("kata-sandi-dewi")]
);
var_dump(Auth::attempt(["email" => "[email protected]", "password" => "kata-sandi-dewi"]));
echo Auth::user()->email . PHP_EOL;
Auth::logout();
var_dump(Auth::check());
var_dump(Auth::attempt(["email" => "[email protected]", "password" => "sandi-salah"]));
'bool(true)
[email protected]
bool(false)
bool(false)Keluaran tersebut memperlihatkan urutan yang sama dengan controller, yaitu attempt() bernilai true untuk kredensial yang benar, identitas pengguna tersedia melalui Auth::user(), dan check() bernilai false setelah logout. Percobaan terakhir bernilai false karena hash dari sandi-salah tidak cocok dengan hash yang tersimpan, dan pesan error pada controller berasal dari percobaan yang gagal seperti ini.
Membatasi halaman dengan middleware auth#
Middleware auth memeriksa status login sebelum sebuah rute dijalankan. Apabila permintaan datang tanpa status login, middleware mengalihkan permintaan ke rute bernama login, dan URL yang diminta tersimpan pada session agar intended() dapat mengembalikan pengguna ke URL tersebut setelah berhasil masuk. Pada aplikasi catatan, rute dashboard menggunakan Route::view() yang langsung merender tampilan tanpa controller, dan middleware auth dirantai pada rute tersebut.
Apabila halaman dashboard dibuka tanpa login, responsnya berupa pengalihan ke halaman login, bukan tampilan dashboard. Sebaliknya, middleware guest pada halaman login dan register mencegah pengguna yang sudah masuk melihat formulir yang tidak lagi relevan. Kedua middleware saling melengkapi sehingga setiap halaman menentukan sendiri siapa yang boleh melihatnya.
Menguji alur autentikasi#
Alur register, login, logout, dan pembatasan dashboard diuji dengan lima kasus pada satu kelas pengujian fitur. Pengujian menggunakan trait RefreshDatabase, yaitu trait yang menyiapkan ulang isi database sehingga setiap kasus berjalan pada keadaan yang bersih. Asersi seperti assertAuthenticated() dan assertGuest() memeriksa status session di sisi aplikasi. Berkas tests/Feature/AutentikasiManualTest.php berisi kelas berikut.
// tidak-dijalankan
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AutentikasiManualTest extends TestCase
{
use RefreshDatabase;
public function test_register_membuat_pengguna_dan_langsung_masuk(): void
{
$respons = $this->post('/register', [
'name' => 'Dewi',
'email' => '[email protected]',
'password' => 'sandi-aman-123',
'password_confirmation' => 'sandi-aman-123',
]);
$respons->assertRedirect(route('dashboard', absolute: false));
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
$this->assertAuthenticatedAs(User::where('email', '[email protected]')->first());
}
public function test_login_dengan_kredensial_salah_ditolak(): void
{
User::factory()->create(['email' => '[email protected]', 'password' => 'sandi-budi']);
$respons = $this->post('/login', [
'email' => '[email protected]',
'password' => 'sandi-salah',
]);
$respons->assertSessionHasErrors('email');
$this->assertGuest();
}
public function test_login_dengan_kredensial_benar_membuka_session(): void
{
User::factory()->create(['email' => '[email protected]', 'password' => 'sandi-citra']);
$respons = $this->post('/login', [
'email' => '[email protected]',
'password' => 'sandi-citra',
]);
$respons->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticated();
}
public function test_logout_mengakhiri_session(): void
{
$pengguna = User::factory()->create();
$respons = $this->actingAs($pengguna)->post('/logout');
$respons->assertRedirect(route('login', absolute: false));
$this->assertGuest();
}
public function test_dashboard_membutuhkan_login(): void
{
$respons = $this->get('/dashboard');
$respons->assertRedirect(route('login', absolute: false));
}
}Pengujian dijalankan dengan filter nama kelasnya, dan baris statusnya disaring agar keluaran yang ditampilkan tetap stabil. Berkas pengujian juga bersifat sementara pada lingkungan contoh, sehingga hasilnya dicatat dari eksekusi nyata ketika berkas itu masih terpasang.
# tidak-dijalankan
# menjalankan pengujian alur autentikasi
cd ~/aplikasi-catatan
php artisan test --filter=AutentikasiManualTest | grep -E "PASS|Tests:"Hasil eksekusinya ketika berkas pengujian masih terpasang adalah sebagai berikut.
PASS Tests\Feature\AutentikasiManualTest
Tests: 5 passed (17 assertions)Kelima kasus lulus, mulai dari register yang langsung memasukkan pengguna baru, penolakan kredensial yang salah, login yang membuka session, logout yang menutupnya, hingga pengalihan dashboard untuk pengunjung tanpa login.
Kesalahan umum#
Kesalahan pertama adalah melewatkan session()->regenerate() pada login yang berhasil. Pengenal session yang lama tetap digunakan setelah login berhasil, sehingga sesiapa yang mengetahui pengenal tersebut dari sebelum login dapat menyusup ke session milik pengguna. Perbaikannya adalah memanggil regenerate() tepat setelah attempt() bernilai true, sebagaimana dilakukan oleh controller di atas.
Kesalahan kedua adalah menyimpan kata sandi tanpa hash, misalnya memasukkannya melalui query builder yang tidak melewati cast model. attempt() selalu membandingkan dengan hash, sehingga login tidak akan pernah berhasil ketika kata sandi tersimpan polos di database. Simpan pengguna melalui model Eloquent, baik dengan Hash::make() secara eksplisit maupun dengan mengandalkan cast hashed pada model User.
Kesalahan ketiga adalah melupakan direktif @csrf di dalam formulir. Setiap pengiriman formulir POST tanpa token CSRF dijawab dengan error 419 Page Expired, dan formulir login mengikuti aturan yang sama. Tambahkan @csrf pada setiap formulir yang menggunakan middleware web.
Ringkasan#
- Register memvalidasi masukan, menyimpan pengguna baru dengan kata sandi ter-hash, lalu langsung memasukkannya melalui
Auth::login(). - Login menggunakan
Auth::attempt(), mengganti pengenal session denganregenerate(), dan mengarahkan pengguna ke tujuan semula melaluiintended(). - Logout mengakhiri session dengan
invalidate()danregenerateToken(), kemudian mengalihkan pengguna ke halaman login. - Middleware
authmelindungi halaman yang membutuhkan login dan mengalihkan pengunjung ke rutelogin, sedangkan middlewareguestmencegah pengguna yang sudah masuk mengakses halaman login dan register.
Referensi#
- Authentication — Laravel
— Dokumentasi resmi facade
Auth,attempt(), dan introspeksi pengguna yang sedang login. - Validation — Laravel — Dokumentasi aturan validasi yang dipakai pada controller register dan login.
- HTTP Tests — Laravel
— Dokumentasi pengujian fitur HTTP, termasuk
assertAuthenticated()danassertGuest().