Lập trình · 22/09/2026

Học Laravel 13 – Bài 20: Authentication và Starter Kit trong Laravel 13: đăng nhập TaskFlow

Bài 20 — Authentication và Starter Kit: TaskFlow có đăng nhập và đăng xuất bằng session, trang dashboard được bảo vệ và lệnh tạo tài khoản local. Đây là nền tảng xác thực để bài tiếp theo kiểm soát quyền trên từng project, không phải hệ thống quản lý tài khoản production hoàn chỉnh.

Authentication và Starter Kit trong Laravel 13: đăng nhập TaskFlow

Bài 20 — Authentication và Starter Kit: TaskFlow có đăng nhập và đăng xuất bằng session, trang dashboard được bảo vệ và lệnh tạo tài khoản local. Đây là nền tảng xác thực để bài tiếp theo kiểm soát quyền trên từng project, không phải hệ thống quản lý tài khoản production hoàn chỉnh.

1. Chọn starter kit hay tích hợp vào dự án có sẵn?

Với dự án mới, starter kit chính thức cung cấp điểm khởi đầu có giao diện và luồng xác thực, giúp tránh tự dựng mọi màn hình. Hãy chọn stack theo kỹ năng frontend và nhu cầu sản phẩm, rồi đọc code được sinh ra. Starter kit không tự quyết định ai được sửa project của ai.

TaskFlow đã có 19 bài với Blade và model riêng. Bài này tích hợp dịch vụ Auth có sẵn của Laravel vào dự án hiện tại, không chạy scaffolding mới ghi đè source. Nếu muốn khảo sát starter kit, tạo một thư mục dự án riêng bằng Laravel installer và so sánh routes, cấu hình và test. Không copy toàn bộ User model hoặc migrations đè lên quan hệ đã xây.

Giới hạn rõ ràng: phiên bản bài học chưa có đăng ký công khai, quên mật khẩu, xác minh email, MFA hay đăng xuất mọi thiết bị. Cần hoàn thiện những luồng phù hợp yêu cầu trước khi mở ứng dụng cho người dùng thật; không coi form login là toàn bộ bảo mật tài khoản.

2. Session guard và dữ liệu người dùng

TaskFlow dùng guard web, User model và password cast hashed đã có. Auth::attempt nhận mật khẩu người dùng nhập để framework kiểm tra hash; không hash mật khẩu đầu vào rồi so hai chuỗi hash. Sau thành công, regenerate session để không tiếp tục dùng session ID trước đăng nhập.

app/Http/Controllers/SessionController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;

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

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

        if (! Auth::attempt($credentials)) {
            throw ValidationException::withMessages(['email' => 'These credentials do not match our records.']);
        }

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

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

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

        return to_route('login');
    }
}

Thông báo sai thông tin chung cho cả email không tồn tại và mật khẩu sai, không tiết lộ trực tiếp tài khoản nào đã đăng ký. Không log credentials. Ví dụ không bật remember me; thêm checkbox sau này đòi hỏi hiểu token và chính sách phiên dài hạn.

Logout chỉ qua POST có CSRF. Controller logout guard web, invalidate session và tạo CSRF token mới. Điều này kết thúc phiên hiện tại, không tuyên bố thu hồi mọi session khác hoặc API token của người dùng.

3. Form và route bảo vệ

resources/views/auth/login.blade.php

<x-layout title="Sign in — TaskFlow">
    <h1>Sign in</h1>
    <form method="POST" action="{{ route('login.store') }}">
        @csrf
        <label for="email">Email</label>
        <input id="email" name="email" type="email" value="{{ old('email') }}" required autocomplete="username">
        @error('email') <p role="alert">{{ $message }}</p> @enderror
        <label for="password">Password</label>
        <input id="password" name="password" type="password" required autocomplete="current-password">
        @error('password') <p role="alert">{{ $message }}</p> @enderror
        <button type="submit">Sign in</button>
    </form>
</x-layout>

routes/web.php

<?php

use App\Http\Controllers\TaskFlowOverviewController;
use App\Http\Controllers\TaskPreviewController;
use App\Http\Controllers\SessionController;
use App\Http\Middleware\AssignRequestId;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/taskflow', TaskFlowOverviewController::class)
    ->middleware(AssignRequestId::class)
    ->name('taskflow.overview');

