The Undocumented AuthenticateSession Middleware - Decoded
Using Laravel's Auth system you may have wondered to yourself: "How do I log out my account from every device when I change my password"? As it turns out, by default, you can't. This issue led to the creation of the new AuthenticateSession
middleware (since Laravel 5.4).
TL;DR
Use the AuthenticateSession
middleware if you wish to terminate any session your account has on other devices which means you will be forcibly logged out of your account on any other device.
Yoo! Don't forget me!
When we log in to our account using Laravel's Auth system and we check the "remember me" checkbox, we tell Laravel to generate a "remember me" cookie, or as Laravel refers to it, a "recaller" cookie.
The "recaller" cookie contains the user's identifier, the "remember me" token (the one that's associated with the user's record in the database), and the user's hashed password, each separated by a pipe ("|"). Before Laravel sends the cookie to the browser, it will encrypt and sign it so the client won't be able to read it or meddle with it as it contains some sensitive information.
# SessionGuard.php
protected function queueRecallerCookie(AuthenticatableContract $user)
{
$this->getCookieJar()->queue($this->createRecaller(
$user->getAuthIdentifier().'|'.$user->getRememberToken().'|'.$user->getAuthPassword()
));
}
When our lovely user revisits our website, Laravel is going to check if there is a "recaller" cookie set for that request given the user has no open session where he is logged in already. If there is, it will attempt to log in the user using the information stored in that cookie. By default, Laravel will read the cookie and retrieve the user's identifier and the "remember me" token only. Then, it will use those values to retrieve the user from the database and log him in.
# SessionGuard.php
protected function userFromRecaller($recaller)
{
// ...
$this->viaRemember = ! is_null($user = $this->provider->retrieveByToken(
$recaller->id(), $recaller->token()
));
return $user;
}
You are probably scratching your head now and thinking to yourself: "Huh? What about the password hash we stored in the "recaller" cookie"? Don't worry. We didn't forget about it. We will get to it soon.
That's how the "remember me" functionality works in a nutshell. But what happens when the user changes his password?
Forgot My Password 😔
When we use the built-in Auth system of Laravel to handle password resets, as part of resetting the password it will also change the user's "remember_me" token. By doing so, when our account session expires on other devices, we won't be able to authenticate using the "recaller" cookie anymore. The last sentence has a few concerning words we should discuss: "when our account session expires". When exactly our account's session is going to expire? What happens until the session expires? I'm glad you asked.
The default session's timeout in Laravel is 2 hours as specified in the config/session.php
configuration file. Note that the lifetime
option is in minutes.
return [
// ...
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => false,
// ...
];
Let's say someone found out what your password is and used that to log in to your account. You later find out about this incident and you immediately change your password thinking this will revoke the hacker's access to your account. Well, not really. By resetting the password the "remember me" token will indeed change but the hacker still has an open session where he's logged in. We somehow need to tell our application to revoke any old session for our account along with changing the "remember me" token. That's where the AuthenticateSession
middleware is coming into play.
Auth Amnesia Is Fine from Time to Time 🤓
If we visit our App\Http\Kernel
class we can see a commented out line in the $middlewareGroups['web']
array:
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
// ...
];
Let's take a quick look at the AuthenticateSession
middleware.
class AuthenticateSession
{
// ...
public function handle($request, Closure $next)
{
if (! $request->user() || ! $request->session()) {
return $next($request);
}
if ($this->auth->viaRemember()) {
$passwordHash = explode('|', $request->cookies->get($this->auth->getRecallerName()))[2];
if ($passwordHash != $request->user()->getAuthPassword()) {
$this->logout($request);
}
}
if (! $request->session()->has('password_hash')) {
$this->storePasswordHashInSession($request);
}
if ($request->session()->get('password_hash') !== $request->user()->getAuthPassword()) {
$this->logout($request);
}
return tap($next($request), function () use ($request) {
$this->storePasswordHashInSession($request);
});
}
// ...
Let's break the method into smaller chunks and go through them.
if (! $request->user() || ! $request->session()) {
return $next($request);
}
If there is no user authenticated (note that the $request->user()
method will also attempt to log in a user via the "recaller" token) or the session is disabled (for APIs for example), simply carry on. There's nothing we need to do.
if ($this->auth->viaRemember()) {
$passwordHash = explode('|', $request->cookies->get($this->auth->getRecallerName()))[2];
if ($passwordHash != $request->user()->getAuthPassword()) {
$this->logout($request);
}
}
Remember the password hash we stored in the "recaller" cookie earlier? If the authenticated user authenticated using the "recaller" cookie ("remember me"), we retrieve that password hash from the cookie and compare it against the current user's password hash. If they do not match (because the user has changed his password), we log out the authenticated user.
if (! $request->session()->has('password_hash')) {
$this->storePasswordHashInSession($request);
}
If we don't have a password hash in the session, we store the authenticated user's password hash in the session. The next part will explain why we do it.
if ($request->session()->get('password_hash') !== $request->user()->getAuthPassword()) {
$this->logout($request);
}
If the password hash stored in the session does not match the authenticated user's password hash (because the user has changed his password), log out the authenticated user. This very check solves the issue we discussed earlier where an attacker would still have an open session with our account logged in even after we changed the password.
return tap($next($request), function () use ($request) {
$this->storePasswordHashInSession($request);
});
Finally, after the request has finished and we are about to return a response, we ensure that we store the password hash in the session. The reason we do it again is that the user may have changed his password in that request, resulting in a new password hash. If we didn't store the password hash, the user would be kicked out of the system after changing his password.
As it turns out, the AuthenticateSession
middleware does just what it sounds like it does; it authenticates the session. By using this middleware, we can ensure that whenever we change our password, any session for our account is terminated and the old "recaller" cookie is no longer valid.