9

I am building a single-page-app with Vue (2.5) using Laravel (5.5) as the backend. Everything works well, except for directly logging in again after having logged out. In this case, the call to /api/user (to retrieve the user's account information and to verify the user's identity once more) fails with a 401 unauthorized (even though the log-in succeeded). As a response, the user is bounced back directly to the login screen (I wrote this measure myself as a reaction to 401 responses).

What does work is to log out, refresh the page with ctrl/cmd+R, and then log in again. The fact that a page refresh fixes my problem, gives me reason to believe that I am not handling refresh of the X-CSRF-TOKEN correctly, or may be forgetting about certain cookies that Laravel uses (as described here ).

This is a snippet of the code of the login form that is executed after a user clicks the login button.

login(){
    // Copy the form data
    const data = {...this.user};
    // If remember is false, don't send the parameter to the server
    if(data.remember === false){
        delete data.remember;
    }

    this.authenticating = true;

    this.authenticate(data)
        .then( this.refreshTokens )
        .catch( error => {
            this.authenticating = false;
            if(error.response && [422, 423].includes(error.response.status) ){
                this.validationErrors = error.response.data.errors;
                this.showErrorMessage(error.response.data.message);
            }else{
                this.showErrorMessage(error.message);  
            }
        });
},
refreshTokens(){
    return new Promise((resolve, reject) => {
        axios.get('/refreshtokens')
            .then( response => {
                window.Laravel.csrfToken = response.data.csrfToken;
                window.axios.defaults.headers.common['X-CSRF-TOKEN'] = response.data.csrfToken;
                this.authenticating = false;
                this.$router.replace(this.$route.query.redirect || '/');
                return resolve(response);
            })
            .catch( error => {
                this.showErrorMessage(error.message);
                reject(error);
            });
    });
},  

the authenticate() method is a vuex action, which calls the login endpoint at the laravel side.

The /refreshTokens endpoint simply calls this Laravel controller function that returns the CSRF token of the currently logged-in user:

public function getCsrfToken(){
    return ['csrfToken' => csrf_token()];
}

After the tokens have been refetched, the user is redirected to the main page (or another page if supplied) with this.$router.replace(this.$route.query.redirect || '/'); and there the api/user function is called to check the data of the currently logged in user.

Are there any other measures I should take to make this work, that I am overlooking?

Thanks for any help!


EDIT: 07 Nov 2017

After all the helpful suggestions, I would like to add some information. I am using Passport to authenticate on the Laravel side, and the CreateFreshApiToken middleware is in place.

I have been looking at the cookies set by my app, and in particular the laravel_token which is said to hold the encrypted JWT that Passport will use to authenticate API requests from your JavaScript application. When logging out, the laravel_token cookie is deleted. When logging in again directly afterwards (using axios to send an AJAX post request) no new laravel_token is being set, so that's why it doesn't authenticate the user. I am aware that Laravel doesn't set the cookie on the login POST request, but the GET request to /refreshTokens (which is not guarded) directly afterwards should set the cookie. However, this doesn't appear to be happening.

I have tried increasing the delay between the request to /refreshTokens and the request to /api/user, to maybe give the server some time to get things in order, but to no avail.

For completeness sake, here is my Auth\LoginController that is handling the login request server-side:

class LoginController extends Controller
{
    use AuthenticatesUsers;

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected $redirectTo = '/';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        // $this->middleware('guest')->except('logout');
    }

    /**
     * Get the needed authorization credentials from the request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    protected function credentials(\Illuminate\Http\Request $request)
    {
        //return $request->only($this->username(), 'password');
        return ['email' => $request->{$this->username()}, 'password' => $request->password, 'active' => 1];
    }

    /**
     * The user has been authenticated.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  mixed  $user
     * @return mixed
     */
    protected function authenticated(\Illuminate\Http\Request $request, $user)
    {
        $user->last_login = \Carbon\Carbon::now();
        $user->timestamps = false;
        $user->save();
        $user->timestamps = true;

        return (new UserResource($user))->additional(
            ['permissions' => $user->getUIPermissions()]
        );
    }


    /**
     * Log the user out of the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function logout(\Illuminate\Http\Request $request)
    {
        $this->guard()->logout();
        $request->session()->invalidate();
    }
}
Daniel Schreij
  • 773
  • 1
  • 10
  • 26
  • How is the 'logout' function implemented? – Bragolgirith Nov 02 '17 at 15:08
  • It calls the logout function of laravel with a post request. so simply `axios.post('/logout')`; – Daniel Schreij Nov 02 '17 at 17:11
  • When you watch the Ajax requests for Logout/login go back and forth do you see a new token being generated and returned by /refreshtokens, and is that the token used on the subsequent (and failing) calls to the server? – Chris Phillips Nov 05 '17 at 07:44
  • Yes, I see a new token being generated. I did some more research and found some new info: the problem is that the token returned by refreshToken is the CSRF token used to secure against cross-site-request forgery. It is not the JWT token used to authenticate users. This is set in a cookie called `laravel_token` which is a httpOnly cookie and thus not accessible in JS. Many sources say that the browser should take care of handling this token even if it is returned through an ajax response, but apparently this isn't the case here... – Daniel Schreij Nov 05 '17 at 11:48
  • More info on this problem here: https://github.com/laravel/passport/issues/293 – Daniel Schreij Nov 05 '17 at 11:58

3 Answers3

4

Considering that you are using an api for authentication, I would suggest using Passport or JWT Authentication to handle authentication tokens.

CUGreen
  • 3,066
  • 2
  • 13
  • 22
  • Thanks. I am using Passport. It says it should automatically take care of the JWT authentication according to https://laravel.com/docs/5.5/passport#consuming-your-api-with-javascript. This works well, except for the log-out log-in problem. I have the idea that I'm not handling the cookie data correctly that is returned with each response according to https://laravel.com/docs/5.5/csrf#csrf-x-csrf-token – Daniel Schreij Nov 03 '17 at 08:07
  • Ah sorry wasn't apparent. I would go over your configurations and setup and make sure it is all correct – CUGreen Nov 03 '17 at 08:18
  • I did already, but couldn't find anything out of order. I think my procedure is correct, but I'm missing some part of the puzzle (like handling the cookie data). I doubt I'm the first one which bumps into this problem, so lets hope someone spots the mistake. – Daniel Schreij Nov 03 '17 at 10:29
  • Apparently I'm confusing the CSRF token used to prevent cross-site request forgery with the `laravel_token` cookie, which is used for authentication. This cookie is httpOnly so not accessible by JS. It should be handled by the browser automatically, but somehow that doesn't happen in my case. (even though I perform an extra GET request which is required to set the cookie, by calling /refreshTokens). – Daniel Schreij Nov 05 '17 at 11:50
  • There is obviously something missing or incorrect in your configuration. Some request/response dumps from dev tools would be helpful. Also, make sure you have ```\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class``` added to your ```web``` middleware group as mentioned here https://laravel.com/docs/5.5/passport#consuming-your-api-with-javascript – CUGreen Nov 05 '17 at 23:04
  • The middleware is inplace and sets the cookie laravel_token which is used to authenticate. I'm going to dive into this and post my findings or dumps here once I have a clue what's happening. – Daniel Schreij Nov 06 '17 at 10:48
4

Finally fixed it!

By returning the UserResource directly in the LoginControllers authenticated method, it is not a valid Laravel Response (but I guess raw JSON data?) so probably things like cookies are not attached. I had to attach a call to response() on the resource and now everything seems to work fine (though I need to do more extensive testing).

So:

protected function authenticated(\Illuminate\Http\Request $request, $user)
{
    ...

    return (new UserResource($user))->additional(
        ['permissions' => $user->getUIPermissions()]
    );
}

becomes

protected function authenticated(\Illuminate\Http\Request $request, $user)
{
    ...

    return (new UserResource($user))->additional(
        ['permissions' => $user->getUIPermissions()]
    )->response();  // Add response to Resource
}

Hurray for the Laravel docs on attributing a section to this: https://laravel.com/docs/5.5/eloquent-resources#resource-responses

Additionally, the laravel_token is not set by the POST request to login, and the call to refreshCsrfToken() also didn't do the trick, probably because it was protected by the guest middleware.

What worked for me in the end is to perform a dummy call to '/' right after the login function returned (or the promise was fulfilled).

In the end, my login function in the component was as follows:

login(){
    // Copy the user object
    const data = {...this.user};
    // If remember is false, don't send the parameter to the server
    if(data.remember === false){
        delete data.remember;
    }

    this.authenticating = true;

    this.authenticate(data)
        .then( csrf_token => {
            window.Laravel.csrfToken = csrf_token;
            window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrf_token;

            // Perform a dummy GET request to the site root to obtain the larevel_token cookie
            // which is used for authentication. Strangely enough this cookie is not set with the
            // POST request to the login function.
            axios.get('/')
                .then( () => {
                    this.authenticating = false;
                    this.$router.replace(this.$route.query.redirect || '/');
                })
                .catch(e => this.showErrorMessage(e.message));
        })
        .catch( error => {
            this.authenticating = false;
            if(error.response && [422, 423].includes(error.response.status) ){
                this.validationErrors = error.response.data.errors;
                this.showErrorMessage(error.response.data.message);
            }else{
                this.showErrorMessage(error.message);  
            }
        });

and the authenticate() action in my vuex store is as follows:

authenticate({ dispatch }, data){
    return new Promise( (resolve, reject) => {
        axios.post(LOGIN, data)
            .then( response => {
                const {csrf_token, ...user} = response.data;
                // Set Vuex state
                dispatch('setUser', user );
                // Store the user data in local storage
                Vue.ls.set('user', user );
                return resolve(csrf_token);
            })
            .catch( error => reject(error) );
    });
},

Because I didn't want to make an extra call to refreshTokens in addition to the dummy call to /, I attached the csrf_token to the response of the /login route of the backend:

protected function authenticated(\Illuminate\Http\Request $request, $user)
{
    $user->last_login = \Carbon\Carbon::now();
    $user->timestamps = false;
    $user->save();
    $user->timestamps = true;

    return (new UserResource($user))->additional([
        'permissions' => $user->getUIPermissions(),
        'csrf_token' => csrf_token()
    ])->response();
}
Daniel Schreij
  • 773
  • 1
  • 10
  • 26
1

You should use Passports CreateFreshApiToken middleware in your web middleware passport consuming-your-api

web => [...,
    \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
],

this attaches attach the right csrftoken() to all your Request headers as request_cookies

Jon Awoyele
  • 336
  • 1
  • 2
  • 10