Registrasi hanya terjadi sekali seumur akun; login adalah pintu yang dipakai pengguna setiap hari. Di chapter ini kita bangun pintu itu secara manual dengan Auth facade: form login, pencocokan kredensial lewat Auth::attempt, opsi “ingat saya”, sampai ritual logout yang tidak boleh setengah jalan. Kita juga berkenalan dengan bentuk OOP-nya — Auth::login($user) — yang sudah kita pakai senyap saat registrasi kemarin. Seluruh alur ditutup dengan feature test yang membuktikan pintu ini benar-benar terkunci dari sisi yang salah.

Route dan Form Login #

Tiga route mengurus satu siklus: tampilkan form, proses login, dan logout:

php
use App\Http\Controllers\LoginController;

Route::get('/login', [LoginController::class, 'create'])->name('login');
Route::post('/login', [LoginController::class, 'store'])->name('login.store');
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
bash
php artisan route:list --path=log

Output yang Dihasilkan:

text
  GET|HEAD   login ............................ login › LoginController@create
  POST       login ....................... login.store › LoginController@store
  POST       logout ......................... logout › LoginController@destroy

                                                            Showing [3] routes

Form-nya disimpan di resources/views/auth/login.blade.php, polanya sama dengan form registrasi — @csrf, old(), @error — plus satu pendatang baru: checkbox remember:

blade
<form method="POST" action="{{ route('login.store') }}">
    @csrf

    <label for="email">Email</label>
    <input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus>

    @error('email')
        <p>{{ $message }}</p>
    @enderror

    <label for="password">Password</label>
    <input id="password" type="password" name="password" required>

    @error('password')
        <p>{{ $message }}</p>
    @enderror

    <label for="remember">
        <input id="remember" type="checkbox" name="remember"> Ingat saya
    </label>

    <button type="submit">Masuk</button>
</form>

Auth::attempt: Mencocokkan Kredensial #

Method store di LoginController menjalankan seluruh logika login:

php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    public function create()
    {
        return view('auth.login');
    }

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

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

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

        return back()->withErrors([
            'email' => 'Kredensial ini tidak cocok dengan data kami.',
        ])->onlyInput('email');
    }
}

Ada empat keputusan penting di sini:

  1. $credentials — array hasil validasi berisi email dan password. Auth::attempt mencari user dengan email tersebut lewat provider, lalu mencocokkan password-nya dengan hash di database memakai Hash::check.
  2. Parameter kedua remember — $request->boolean('remember') mengubah kehadiran checkbox menjadi true/false. Bila true, Laravel menaruh token di cookie tahan lama sehingga login bertahan setelah browser ditutup — mekanisme lengkapnya dibahas di chapter Remember Me.
  3. session()->regenerate() — ID session lama dibuang dan diganti baru setelah login sukses. Langkah ini memutus serangan session fixation: penyerang yang menanam ID session lama tidak lagi bisa mewarisi status login korban.
  4. redirect()->intended(...) — mengantar user ke halaman yang semula dituju sebelum diarahkan ke login, dengan route dashboard sebagai fallback.

Kegagalan login dijawab dengan back()->withErrors(['email' => ...])->onlyInput('email'): kembali ke form, pesan error menempel di field email, dan isian email diisi ulang — password sengaja tidak, karena password tidak boleh dipertontonkan balik.

Bentuk OOP: Auth::login dan Pemeriksaan Status #

Auth::attempt adalah jalur kredensial. Kerabatnya, Auth::login($user), menerima instance user secara langsung tanpa memeriksa kredensial apa pun — berguna tepat setelah registrasi atau setelah login sosial. Runner berikut membuktikan bentuk kerjanya:

php
use App\Models\User;
use Illuminate\Support\Facades\Auth;

$user = User::create([
    'name' => 'Siti',
    'email' => '[email protected]',
    'password' => 'rahasia-kuat',
]);

Auth::login($user);

