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:
Devise::RegistrationsController
and the others from ApiController
.Thanks!
The token-based verification method works simply. The user enters his details and sends the request to the server. If the information is correct, the server creates a unique HMACSHA256 encoded token, also known as the JSON (JWT) web token.
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With