Authentication with Ember is difficult. I have spent a couple of weeks trying out different approaches and failing time and again. With the help of Ryan Florence and Brad Humphrey, I have finally been able to understand how it should work and also have built a simple application which uses it.
My goal in this article will be to build a simple Ember application with a RESTful backend (in Rails) which provides authentication and user registration. We will also set all requests to pass the access token to our backend for authorization.
Here are a couple of the resources I used to build this app:
Our application is going to be using the Rails::API (see Railscast) gem. By using this gem, we limit our Rails app to include only things necessary for API-driven apps. We will also be using Rails 4.0.
$ gem install rails-api
$ rails-api new simple_auth --skip-bundle
$ cd simple_auth
We are going to use the active_model_serializers gem to format our JSON responses to be Ember-friendly. We will also use has_secure_password so let’s uncomment the ‘bcrypt’ gem in our Gemfile:
We are going to have two models in our application: user and api_key. The user will contain the user information including the encrypted password and the api_key will contain the access token and expiration date. The reason we have separated these two tables is to allow a user to have multiple sessions at a time.
Create the resources.
$ rails g resource user name username:string:uniq email:string:uniq password_digest
...
$ rails g resource api_key user:references access_token:string:uniq scope expired_at:datetime created_at:datetime --timestamps=false
Run your migrations:
$ rake db:migrate; rake db:migrate RAILS_ENV=test
Because we are using the Active Model Serializers gem, serializers are created automatically for our models. However, we want to limit what they return to only the parts which are useful. Update the serializers as follows:
Add a test to ensure the api_key generates an access token when created.
test/models/api_key_test.rb
1234567891011121314151617181920212223242526272829
require'test_helper'require'minitest/mock'classApiKeyTest<ActiveSupport::TestCasetest"generates access token"dojoe=users(:joe)api_key=ApiKey.create(scope:'session',user_id:joe.id)assert!api_key.new_record?assertapi_key.access_token=~/\S{32}/endtest"sets the expired_at properly for 'session' scope"doTime.stub:now,Time.at(0)dojoe=users(:joe)api_key=ApiKey.create(scope:'session',user_id:joe.id)assertapi_key.expired_at==4.hours.from_nowendendtest"sets the expired_at properly for 'api' scope"doTime.stub:now,Time.at(0)dojoe=users(:joe)api_key=ApiKey.create(scope:'api',user_id:joe.id)assertapi_key.expired_at==30.days.from_nowendendend
For this to pass, we need to update the api_key model:
app/models/api_key.rb
12345678910111213141516171819202122232425
classApiKey<ActiveRecord::Basevalidates:scope,inclusion:{in:%w( session api )}before_create:generate_access_token,:set_expiry_datebelongs_to:userscope:session,->{where(scope:'session')}scope:api,->{where(scope:'api')}scope:active,->{where('expired_at >= ?',Time.now)}privatedefset_expiry_dateself.expired_at=ifself.scope=='session'4.hours.from_nowelse30.days.from_nowendenddefgenerate_access_tokenbeginself.access_token=SecureRandom.hexendwhileself.class.exists?(access_token:access_token)endend
classApplicationController<ActionController::APIprotected# Renders a 401 status code if the current user is not authorizeddefensure_authenticated_userhead:unauthorizedunlesscurrent_userend# Returns the active user associated with the access token if availabledefcurrent_userapi_key=ApiKey.active.where(access_token:token).firstifapi_keyreturnapi_key.userelsereturnnilendend# Parses the access token from the headerdeftokenbearer=request.headers["HTTP_AUTHORIZATION"]# allows our tests to passbearer||=request.headers["rack.session"].try(:[],'Authorization')ifbearer.present?bearer.split.lastelsenilendendend
Now let’s set up our users controller:
app/controllers/users_controller.rb
12345678910111213141516171819202122232425262728
classUsersController<ApplicationControllerbefore_filter:ensure_authenticated_user,only:[:index]# Returns list of users. This requires authorizationdefindexrenderjson:User.allenddefshowrenderjson:User.find(params[:id])enddefcreateuser=User.create(user_params)ifuser.new_record?renderjson:{errors:user.errors.messages},status:422elserenderjson:user.session_api_key,status:201endendprivate# Strong Parameters (Rails 4)defuser_paramsparams.require(:user).permit(:name,:username,:email,:password,:password_confirmation)endend
Now create a session controller and place our code for authenticating an existing user into it.
$ rails g controller session
app/controllers/session_controller.rb
12345678910
classSessionController<ApplicationControllerdefcreateuser=User.where("username = ? OR email = ?",params[:username_or_email],params[:username_or_email]).firstifuser&&user.authenticate(params[:password])renderjson:user.session_api_key,status:201elserenderjson:{},status:401endendend
Because RailsAPI application controller extends ActionController::API, it doesn’t know about ActionController::StrongParameters. Because of this we need to add an initializer:
# The application controllers don't know anything about ActionController::StrongParameters # because they're not extending the class ActionController::StrongParameters was included within. # This is why the require() method call is not calling the implementation # in ActionController::StrongParameters## see http://stackoverflow.com/questions/13745689/getting-rails-api-and-strong-parameters-to-work-togetherActionController::API.send:include,ActionController::StrongParameters
Routes
Update your routes file to make sure that it reflects our changes:
Let’s write some tests to make sure our API is functioning as we expect it to. First, let’s test out our session controller (for authentication):
test/controllers/session_controller_test.rb
12345678910111213141516171819202122232425262728
require'test_helper'classSessionControllerTest<ActionController::TestCasetest"authenticate with username"dopw='secret'larry=User.create!(username:'larry',email:'larry@example.com',name:'Larry Moulders',password:pw,password_confirmation:pw)post'create',{username_or_email:larry.username,password:pw}results=JSON.parse(response.body)assertresults['api_key']['access_token']=~/\S{32}/assertresults['api_key']['user_id']==larry.idendtest"authenticate with email"dopw='secret'larry=User.create!(username:'larry',email:'larry@example.com',name:'Larry Moulders',password:pw,password_confirmation:pw)post'create',{username_or_email:larry.email,password:pw}results=JSON.parse(response.body)assertresults['api_key']['access_token']=~/\S{32}/assertresults['api_key']['user_id']==larry.idendtest"authenticate with invalid info"dopw='secret'larry=User.create!(username:'larry',email:'larry@example.com',name:'Larry Moulders',password:pw,password_confirmation:pw)post'create',{username_or_email:larry.email,password:'huh'}assertresponse.status==401endend
Now, let’s add some tests to our users controller (for registration):
require'test_helper'classUsersControllerTest<ActionController::TestCasetest"#create"dopost'create',{user:{username:'billy',name:'Billy Blowers',email:'billy_blowers@example.com',password:'secret',password_confirmation:'secret'}}results=JSON.parse(response.body)assertresults['api_key']['access_token']=~/\S{32}/assertresults['api_key']['user_id']>0endtest"#create with invalid data"dopost'create',{user:{username:'',name:'',email:'foo',password:'secret',password_confirmation:'something_else'}}results=JSON.parse(response.body)assertresults['errors'].size==3endtest"#show"dojoe=users(:joe)post'show',{id:joe.id}results=JSON.parse(response.body)assertresults['user']['id']==joe.idassertresults['user']['name']==joe.nameendtest"#index without token in header"doget'index'assertresponse.status==401endtest"#index with invalid token"doget'index',{},{'Authorization'=>"Bearer 12345"}assertresponse.status==401endtest"#index with expired token"dojoe=users(:joe)expired_api_key=joe.api_keys.session.createexpired_api_key.update_attribute(:expired_at,30.days.ago)assert!ApiKey.active.map(&:id).include?(expired_api_key.id)get'index',{},{'Authorization'=>"Bearer #{expired_api_key.access_token}"}assertresponse.status==401endtest"#index with valid token"dojoe=users(:joe)api_key=joe.session_api_keyget'index',{},{'Authorization'=>"Bearer #{api_key.access_token}"}results=JSON.parse(response.body)assertresults['users'].size==2endend
That was a lot! Let’s run our tests and make sure everything passes.