echo 'login via instance : '.var_export(Auth::check(), true)."\n";
echo 'auth()->user()     : '.auth()->user()->email."\n";
echo 'id()               : '.Auth::id()."\n";

Output yang Dihasilkan:

text
login via instance : true
auth()->user()    : [email protected]
id()              : 1

Satu kali Auth::login($user), seluruh pemeriksaan status setelahnya sepakat: Auth::check() mengembalikan true, auth()->user() mengembalikan instance dengan email Siti, dan Auth::id() mengembalikan primary key 1. Perhatikan juga bahwa User::create di atas meng-assign password plaintext — cast hashed pada model yang mengubahnya menjadi hash sebelum menyimpan.

Logout dan Ritual Keamanannya #

Logout yang benar lebih dari sekadar membuang status login. Method destroy melakukan tiga langkah berurutan:

php
public function destroy(Request $request)
{
    Auth::logout();

    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return redirect()->route('login');
}

Auth::logout() menghapus data login dari session. Selanjutnya $request->session()->invalidate() menghapus seluruh data session — termasuk sisa old input dan flash message — sehingga tidak ada data lama yang bocor ke sesi berikutnya. Terakhir, $request->session()->regenerateToken() mengganti token CSRF lama dengan yang baru; token lama yang mungkin bocor otomatis tidak berlaku lagi. Ketiganya adalah satu paket ritual yang sebaiknya tidak dipangkas.

Satu perintah kerabat yang perlu kita ketahui: Auth::logoutOtherDevices($password) memaksa semua perangkat lain keluar dengan cara membatalkan semua remember token milik user — berguna saat user mencurigai akunnya dipakai pihak tak dikenal. Perintah ini meminta password diketik ulang sebagai konfirmasi.

Menguji Login dan Logout #

Empat feature test menutup seluruh siklus — form tampil, login sukses, login gagal, dan logout:

php
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class LoginTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_bisa_login_dengan_kredensial_benar(): void
    {
        $user = User::factory()->create([
            'name' => 'Budi',
            'email' => '[email protected]',
            'password' => 'rahasia-kuat',
        ]);

        $response = $this->post('/login', [
            'email' => '[email protected]',
            'password' => 'rahasia-kuat',
        ]);

        $response->assertRedirect('/dashboard');
        $this->assertAuthenticatedAs($user);
    }

    public function test_password_salah_ditolak(): void
    {
        User::factory()->create([
            'email' => '[email protected]',
            'password' => 'rahasia-kuat',
        ]);

        $response = $this->post('/login', [
            'email' => '[email protected]',
            'password' => 'password-salah',
        ]);

        $response->assertSessionHasErrors('email');
        $this->assertGuest();
    }

    public function test_user_yang_login_bisa_logout(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->post('/logout');

        $response->assertRedirect('/login');
        $this->assertGuest();
    }
}

Test kedua menegaskan perilaku yang penting: password salah menghasilkan redirect balik (bukan error 500, bukan halaman blank) dengan error menempel di session, dan status tetap assertGuest(). Hasil eksekusi lengkapnya:

bash
php artisan test tests/Feature/LoginTest.php

Output yang Dihasilkan:

text
  PASS  Tests\Feature\LoginTest
  ✓  halaman login bisa dikunjungi                                 0.14s
  ✓  user bisa login dengan kredensial benar                       0.03s
  ✓  password salah ditolak                                        0.21s
  ✓  user yang login bisa logout                                   0.01s

  Tests:    4 passed (12 assertions)
  Duration:  0.47s

Sepanjang chapter ini, satu elemen bekerja senyap di setiap pembandingan: Auth::attempt tidak pernah membandingkan plaintext dengan plaintext, melainkan dengan hash yang tersimpan. Bagaimana hash itu terbentuk, mengapa dua password sama menghasilkan hash berbeda, dan kapan hash perlu dibuat ulang adalah urusan chapter Password Hashing . Rujukan resmi login manual ada di dokumentasi Authentication .