Is this Rails JSON authentication API (using Devise) secure?
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.
GMA
Updated on April 28, 2020Comments
-
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 myusers
table, and the following touser.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 linebefore_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 fromDevise::RegistrationsController
whereasApi::RegistrationsController
inherits fromApiController
(I also have some other controllers such asApi::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 inApi::RegistrationsController
. The tutorial I linked to above has the lineinclude 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'sauthentication_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 myApi::FriendsController
could do something likeUser.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:
- Is this login system secure? Or is there something I've overlooked or misunderstood, e.g. when it comes to CSRF attacks?
- Is my understanding of how to authenticate requests once users are signed in correct? (See "thirdly..." above.)
-
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 fromApiController
.
Thanks!
-
GMA over 10 yearsThanks, 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 over 10 yearsThis 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 over 10 yearsSo to clarify, this is what happens? 1) The iOS app calls
GET /users/sign_in
and gets the CSRF token. 2) It submits toPOST /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 over 10 yearsYes, 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 about 10 yearsWhat if you don't use cookies/sessions at all? Why would you care about CSRF than? Only need to care about XSS than.
-
RonLugge about 10 yearsHow 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.