Building Login by Hand

A login controller written by hand: attempt with remember, session regeneration on login, and a logout that invalidates the session.

Syntaxif (Auth::attempt($credentials, $remember)) { $request->session()->regenerate(); }

A login controller does four things: validate the input, call Auth::attempt, regenerate the session, and redirect. A starter kit does this for you through Fortify. Writing it once is worth the time, because every line is there for a reason.

attempt and remember

Auth::attempt($credentials, $remember) returns a boolean. The second argument switches on "remember me": Laravel stores a token in the user's remember_token column and sets a long-lived cookie named remember_web_<sha1>, so the user is signed in again after the session cookie has gone. Auth::viaRemember() reports whether the current request was authenticated that way.

$request->boolean('remember') is shorthand for filter_var($request->input('remember', false), FILTER_VALIDATE_BOOLEAN). They are the same call, and both turn a checkbox's "on" into true.

Regenerating the session

Call $request->session()->regenerate() immediately after a successful attempt. It issues a new session ID and carries the data over. Without it, a session ID an attacker planted in the browser before login is still valid after login, which is session fixation.

Logging out

Auth::logout() only forgets the user. Follow it with $request->session()->invalidate(), which flushes the session data and gives it a new ID, and $request->session()->regenerateToken(), which issues a fresh CSRF token so the next form in that browser is signed for the new session.

Make logout a POST route with @csrf in the form. A GET logout can be fired by any image tag on another site.

Example

Example · php
<?php
// app/Http/Controllers/Auth/LoginController.php

declare(strict_types=1);

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;

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

    public function store(Request $request): RedirectResponse
    {
        // validate() returns only the validated keys, which is exactly
        // the credentials array attempt() wants.
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        // Shorthand for filter_var($request->input('remember', false),
        // FILTER_VALIDATE_BOOLEAN).
        $remember = $request->boolean('remember');

        if (! Auth::attempt($credentials, $remember)) {
            // One message for both cases. Saying which half was wrong
            // tells an attacker which emails are registered.
            return back()
                ->withErrors(['email' => 'The provided credentials do not match our records.'])
                ->onlyInput('email');
        }

        // New session ID, same session data. This closes session fixation.
        $request->session()->regenerate();

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

    public function destroy(Request $request): RedirectResponse
    {
        Auth::logout();                            // forget the user
        $request->session()->invalidate();         // flush the data, new ID
        $request->session()->regenerateToken();    // new CSRF token

        return redirect('/');
    }
}

When to use it

  • Building login and logout without pulling in a starter kit's frontend
  • Adding a remember-me checkbox to an existing login form
  • Fixing a login that works but leaves the user on the wrong page afterwards
  • Hardening an inherited login controller that never regenerated the session

More examples

The three routes and the form

Two routes share the /login URI and are told apart by method, which is why the GET one carries the name. onlyInput('email') in the controller is what makes old('email') repopulate the field without ever flashing the password back to the browser. The logout form needs @csrf because it is a POST; without the token the request fails with a 419.

Example · php
<?php
// routes/web.php

declare(strict_types=1);

use App\Http\Controllers\Auth\LoginController;
use Illuminate\Support\Facades\Route;

// The name 'login' matters: the auth middleware redirects guests to it.
Route::get('/login', [LoginController::class, 'create'])
    ->middleware('guest')
    ->name('login');

Route::post('/login', [LoginController::class, 'store'])
    ->middleware('guest');

Route::post('/logout', [LoginController::class, 'destroy'])
    ->middleware('auth')
    ->name('logout');

// resources/views/auth/login.blade.php
//
// <form method="POST" action="{{ route('login') }}">
//     @csrf
//     <input type="email" name="email" value="{{ old('email') }}">
//     <input type="password" name="password">
//     <input type="checkbox" name="remember" value="1">
//     <button>Log in</button>
//     @error('email') <p>{{ $message }}</p> @enderror
// </form>

Shaping the credentials before they reach attempt

Use this when you build the credentials array yourself instead of taking validate()'s return value, for example in a console command or a test helper. The checkbox is the reason for filter_var: an unchecked box sends nothing at all, and a checked one sends the string "on", which is truthy either way under a plain cast but correct under FILTER_VALIDATE_BOOLEAN.

Example · php
<?php

declare(strict_types=1);

use Illuminate\Support\Arr;

// What a login form actually posts.
$input = [
    'email' => '[email protected]',
    'password' => 'secret',
    'remember' => 'on',
    'utm_source' => 'newsletter',
];

// Only these two keys may reach attempt(); anything else becomes
// a where clause and the lookup fails.
$credentials = Arr::only($input, ['email', 'password']);

// $request->boolean('remember') is this line.
$remember = filter_var($input['remember'] ?? false, FILTER_VALIDATE_BOOLEAN);

echo implode(',', array_keys($credentials)), PHP_EOL;
echo var_export($remember, true), PHP_EOL;

// Output:
// email,password
// true

Remember me, and the cookie behind it

attempt() is login() with a credential check in front of it, which is why they take the same remember flag. viaRemember() is how you tell a long-lived cookie session from a fresh password: pair it with the password.confirm middleware on billing or account-deletion screens. Logging out clears the column and the cookie, so a remembered browser is only remembered until someone presses log out on it.

Example · php
<?php
// app/Http/Controllers/Auth/LoginController.php (excerpt)

use Illuminate\Support\Facades\Auth;

// Second argument true: a random token is written to the user's
// remember_token column and mirrored in a cookie named
// remember_web_<sha1>, encrypted with APP_KEY.
if (Auth::attempt($credentials, remember: true)) {
    $request->session()->regenerate();
}

// Later, anywhere in the request:
if (Auth::viaRemember()) {
    // Authenticated from the cookie, not from a password this session.
    // Ask for the password again before anything sensitive.
    return redirect()->route('password.confirm');
}

// Already have the user object, for example straight after registering?
// login() skips the credential check entirely.
Auth::login($user);                      // no remembering
Auth::login($user, remember: true);      // with the cookie
Auth::loginUsingId(1, remember: true);   // by primary key

Discussion

  • Be the first to comment on this lesson.