Route::get('/tasks/create', [TaskPreviewController::class, 'create'])->name('tasks.create');
Route::post('/tasks/preview', [TaskPreviewController::class, 'preview'])
    ->middleware('throttle:task-preview')->name('tasks.preview');

Route::middleware('guest')->group(function () {
    Route::get('/login', [SessionController::class, 'create'])->name('login');
    Route::post('/login', [SessionController::class, 'store'])
        ->middleware('throttle:login')->name('login.store');
});
Route::view('/dashboard', 'dashboard')->middleware('auth')->name('dashboard');
Route::post('/logout', [SessionController::class, 'destroy'])->middleware('auth')->name('logout');

Form chỉ phục hồi email bằng old(), không đổ lại password. Blade escape dữ liệu và @csrf cung cấp token. Route login dùng guest; dashboard và logout dùng auth. Dashboard hiện chào người dùng và có form POST logout, chưa liệt kê task riêng tư. Các route preview cũ vẫn công khai và không lưu dữ liệu.

Người chưa đăng nhập vào dashboard bằng trình duyệt được chuyển đến login; request JSON nhận 401. Sau login, intended redirect quay lại URL được middleware lưu hoặc dashboard. Đây là xác thực danh tính, chưa phải kiểm tra quyền tài nguyên: người dùng đăng nhập không được mặc định sửa mọi task.

4. Giới hạn thử đăng nhập

Thêm vào boot của AppServiceProvider, dùng các import RateLimiter, Limit và Request đã có từ bài 19:

RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
    ->by('login-ip:'.$request->ip()));

POST /login đã gắn throttle:login. Năm lần/phút theo IP là mức minh họa, đếm cả request thất bại validation. IP chung có thể gây ảnh hưởng nhiều người, còn tấn công phân tán có thể dùng nhiều IP. Production cần đánh giá quota theo tài khoản/IP, trusted proxy, giám sát và cơ chế phục hồi; không tuyên bố limiter này chặn mọi brute force.

5. Tạo tài khoản thực hành không có mật khẩu mặc định

app/Console/Commands/CreateLocalUser.php

<?php

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;

class CreateLocalUser extends Command
{
    protected $signature = 'taskflow:create-local-user';

    protected $description = 'Create a development user using an interactive hidden password prompt';

    public function handle(): int
    {
        if (! app()->environment(['local', 'testing'])) {
            $this->error('This command is only available in local/testing environments.');

            return self::FAILURE;
        }

        $input = [
            'name' => $this->ask('Name'),
            'email' => $this->ask('Email'),
            'password' => $this->secret('Password (12-72 characters)'),
        ];
        $validator = Validator::make($input, [
            'name' => ['required', 'string', 'max:120'],
            'email' => ['required', 'email', 'max:255', 'unique:users,email'],
            'password' => ['required', 'string', 'min:12', 'max:72'],
        ]);
        if ($validator->fails()) {
            foreach ($validator->errors()->all() as $message) {
                $this->error($message);
            }

            return self::FAILURE;
        }

        User::create($validator->validated());
        $this->info('Local user created. Sign in at /login.');

        return self::SUCCESS;
    }
}
php artisan migrate
php artisan taskflow:create-local-user
npm run build
php artisan serve

Chỉ chạy trên database phát triển đã cấu hình đúng, rồi mở /login. Lệnh hỏi password bằng prompt ẩn, không truyền qua argument shell và không in password ra output. User cast lưu hash. Tài khoản trùng email bị từ chối, không tự đổi mật khẩu tài khoản cũ. Lệnh chỉ cho local/testing và không phải quy trình cấp tài khoản production.

Seeder demo trước đó dùng mật khẩu ngẫu nhiên nên không cung cấp thông tin đăng nhập dùng chung. Tạo một user riêng bằng lệnh này; bài policy tiếp theo sẽ tạo project theo owner hiện tại. Không đổi mật khẩu demo thành một chuỗi công khai để tiện hướng dẫn.

6. Kiểm thử thực tế và phần chưa chứng minh

tests/Feature/SessionAuthenticationTest.php

