Search code examples
ruby-on-railsapiauthenticationdevisereset-password

Unauthorized request using devise authentication methods as API for resetting password


I am working on a project that is divided in two apps : - one rails JSON API that is dealing with the database and is rendering data as JSON - one "front-end" rails app that is sending requests to the API whenever it needs and displaying the json data in a nice way.

Authentification for the API is token based using gem'simple_token_authentication' meaning that for most of the requests that are sent to the API you have to send the user token & his email in the header for the request to be authorized.

The one who worked on the project before me had also installed Devise authentification system on the API side to allow direct access to the API methods from the navigator after successfull login with email & password.

I just started coding on the "front-end app" that is supposed to request the API and I am having trouble especially with the authentification system.

As Devise was already installed on the API, I thought it would be a good idea to make the user login on the front-end app which would then request devise's methods present on the API for creating user, auth, reseting password...

The problem is that devise's methods are rendering html and not JSON so I actually had to override most of devise's controller. To give you a quick idea of how it works : You fill the sign up form on the front-end app then the params are sent to the front-end app controller that is then requesting devise's register user method on the API :

1) front-end app controller :

  def create
    # Post on API to create USER
    @response =   HTTParty.post(ENV['API_ADDRESS']+'users',
        :body => { :password => params[:user][:password],
                   :password_confirmation => params[:user][:password_confirmation],
                   :email => params[:user][:email]
                 }.to_json,
        :headers => { 'Content-Type' => 'application/json' })
    # si le User est bien crée je récupère son email et son token, je les store en session et je redirige vers Account#new
    if user_id = @response["id"]
      session[:user_email] = @response["email"]
      session[:user_token] = @response["authentication_token"]
      redirect_to new_account_path
    else
      puts @response
      @errors = @response["errors"]
      puts @errors
      render :new
    end
  end 

2) API overrided devise controller :

class RegistrationsController < Devise::RegistrationsController
  def new
    super
  end

  def create
    @user = User.new(user_params)
    if @user.save
      render :json => @user
    else
      render_error
    end
  end

  def update
    super
  end

  private

  def user_params
    params.require(:registration).permit(:password, :email)
  end

  def render_error
    render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity
  end
end

This works ok. Here I send back the user that was just created on the API as JSON and I store is auth token and his email in the session hash.

My problem is with the reset_password method for which I am trying to reuse some of devise code. First, I ask for a reset of the password which generates a reset password token for the user who requested the change. This also generates an email to the user with a link (with the token inside) pointing to the reset password form for the specific user. This is working well. I am getting the link in the email then going to the edit_password form on my front-end app :

Change your password

<form action="/users/password" method='post'>
  <input name="authenticity_token" value="<%= form_authenticity_token %>" type="hidden">
  <%= hidden_field_tag "[user][reset_password_token]", params[:reset_password_token] %>
   <%=label_tag "Password" %>
  <input type="text" name="[user][password">
  <%=label_tag "Password Confirmation" %>
  <input type="text" name="[user][password_confirmation]">
  <input type="Submit" value="change my password">
</form>

When the form is submitted it goes through my front-end app controller :

  def update_password
      @response =   HTTParty.patch(ENV['API_ADDRESS']+'users/password',
        :body => {
          :user => {
            :password => params[:user][:password],
            :password_confirmation => params[:user][:password_confirmation],
            :reset_password_token => params[:user][:reset_password_token]
          }
                 }.to_json,
        :headers => { 'Content-Type' => 'application/json' })
  end

which then calls my overrided Devise::PasswordController (update method) :

# app/controllers/registrations_controller.rb
class PasswordsController < Devise::RegistrationsController
   # POST /resource/password
  def create
    if resource_params[:email].blank?
      render_error_empty_field and return
    end
    self.resource = resource_class.send_reset_password_instructions(resource_params)
    yield resource if block_given?

    if successfully_sent?(resource)
      render_success
    else
      render_error
    end
  end

  def update
    self.resource = resource_class.reset_password_by_token(resource_params)
    yield resource if block_given?

    if resource.errors.empty?
      resource.unlock_access! if unlockable?(resource)
      render_success
    else
      render_error
    end
  end


  private

  # TODO change just one big method render_error with different cases

  def render_success
    render json: { success: "You will receive an email with instructions on how to reset your password in a few minutes." }
  end

  def render_error
    render json: { error: "Ce compte n'existe pas." }
  end
  def render_error_empty_field
    render json: { error: "Merci d'entrer un email" }
  end
end

However the request is always Unauthorized :

Started PATCH "/users/password" for ::1 at 2016-02-05 11:28:30 +0100
Processing by PasswordsController#update as HTML
  Parameters: {"user"=>{"password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]", "reset_password_token"=>"[FILTERED]"}, "password"=>{"user"=>{"password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]", "reset_password_token"=>"[FILTERED]"}}}
Completed 401 Unauthorized in 1ms (ActiveRecord: 0.0ms)

I dont understand why is this last request unauthorized ?


Solution

  • Your predecessor likely made a mess of things on the API side just for his convenience.

    We know that using cookies for API's is a really bad idea since it leaves the doors wide open for CSRF/XSRF attacks.

    We can't use the Rails CSRF protection for an API because it only works as sort of guarantee that the request originated from our own server. And an API that can only be used from your own server is not very useful.

    Devise by default uses a cookie based auth strategy because thats what works for web based applications and Devise is all about making auth in web based applications easy.

    So what you should do is either remove Devise completely from the API app or convert Devise to use a token based strategy. You also should consider removing the sessions middleware from the API app. Also the Devise controllers are so heavily slanted towards client interaction so that trying to beat them into API controllers is going to be very messy.

    Updating a password in an API is just:

    class API::V1::Users::PasswordsController
      before_action :authenticate_user!
      def create
        @user = User.find(params[:user_id])
        raise AccessDenied unless @user == current_user
        @user.update(password: params[:password])
        respond_with(@user)
      end
    end
    

    This is a very simplified example - but the point is if you strip off all the junk from the controller related to forms / flashes and redirects there is not that much you are really going to re-use.

    If your front-end app is a "classical" client/server Rails app then you can use a regular cookie based auth (Devise) and let it share the database with the API app. Token based auth does not work well with classical client/server apps due to its stateless nature.

    If the front end app is a SPA like Angular or Ember.js you might want to look into setting up your own OAuth provider with Doorkeeper instead.

    Outh service diagram