Search code examples
phpangularjslaravellaravel-5csrf

Laravel 5.1/AngularJS: Reset password in Angular view (how to validate CSRF token?)


I managed to adjust the default Laravel auth so that it will work as an API for my AngularJS, and so far everything works well. Can go to /reset and enter an email and get sent an email with a password reset link which goes to /reset/{token} and if you don't get any validation errors, your password will successfully be changed.

The only problem is since I am using an Angular view, there isn't really anything validating the token and making sure it's not just gibberish before showing the reset-password state. I tried adding this to the top of the controller:

    if ($stateParams.token != $cookies.get('XSRF_TOKEN')) {
        $state.go('reset');
    }

...which would basically see if the token is the current CSRF token, but that doesn't work because when the password reset link is sent the CSRF token is changed or something... it is no longer the token from the session.

Anyone have any ideas how I can do this? I want to just redirect the user if the token entered in the url on `/reset/:token' is not valid.

Here is my code..

App.js:

        .state('reset', {
            url: '/reset',
            data: {
                permissions: {
                    except: ['isLoggedIn'],
                    redirectTo: 'user.dashboard'
                }
            },
            templateUrl: 'views/auth/forgot-password.html',
            controller: 'ForgotPasswordController as forgot'
        })
        .state('reset-password', {
            url: '/reset/:token',
            data: {
                permissions: {
                    except: ['isLoggedIn'],
                    redirectTo: 'user.dashboard'
                }
            },
            templateUrl: 'views/auth/reset-password.html',
            controller: 'ResetPasswordController as reset'
        })

This is in the ResetsPassword trait in ResetsPassword.php. Most was already set up but I removed/changed a lot to work as an API:

     /**
     * Send a reset link to the given user.
     */
    public function postEmail(EmailRequest $request)
    {
        $response = Password::sendResetLink($request->only('email'), function (Message $message) {
            $message->subject($this->getEmailSubject());
        });

        switch ($response) {
            case Password::RESET_LINK_SENT:
                return;

            case Password::INVALID_USER:
                return response()->json([
                    'denied' => 'We couldn\'t find your account with that information.'
                ], 404);
        }
    }

    /**
     * Get the e-mail subject line to be used for the reset link email.
     */
    protected function getEmailSubject()
    {
        return property_exists($this, 'subject') ? $this->subject : 'Your Password Reset Link';
    }

    /**
     * Reset the given user's password.
     */
    public function postReset(ResetRequest $request)
    {
        $credentials = $request->only(
            'password', 'password_confirmation', 'token'
        );

        $response = Password::reset($credentials, function ($user, $password) {
            $this->resetPassword($user, $password);
        });

        switch ($response) {
            case Password::PASSWORD_RESET:
                return;

            default:
                return response()->json([
                    'error' => [
                        'message' => 'Could not reset password'
                    ]
                ], 400);
        }
    }

    /**
     * Reset the given user's password.
     */
    protected function resetPassword($user, $password)
    {
        $user->password = bcrypt($password);

        $user->save();
    }

Solution

  • Figured this out.

    For anyone having a similar problem... Here's how I solved it (will probably make it better later on, but for now it works).

    I added another url for the API which is reset/password and it takes a GET request. I pass it the token based on the $stateParams value and if that token does not exist in the password_resets table OR if that token does exist and is expired, return some errors. In the controller, I handle the errors with a redirect. Again I don't think this is ideal because anyone looking at the source could change it up and remove the redirect so I have to find a better way to implement this.

    But again, for now it works and it is a solution nonetheless.

    ResetsPasswords.php (added a method for the get request):

    public function verifyToken(Request $request)
        {
            $user = DB::table('password_resets')->where('token', $request->only('token'))->first();
    
            if ($user) {
                if ($user->created_at > Carbon::now()->subHours(2)) {
                    return response()->json([
                        'success' => [
                            'message' => 'Token is valid and not expired.'
                        ]
                    ], 200);
                } else {
                    return response()->json([
                        'error' => [
                            'message' => 'Token is valid but it\'s expired.'
                        ]
                    ], 401);
                }
            } else {
                return response()->json([
                    'error' => [
                        'message' => 'Token is invalid.'
                    ]
                ], 401);
            }
        }
    

    and in my resetPasswordController.js I just check if the respose returns 'success' or any of the 'error' responses, and if it's an 'error' response I would just do something like $state.go('reset') which would redirect them back to the "forgot password" form where they enter their email to get a reset password link.

    EDIT:

    I figured that checking for a valid token in the controller was bad because it would always load the view at least for a split second. I was looking for some kind of middleware but then I forgot that I was already using the angular-permission package, which sort of acts as a front-end middleware.

    I defined a role isTokenValid and set it up such that it automatically calls a function in the authService I have and gets a response from my API based on the validity of the token. If successful, the role allows the user to enter the state. Otherwise it redirects to the reset state. This prevents the view showing for the split second. Acts very similar to Laravel middleware.

    Only problem is since it's happening on the front end, any hacker can bypass that but that's okay because the server-side code is still there so even if they access the view they can't do anything with the passwords they enter cause the token is still invalid and has to match a particular user.

    Another improvement would be to find a way to disallow the view even without the front-end middleware implementation. Maybe I'll update this again if I find a way to do that.

    Implementation

    The role:

            .defineRole('isTokenValid', function(stateParams) {
                var token = stateParams.token;
                var deferred = $q.defer();
    
                authService.verifyToken(token)
                    .then(function(res) {
                        if (res.success) {
                            deferred.resolve();
                        }
                    }, function(res) {
                        if (res.error) {
                            deferred.reject();
                        }
                    });
    
                return deferred.promise;
            });
    

    And the state:

                .state('reset-password', {
                    url: '/reset/:token',
                    data: {
                        permissions: {
                            only: ['isTokenValid'],
                            redirectTo: 'reset'
                        }
                    },
                    templateUrl: 'views/auth/reset-password.html',
                    controller: 'ResetPasswordController as reset'
                })
    

    Hope it helps anyone with the same problem.