Is this Rails JSON authentication API (using Devise) secure?

28,286

Solution 1

You don't want to disable CSRF, I have read that people think it doesn't apply to JSON APIs for some reason, but this is a misunderstanding. To keep it enabled, you want to make a few changes:

  • on there server side add a after_filter to your sessions controller:

    after_filter :set_csrf_header, only: [:new, :create]
    
    protected
    
    def set_csrf_header
       response.headers['X-CSRF-Token'] = form_authenticity_token
    end
    

    This will generate a token, put it in your session and copy it in the response header for selected actions.

  • client side (iOS) you need to make sure two things are in place.

    • your client needs to scan all server responses for this header and retain it when it is passed along.

      ... get ahold of response object
      // response may be a NSURLResponse object, so convert:
      NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse*)response;
      // grab token if present, make sure you have a config object to store it in
      NSString *token = [[httpResponse allHeaderFields] objectForKey:@"X-CSRF-Token"];
      if (token)
         [yourConfig setCsrfToken:token];
      
    • finally, your client needs to add this token to all 'non GET' requests it sends out:

      ... get ahold of your request object
      if (yourConfig.csrfToken && ![request.httpMethod isEqualToString:@"GET"])
        [request setValue:yourConfig.csrfToken forHTTPHeaderField:@"X-CSRF-Token"];
      

Final piece of the puzzle is to understand that when logging in to devise, two subsequent sessions/csrf tokens are being used. A login flow would look like this:

GET /users/sign_in ->
  // new action is called, initial token is set
  // now send login form on callback:
  POST /users/sign_in <username, password> ->
    // create action called, token is reset
    // when login is successful, session and token are replaced 
    // and you can send authenticated requests

Solution 2

Your example seems to mimic the code from the Devise blog - https://gist.github.com/josevalim/fb706b1e933ef01e4fb6

As mentioned in that post, you are doing it similar to option 1, which they say is the insecure option. I think the key is that you don't want to simply reset the authentication token every time the user is saved. I think the token should be created explicitly (by some kind of TokenController in the API) and should expire periodically.

You'll notice I say 'I think' since (as far as I can tell) nobody has any more information on this.

Share:
28,286
GMA
Author by

GMA

Updated on April 28, 2020

