We recently got our Laravel 5.6 application penetration tested and one of the issues which were flagged was the expiration not being set correctly on Logout. The AuthenticatesUsers trait calls the invalidate method on the session which basically flushes the session data and regenerates the ID but doesn't set expiration to it.
According to the report, if an attacker can obtain a valid session token, they will be able to hijack the affected user’s account. The user logging off will not invalidate the attacker’s session.
Any pointers here would be of great help.
Thanks
/**
* Log the user out of the application.
*
* #param \Illuminate\Http\Request $request
* #return \Illuminate\Http\Response
*/
public function logout(Request $request)
{
$this->guard()->logout();
$request->session()->invalidate();
return redirect('/');
}
Laravel 5.6 added an Auth::logoutOtherDevices() method for this purpose:
https://laravel.com/docs/5.7/authentication#invalidating-sessions-on-other-devices
https://laracasts.com/series/whats-new-in-laravel-5-6/episodes/7
https://github.com/laravel/framework/issues/16311
Related
I am implementing some custom auth functionality in to my application, which is built in Laravel v8. I have been looking at Laravel Breeze to see how it is has been implemented there.
These are the relevant functions from Laravel Breeze (https://github.com/laravel/breeze/blob/1.x/stubs/default/App/Http/Controllers/Auth/AuthenticatedSessionController.php):
class AuthenticatedSessionController extends Controller
{
/**
* Handle an incoming authentication request.
*
* #param \App\Http\Requests\Auth\LoginRequest $request
* #return \Illuminate\Http\RedirectResponse
*/
public function store(LoginRequest $request)
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(RouteServiceProvider::HOME);
}
/**
* Destroy an authenticated session.
*
* #param \Illuminate\Http\Request $request
* #return \Illuminate\Http\RedirectResponse
*/
public function destroy(Request $request)
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
So you will notice:
In the store() function, which is called during Login, it does $request->session()->regenerate();
In the destroy() function, which is called during Logout, it does $request->session()->invalidate();
In my application custom auth code, I have applied the same implementation in my login and logout actions.
What I have found is, when I logout, it deletes the existing session file inside storage/framework/sessions but then creates another one.
Then when I login, it creates a brand new session file. This essentially means the folder gets full of session files.
Does anyone know the reason why it is implemented this way? I would have thought logout would just delete the session file without creating a new one?
That is the normal behavior of PHP and it is not specific to Laravel.
In PHP by default, all sessions are files that are stored in a tmp directory which if you analyze the session.save_path value in php.ini file, you will see where it is. These files include the serialized session data that you access using $_SESSION keyword.
Laravel utilize basically the original session file store but acts a little bit different. Beside changing the directory they are saved, when you call the regenerate function it creates another session file and deletes the old one. You can see it the implementation Illuminate\Session\Store.php. The migrate function is used to delete the session and return new session id.
public function regenerate($destroy = false)
{
return tap($this->migrate($destroy), function () {
$this->regenerateToken();
});
}
For the invalidate function it deletes the session file actually. If you inspect the implementation, the invalidate calls for migrate method, it calls a destroy method with session id as input and this function simply deletes the session file. But soon after it deletes the file, it needs to create a new session file, why? Because the logged out user needs a new session id so we can track them.
public function invalidate()
{
$this->flush();
return $this->migrate(true);
}
Laravel Session Cleanup
Laravel has a garbage cleanup functionality which runs randomly and deletes the session files that are not valid. What does it mean randomly? Well the cleanup operation is triggered based on a randomness and traffic. So in each request Laravel checks the odd of triggering the clean or not, and the odds are 2 out of 100 by default. This means if applications receives 50 requests, there is a high chance that it will trigger this cleanup.
So if you have a high traffic, there is a high chance that the session directory will be cleared at short intervals, which is quire cool since it always makes sure that the specified directory does not get over populated when visiting users increase.
By the way if you want to act aggressively and delete on a higher chance, you can change the lottery odds in the config\session.php file:
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
This is a bit of a different question because it is not directly related to my code but to code that causes a problem in another question.
When digging through the code related to this other question, I found a weird return statement that I don't understand. Before going on with my question, please have a look at these two code snippets from the laravel/framework code.
Trait \Illuminate\Foundation\Auth\AuthenticatesUsers:
/**
* Handle a login request to the application.
*
* #param \Illuminate\Http\Request $request
* #return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
*
* #throws \Illuminate\Validation\ValidationException
*/
public function login(Request $request)
{
$this->validateLogin($request);
// If the class is using the ThrottlesLogins trait, we can automatically throttle
// the login attempts for this application. We'll key this by the username and
// the IP address of the client making these requests into this application.
if ($this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
return $this->sendLockoutResponse($request);
}
if ($this->attemptLogin($request)) {
return $this->sendLoginResponse($request);
}
// If the login attempt was unsuccessful we will increment the number of attempts
// to login and redirect the user back to the login form. Of course, when this
// user surpasses their maximum number of attempts they will get locked out.
$this->incrementLoginAttempts($request);
return $this->sendFailedLoginResponse($request);
}
Trait \Illuminate\Foundation\Auth\ThrottlesLogins:
/**
* Redirect the user after determining they are locked out.
*
* #param \Illuminate\Http\Request $request
* #return void
* #throws \Illuminate\Validation\ValidationException
*/
protected function sendLockoutResponse(Request $request)
{
$seconds = $this->limiter()->availableIn(
$this->throttleKey($request)
);
throw ValidationException::withMessages([
$this->username() => [Lang::get('auth.throttle', ['seconds' => $seconds])],
])->status(429);
}
In the AuthenticatesUsers trait, it is checked if there have been made too many authentication attempts for a given user. If so, an event is fired and a lockout response is returned. So far so good.
What I don't understand is the return statement in front of $this->sendLockoutResponse($request). Given method does always throw an exception and does return nothing (well, it would return void, but it doesn't, because it always throws).
So what is the purpose of the return statement here? Is it as hint for the reader that the login() is cancelled at this point or is this some special syntax I never heard of before?
I want to know how Laravel:
creates CSRF tokens
where it is located
expiration time
When I refresh the web page I see the same token that was already created and how increase or decrease expiration time?
In laravel/vendor/laravel/framework/src/Illuminate/Session/Store.php there is a function called regenerateToken() (github)
/**
* Regenerate the CSRF token value.
*
* #return void
*/
public function regenerateToken()
{
$this->put('_token', Str::random(40));
}
It just uses a 40 character long random string as you can see.
I am overwriting session.timeout value in one of the middleware (for Laravel web app) but it doesn't seem to be affecting in terms of timing out a session. Though if I debug it shows value I have overwritten.
Config::set('session.lifetime', 1440);
default value is as following:
'lifetime' => 15,
Website that I am working on has very short session lifetime for most of the users but for selected users I want to provide extended session lifetime.
It seems the only way to accomplish a dynamic lifetime value, is by setting the value in middleware, before the session gets initiated. Otherwise its too late, as the application SessionHandler will have already been instantiated using the default config value.
namespace App\Http\Middleware;
class ExtendSession
{
/**
* Handle an incoming request.
*
* #param \Illuminate\Http\Request $request
* #param \Closure $next
* #return mixed
*/
public function handle($request, $next)
{
$lifetime = 2;
config(['session.lifetime' => $lifetime]);
return $next($request);
}
}
Then in the kernel.php file, add this class prior to StartSession.
\App\Http\Middleware\ExtendSession::class,
\Illuminate\Session\Middleware\StartSession::class,
Here is what worked for me (using Laravel 5.6 or 5.5) to let a user choose session duration at login time.
Editing the lifetime of the session in Auth controller doesn't work because by then the session is already started. You need to add middleware that executes before Laravel runs its own "StartSession" middleware.
One way is to create a cookie to store the user's lifetime length preference and use that value when setting the session expiration on each request.
New file: app/Http/Middleware/SetSessionLength.php
namespace App\Http\Middleware;
use Illuminate\Support\Facades\Cookie;
class SetSessionLength {
const SESSION_LIFETIME_PARAM = 'sessionLifetime';
const SESSION_LIFETIME_DEFAULT_MINS = 5;
/**
* Handle an incoming request.
*
* #param \Illuminate\Http\Request $request
* #param \Closure $next
* #return mixed
*/
public function handle($request, $next) {
$lifetimeMins = Cookie::get(self::SESSION_LIFETIME_PARAM, $request->input(self::SESSION_LIFETIME_PARAM)); //https://laravel.com/api/6.x/Illuminate/Support/Facades/Cookie.html#method_get
if ($lifetimeMins) {
Cookie::queue(self::SESSION_LIFETIME_PARAM, $lifetimeMins, $lifetimeMins); //https://laravel.com/docs/6.x/requests#cookies
config(['session.lifetime' => $lifetimeMins]);
}
return $next($request);
}
}
Modify Kernel: app/Http/Kernel.php
Add \App\Http\Middleware\SetSessionLength::class, right before \Illuminate\Session\Middleware\StartSession::class,.
Modify Config: config/session.php
'lifetime' => env('SESSION_LIFETIME', \App\Http\Middleware\SetSessionLength::SESSION_LIFETIME_DEFAULT_MINS),
Modify: resources/views/auth/login.blade.php
To let the user chose their preferred number of minutes, add a dropdown of minutes, such as starting with <select name="{{\App\Http\Middleware\SetSessionLength::SESSION_LIFETIME_PARAM}}">. Otherwise, change SetSessionLength.php above not to pull from $request->input but retrieve from somewhere else, such as a database record for that user.
The problem occurs because the session has already started, and after that you are changing session lifetime configuration variable.
The variable needs to be changed for current request, but the user already has a session with lifetime specified.
You have to change your login method. And do following steps:
See if user exists in database
If yes, and he is user who needs longer session lifetime, run config(['session.lifetime' => 1440]);
log user in
I recommend using helper to change config on the fly.
config(['session.lifetime' => 1440]);
Is there a way to check if a user already has a valid session on a different machine?
What I want to do is when a user logs in, destroy an other sessions which they may already have, so that if they forget to logout from a computer say on campus or at work, and then they log in at home, it will destroy those other 2 sessions so they are no longer logged in?
Facebook employs this in some way.
My only thoughts so far is something to this effect:
$user = User::find(1); // find the user
Auth::login($user); // log them in
Auth::logout(); // log them out hoping that it will destroy all their sessions on all machines
Auth::login($user); // log them in again so they have a valid session on this machine
I have not had the chance to test this, and I do not know if Auth::login($user); will destroy all sessions for that user, or only the current one.
Thanks!
You can save a session_id within a user model, so that:
When logout event is fired (auth.logout) you would clear it.
When new logging event is fired you can check if attribute session_id is not null within the user model.
If it's not - destroy previous session by:
Session::getHandler()->destroy($user->session_id);
$user->session_id = Session::getId();
Hope that would help!
I realise this is an old question, but there is now a method in laravel 5.6 that does exactly this, so it may be useful for someone coming to this later. You can also retro-fit this method to earlier versions of laravel very easily.
See the docs at https://laravel.com/docs/5.6/authentication#invalidating-sessions-on-other-devices
I had the same use case as you (log out all other devices on log-in). I overrode the default login method to add my own custom logic (first copying the default login method from vendor/laravel/framework/src/illuminate/Foundation/Auth/AuthenticatesUsers.php)
In that method, there is the line if ($this->attemptLogin($request)) - within this, before the return statement, add your call to logoutOtherDevices, as below
if ($this->attemptLogin($request)) {
//log out all other sessions
Auth::logoutOtherDevices($request->password); //add this line
return $this->sendLoginResponse($request);
}
Also ensure you have un-commented the Illuminate\Session\Middleware\AuthenticateSession middleware in your app/Http/Kernel.php, as per the docs
(note that I haven't tested the above code as I was using an older version of laravel that doesn't have this method, see below). This should work in 5.6 though.
Older Laravel versions
I was actually using laravel 5.5, so didn't have access to this handy method. Luckily, it's easy to add.
I opened a laravel 5.6 project and copied the logoutOtherDevices method from vendor/laravel/framework/src/illuminate/Auth/SessionGuard.php - for reference I have pasted below
/**
* Invalidate other sessions for the current user.
*
* The application must be using the AuthenticateSession middleware.
*
* #param string $password
* #param string $attribute
* #return null|bool
*/
public function logoutOtherDevices($password, $attribute = 'password')
{
if (! $this->user()) {
return;
}
return tap($this->user()->forceFill([
$attribute => Hash::make($password),
]))->save();
}
I then copied this into my LoginController - it could go somewhere else of your choice, but I've put it here for ease / laziness. I had to modify it slightly, as below ($this->user() becomes Auth::user())
/**
* Invalidate other sessions for the current user.
* Method from laravel 5.6 copied to here
*
* The application must be using the AuthenticateSession middleware.
*
* #param string $password
* #param string $attribute
* #return null|bool
*/
public function logoutOtherDevices($password, $attribute = 'password')
{
if (! Auth::user()) {
return;
}
return tap(Auth::user()->forceFill([
$attribute => Hash::make($password),
]))->save();
}
I can then call this method in my login method, as specified earlier in my answer, with a slight adjustment - $this->logoutOtherDevices($request->password);
If you want to test this locally, it seems to work if you open your site on a normal and an incognito window. When you log in on one, you'll be logged out on the other - though you'll have to refresh to see anything change.
I hope you will see this job:
Session::regenerate(true);
a new session_id be obtained.
This may not be the best answer, but first thing that came to my mind was lowering the timeout on the session.
In app->config->session.php there's a setting for both lifetime and expire_on_close (browser).
I'd try looking into that for now, and see if someone else comes up with something better.