Lesson 20 — Authentication and Starter Kits: TaskFlow gains session login/logout, a protected dashboard and an interactive local-user command. This establishes identity for the next lesson's project authorization; it is not a complete production account-management system.
1. Starter kit or integration into an existing project?
For a new application, official starter kits provide authentication UI and flows. Select a frontend stack that fits your team and inspect the generated code. A starter kit does not decide who may edit another person's project.
TaskFlow already has nineteen lessons of Blade templates and domain models. We integrate Laravel's existing Auth services without overwriting that work with fresh scaffolding. Explore a starter kit in a separate directory through the Laravel installer, then compare routes, configuration and tests. Do not overwrite the existing User relationships or migrations wholesale.
This teaching milestone has no public registration, password recovery, email verification, MFA or all-device logout. Implement the account lifecycle required by your product before exposing it to real users. A login form is not the entirety of account security.
2. Session guard and user credentials
TaskFlow uses the web guard, existing User model and hashed password cast. Auth::attempt receives the submitted password for framework verification; do not hash it yourself and compare two hash strings. Successful authentication regenerates the session instead of retaining its pre-login identifier.
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');
}
}The same credential error covers unknown email and wrong password without directly revealing which account exists. Never log credentials. This example does not enable remember-me authentication; persistent login requires a deliberate token and session policy.
Logout is a CSRF-protected POST. The controller logs out the web guard, invalidates the current session and regenerates its CSRF token. It does not claim to revoke other sessions or API tokens.
3. Login form and protected routes
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');The form restores only email, never password. Blade escapes output and @csrf supplies the token. Login uses guest middleware; dashboard and logout use auth. The dashboard currently greets the user and provides a POST logout form without exposing project data. Existing public preview routes remain non-persistent.
Unauthenticated browser visits redirect to login, while JSON requests receive 401. After login, intended redirects to the middleware-recorded destination or the dashboard. Identity is not resource authorization: being signed in does not grant access to every task.
4. Throttle login attempts
Add this to AppServiceProvider's boot method using the RateLimiter, Limit and Request imports from lesson 19:
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
->by('login-ip:'.$request->ip()));
The login POST attaches throttle:login. Five requests per minute per IP is a teaching value and includes invalid submissions. Shared IPs can affect multiple people; distributed attackers can use multiple addresses. Evaluate account/IP quotas, trusted proxies, monitoring and recovery rather than claiming this limiter stops every brute-force attack.
5. Provision a development account without default credentials
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
Run against a correctly configured development database and open /login. Password input uses a hidden prompt rather than a shell argument or printed output. The User cast stores a hash. Duplicate email is rejected instead of silently resetting an existing account. The command allows only local/testing and is not production account provisioning.
The earlier demo seeder uses a random password, not shared login credentials. Create your own user with this command; the policy lesson will create projects owned by the current user. Do not replace the demo password with a publicly documented constant.
6. Tests and remaining verification
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
The suite passed 57 tests with 229 assertions: guest redirect/401, session rotation, logout invalidation and token rotation, failed credentials without password flashing, HTTP 429, password hashing and production command refusal. Tests use in-memory SQLite without changing real accounts.
Laravel HTTP tests normally bypass CSRF checks, so successful login tests and @csrf markup do not prove forged requests are rejected. The session/CSRF lesson covers that separately. HTTPS proxy cookies, account recovery and MFA are not verified here. Deployment still requires HTTPS, appropriate secure-cookie/session settings and checks against the actual runtime configuration.
Exercises: request GET /logout, log in after quota expiry, and test authenticated visits to /login. References: Authentication and Starter Kits.
Navigation: Lesson 19 · Roadmap. Next: Gates and Policies.




No comments yet. Be the first to share your thoughts.