<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class SessionAuthenticationTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        config(['cache.default' => 'array', 'session.driver' => 'array',
            'database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
        $this->withoutVite();
    }

    public function test_guest_redirect_and_json_unauthenticated_response(): void
    {
        $this->get('/dashboard')->assertRedirect(route('login'));
        $this->getJson('/dashboard')->assertUnauthorized();
        $this->get('/login')->assertOk()->assertSee('autocomplete="current-password"', false);
    }

    public function test_login_rotates_session_and_logout_invalidates_it(): void
    {
        $user = User::factory()->create(['password' => 'test-password-only']);
        $this->get('/login');
        $oldSession = session()->getId();
        $this->post('/login', ['email' => $user->email, 'password' => 'test-password-only'])
            ->assertRedirect(route('dashboard'));
        $this->assertAuthenticatedAs($user);
        $this->assertNotSame($oldSession, session()->getId());
        $this->get('/dashboard')->assertOk()->assertSeeText($user->name);
        $token = session()->token();
        $this->withSession(['private_marker' => 'remove-on-logout'])->post('/logout')
            ->assertRedirect(route('login'))->assertSessionMissing('private_marker');
        $this->assertGuest();
        $this->assertNotSame($token, session()->token());
    }

    public function test_wrong_credentials_do_not_authenticate_or_flash_password(): void
    {
        $user = User::factory()->create();
        $this->from('/login')->post('/login', ['email' => $user->email, 'password' => 'wrong-secret'])
            ->assertRedirect('/login')->assertSessionHasErrors('email')
            ->assertSessionMissing('_old_input.password');
        $this->assertGuest();
    }

    public function test_login_has_a_real_ip_rate_limit(): void
    {
        for ($i = 0; $i < 5; $i++) {
            $this->postJson('/login', ['email' => 'missing@example.test', 'password' => 'wrong'])
                ->assertUnprocessable();
        }
        $this->postJson('/login', ['email' => 'missing@example.test', 'password' => 'wrong'])
            ->assertStatus(429)->assertHeader('Retry-After');
        $this->assertGuest();
    }

    public function test_local_user_command_hashes_password(): void
    {
        $this->artisan('taskflow:create-local-user')
            ->expectsQuestion('Name', 'Course User')
            ->expectsQuestion('Email', 'course@example.test')
            ->expectsQuestion('Password (12-72 characters)', 'local-test-password')
            ->expectsOutput('Local user created. Sign in at /login.')
            ->assertSuccessful();
        $user = User::where('email', 'course@example.test')->sole();
        $this->assertTrue(\Illuminate\Support\Facades\Hash::check('local-test-password', $user->password));
    }

    public function test_local_user_command_refuses_production(): void
    {
        $this->app->instance('env', 'production');
        $this->artisan('taskflow:create-local-user')
            ->expectsOutput('This command is only available in local/testing environments.')
            ->assertFailed();
        $this->assertDatabaseCount('users', 0);
    }
}
php artisan test --filter=SessionAuthenticationTest
php artisan test

Bộ test pass 57 test, 229 assertions: guest redirect/401, login đổi session ID, logout xóa dữ liệu phiên và đổi token, sai mật khẩu không xác thực/không flash password, rate limit 429, tạo user có hash và từ chối lệnh ở production. Database dùng SQLite bộ nhớ; test không đổi tài khoản thật.

HTTP test Laravel mặc định bỏ kiểm tra CSRF, nên @csrf và test login thành công không tự chứng minh chặn request giả mạo. Bài session/CSRF sẽ kiểm tra riêng. Test cũng chưa kiểm chứng cookie qua HTTPS trên reverse proxy, email recovery hoặc MFA. Trước deploy cần HTTPS, secure cookie, cấu hình session phù hợp và kiểm tra cấu hình thực tế.

Bài tập: thử GET /logout, đăng nhập đúng sau khi quota hết, và thêm test người đã đăng nhập truy cập /login. Tham khảo AuthenticationStarter Kits.

Điều hướng: Bài 19 · Lộ trình. Tiếp theo: Gate và Policy.

Điều hướng khóa học Laravel 13

Bài trước (19) · Bài sau (21) · Mục lục trọn bộ 37 bài

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.