Comments

  • GMA
    GMA about 4 years

    My Rails app uses Devise for authentication. It has a sister iOS app, and users can log in to the iOS app using the same credentials that they use for the web app. So I need some kind of API for authentication.

    Lots of similar questions on here point to this tutorial, but it seems to be out-of-date, as the token_authenticatable module has since been removed from Devise and some of the lines throw errors. (I'm using Devise 3.2.2.) I've attempted to roll my own based on that tutorial (and this one), but I'm not 100% confident in it - I feel like there may be something I've misunderstood or missed.

    Firstly, following the advice of this gist, I added an authentication_token text attribute to my users table, and the following to user.rb:

    before_save :ensure_authentication_token
    
    def ensure_authentication_token
      if authentication_token.blank?
        self.authentication_token = generate_authentication_token
      end
    end
    
    private
    
      def generate_authentication_token
        loop do
          token = Devise.friendly_token
          break token unless User.find_by(authentication_token: token)
        end
      end
    

    Then I have the following controllers:

    api_controller.rb

    class ApiController < ApplicationController
      respond_to :json
      skip_before_filter :authenticate_user!
    
      protected
    
      def user_params
        params[:user].permit(:email, :password, :password_confirmation)
      end
    end
    

    (Note that my application_controller has the line before_filter :authenticate_user!.)

    api/sessions_controller.rb

    class Api::SessionsController < Devise::RegistrationsController
      prepend_before_filter :require_no_authentication, :only => [:create ]
    
      before_filter :ensure_params_exist
    
      respond_to :json
    
      skip_before_filter :verify_authenticity_token
    
      def create
        build_resource
        resource = User.find_for_database_authentication(
          email: params[:user][:email]
        )
        return invalid_login_attempt unless resource
    
        if resource.valid_password?(params[:user][:password])
          sign_in("user", resource)
          render json: {
            success: true,
            auth_token: resource.authentication_token,
            email: resource.email
          }
          return
        end
        invalid_login_attempt
      end
    
      def destroy
        sign_out(resource_name)
      end
    
      protected
    
        def ensure_params_exist
          return unless params[:user].blank?
          render json: {
            success: false,
            message: "missing user parameter"
          }, status: 422
        end
    
        def invalid_login_attempt
          warden.custom_failure!
          render json: {
            success: false,
            message: "Error with your login or password"
          }, status: 401
        end
    end
    

    api/registrations_controller.rb

    class Api::RegistrationsController < ApiController
      skip_before_filter :verify_authenticity_token
    
      def create
        user = User.new(user_params)
        if user.save
          render(
            json: Jbuilder.encode do |j|
              j.success true
              j.email user.email
              j.auth_token user.authentication_token
            end,
            status: 201
          )
          return
        else
          warden.custom_failure!
          render json: user.errors, status: 422
        end
      end
    end
    

    And in config/routes.rb:

      namespace :api, defaults: { format: "json" } do
        devise_for :users
      end
    

    I'm out of my depth a bit and I'm sure there's something here that my future self will look back on and cringe (there usually is). Some iffy parts:

    Firstly, you'll notice that Api::SessionsController inherits from Devise::RegistrationsController whereas Api::RegistrationsController inherits from ApiController (I also have some other controllers such as Api::EventsController < ApiController which deal with more standard REST stuff for my other models and don't have much contact with Devise.) This is a pretty ugly arrangement, but I couldn't figure out another way of getting access the methods I need in Api::RegistrationsController. The tutorial I linked to above has the line include Devise::Controllers::InternalHelpers, but this module seems to have been removed in more recent versions of Devise.

    Secondly, I've disabled CSRF protection with the line skip_before_filter :verify_authentication_token. I have my doubts about whether this is a good idea - I see a lot of conflicting or hard to understand advice about whether JSON APIs are vulnerable to CSRF attacks - but adding that line was the only way I could get the damn thing to work.

    Thirdly, I want to make sure I understand how authentication works once a user has signed in. Say I have an API call GET /api/friends which returns a list of the current user's friends. As I understand it, the iOS app would have to get the user's authentication_token from the database (which is a fixed value for each user that never changes??), then submit it as a param along with every request, e.g. GET /api/friends?authentication_token=abcdefgh1234, then my Api::FriendsController could do something like User.find_by(authentication_token: params[:authentication_token]) to get the current_user. Is it really this simple, or am I missing something?

    So for anyone who's managed to read all the way to the end of this mammoth question, thanks for your time! To summarise:

    1. Is this login system secure? Or is there something I've overlooked or misunderstood, e.g. when it comes to CSRF attacks?
    2. Is my understanding of how to authenticate requests once users are signed in correct? (See "thirdly..." above.)
    3. Is there any way this code can be cleaned up or made nicer? Particularly the ugly design of having one controller inherit from Devise::RegistrationsController and the others from ApiController.

    Thanks!

  • GMA
    GMA over 10 years
    Thanks, this is really helpful. So am I right in thinking that, once I'm signed in, I need to get the auth_token from the response, then pass that along with subsequent requests to authenticate as that user?
  • beno1604
    beno1604 over 10 years
    This is done automatically if you modify the way your client sends out it's (non-GET) requests like I showed. BTW, this is all assuming you use devise's default, session based authentication. If you authenticate with tokens you need a different login flow, not sure about the details there.
  • GMA
    GMA over 10 years
    So to clarify, this is what happens? 1) The iOS app calls GET /users/sign_in and gets the CSRF token. 2) It submits to POST /users/sign_in using the CSRF token it just received, and gets a new CSRF token. This also saves a cookie on the iOS app and creates a new session. 3) For all future requests, the iOS app authenticate using the cookie, plus it includes the CSRF token for protection on non-GET requests. Am I right?
  • beno1604
    beno1604 over 10 years
    Yes, that is basically it. To clarify, cookies/sessions are being used in both the anonymous as well as the logged in states.
  • if __name__ is None
    if __name__ is None about 10 years
    What if you don't use cookies/sessions at all? Why would you care about CSRF than? Only need to care about XSS than.
  • RonLugge
    RonLugge about 10 years
    How does any of this apply to a RESTful API? A RESTful API doesn't use sessions! A hacker would have to use javascript call against the API, which would have to access the HTTP Local Storage or site cookies -- which would essentially require getting the malicious script on your site, at which point it seems like there are plenty of easier ways to attack the system.