A lot of the information came from this post which helped me no end for setting up my API. I'll add a bit more detail in this post to help explain some of the settings and I needed to do quite a bit of reading to understand it all. My intention is to build a Rails API and then build a separate React.js frontend to consume that API. In this post I'll just discuss the setting up of the Rails API but may write a post later about React.
To build my API I'll be using the rails-api
gem and my API also needs to store data for which I'll be using PostgreSQL. If you don't have it already installed, type the following from the command line gem install rails-api
and then rails-api new api_app_name --database=postgresql
to setup your API.
To register users I will be using the Devise gem and for signing in I'm using the Doorkeeper(OAuth 2) gem. This is no doubt different to your usual Rails experience but an important note is that an API should not handle sessions. Essentially the API should be stateless which means that it provides a response after you supply it with a request and that's it. No knowledge of previous or future state is required for it to work. Therefore the flow of authentication should be a client request with an email and password for example. The user resource is returned along with an authentication token. For every route in the API that requires authentication, the client has to send the authentication token. OAuth 2.0 is great for authorisation flows in web applications and Doorkeeper makes it simple to introduce OAuth 2.0 functionality to a Rails application.
With that said here is the basics of my Gemfile:
source 'https://rubygems.org'
gem 'rails', '4.2.4'
gem 'rails-api'
gem 'pg'
gem 'devise'
gem 'doorkeeper'
gem 'rack-cors', :require => 'rack/cors'
group :development, :test do
gem 'byebug'
end
group :development do
gem 'spring'
gem 'better_errors'
gem 'web-console', '~> 2.0'
end
One other point of interest is the rack-cors
gem (cross-origin resource sharing) which will allow other applications, running on a different server, to talk to the Rails API. I'll go into the settings for that later in the post
Next, in the terminal type rails generate devise:install
, then rails generate devise user
and finally rails generate doorkeeper:install
. Make a quick addition to config/environments/development.rb
with the following code:
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
In my case I also wanted to add a full_name
column to the user model so ensure you run a migration if you need any additions to the model. With that done all that is needed is to run rake db:migrate
.
In order to register I will override the Devise registration with a controller that only works over JSON and accepts a create action. An organised way of overriding Devise controllers is with the use of namespaces and therefore my controller will be at app/controllers/users/registrations_controller.rb
:
class Users::RegistrationsController < Devise::RegistrationsController
respond_to :json
respond_to :html, only: []
respond_to :xml, only: []
skip_before_filter :verify_authenticity_token
before_filter :not_allowed, only: [:new, :edit, :cancel]
def not_allowed
raise MethodNotAllowed
end
private
def sign_up_params
params.require(:user).permit([
:email,
:password,
:password_confirmation,
:full_name
])
end
def account_update_params
params.require(:user).permit([
:email,
:full_name
])
end
end
From what I can understand, Devise requires the sign_up_params
parameter to be passed in the create method and account_update_params
for the update method and the above code overrides these Devise methods.
One thing I'm not totally sure about is this line: skip_before_filter :verify_authenticity_token
. When I setup my Rails app using the rails-api gem it didn't add the line protect_from_forgery with: :exception
to ApplicationController
as it does with a regular Rails application and in fact it doesn't recognise the method if I try to add it. Therefore the API isn't protecting against CSRF attacks but reading the docs for the rails-api gem it says this is fine if you aren't using cookie based authentication for the API. Therefore the filter isn't really doing anything in this case so I have just deleted it. However, if I have a logged in user then surely I need to maintain some sort of state like I do in a Rails app by using a session cookie? How do I go about this in the API so the user doesn't have to log in with every request without using something like cookie based authentication? If I'm then maintaining state then surely I'm vulnerable to CSRF attacks? I'd really appreciate any feedback on this so I know that I'm not creating a glaring security issue!
I have to setup my routes to initialise Doorkeeper and force Devise to use the above controller rather than its default:
Rails.application.routes.draw do
use_doorkeeper
devise_for :users,
only: :registrations,
controllers: {
registrations: 'users/registrations'
}
end
As mentioned earlier, I now need to do some CORS setup to allow other applications to communicate with my API. This is the code for config/application.rb
:
module SiteAnalysisApi
class Application < Rails::Application
...
config.middleware.insert_before 0, "Rack::Cors" do
allow do
origins '*'
resource '*',
headers: :any,
methods: [:get, :post, :delete, :put, :options],
max_age: 0
end
end
config.middleware.use ActionDispatch::Flash
end
end
The above will allow GET, POST, DELETE, PUT or OPTIONS requests from any origin on any resource. I'll tweak the origins to be more selective at a later date as seen here for example.
I also have to add config.middleware.use ActionDispatch::Flash
so I can visit the oauth paths (check out http://localhost:3000/rails/info/routes when you run your server) because the rails-api gem has stripped out a lot of the Rails middleware and I would get an unidentified method 'flash'
error message.
I also have to set some HTTP headers to enable CORS which I have done globally in ApplicationController:
class ApplicationController < ActionController::API
before_filter :cors_preflight_check
after_filter :cors_set_access_control_headers
def cors_preflight_check
if request.method == 'OPTIONS'
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = 'X-Requested-With, X-Prototype-Version, Token'
headers['Access-Control-Max-Age'] = '1728000'
render text: '', content_type: 'text/plain'
end
end
def cors_set_access_control_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization, Token'
headers['Access-Control-Max-Age'] = "1728000"
end
private
def current_resource_owner
User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
end
end
The cors_preflight_check
filter will "preflight" the request by sending an OPTIONS request to the server first. This essentially asks the server if it would allow a type of client request before the request is actually sent. The server will respond with only the necessary headers and an empty text/plain. If the request is authorised then the client can then send the actual request and the server can respond. Note that again I have the origin set to '*'
which I will change at a later date.
The cors_set_access_control_headers
filter occurs after the content has been generated but before it is sent to the client so the CORS access control headers can be sent with the response for this controller.
Also of note is the current_resource_owner method. This (by calling doorkeeper_token.resource_owner_id) is what will retrieve the access token owner from the OAuth authenticated model once Doorkeeper has issued a token and you've included it in a request. You'd call current_resource_owner in lieu of Devise's generated method. In this case it would have been current_user but this requires a session so will not work for my application.
Next up is to set the Doorkeeper initialiser in config/initializers/doorkeeper.rb
:
Doorkeeper.configure do
# Change the ORM that doorkeeper will use (needs plugins)
orm :active_record
# This block will be called to check whether the resource owner is authenticated or not.
resource_owner_authenticator do
warden.authenticate!(scope: :user)
end
resource_owner_from_credentials do |routes|
u = User.find_for_database_authentication(email: params[:email])
u if u && u.valid_password?(params[:password])
end
# Access token expiration time (default 2 hours)
access_token_expires_in 24.hours
# Define access token scopes for your provider
# For more information go to https://github.com/applicake/doorkeeper/wiki/Using-Scopes
default_scopes :api
optional_scopes :write
skip_authorization do |resource_owner, client|
true
end
grant_flows %w(password)
end
In this example because I intend to have full control over the server and the client, it’s fine to ask the user in the webapp client for username and password directly. This then allows me to use the password grant authentication flow. One other very important thing to note is that when using the OAuth 2.0 authentication it is highly recommended to use HTTPS to prevent the authorisation token being visible. To read more about the grant flow types read this article and here is a great video about the password grant flow.
The resource_owner_from_credentials
block checks the users email and password to see if they match an entry in the database and will return an access token to the client if they are valid.
Authorisation scopes are a way to determine to what extent the client can use resources located in the provider. Default Scopes are the ones that are selected for authorisations that do not specify which scopes they need. In other words, if the client does not pass scope parameter in the authorisation URI then these are the scopes that they will get assigned. See this resource for more information.
At this point I can run my server and I'll use Postman to test the end points. First up is registering a new user for which I use a POST request to http://localhost:3000/users.json
with the following JSON in the body:
{
"user": {
"email": "knoxjeffrey@outlook.com",
"password": "password",
"password_confirmation": "password",
"full_name": "Jeff Knox"
}
}
When you check the database all being well the newly submitted data should be there. Next I'll test logging in with a POST request to http://localhost:3000/oauth/token
and the following in the body:
{
"grant_type": "password",
"email": "knoxjeffrey@outlook.com",
"password": "password"
}
Once again, all being well, you should see the something like the following response:
{
"access_token": "ce496b1301086a288358f314098c2f620d086e3d469a2911df35cb853bbb8b0d",
"token_type": "bearer",
"expires_in": 86400,
"scope": "api",
"created_at": 1444265281
}
and in the oauth_access_tokens
table you will see a new record has been added.
What I want to do next is to setup a controller that will manage all the rules for accessing my API at app/controllers/api_controller
:
class ApiController < ApplicationController
before_action -> { doorkeeper_authorize! :api }
end
This differs from the example as doorkeeper_for
no longer works. This is using the default scope of api
from earlier to ensure the access token for the client has this scope to access data in the API.
Following the example I will also create my first controller that is hidden behind OAuth. This can only be accessed with a token and appropriate authorisation headers. The example uses this end point to sync the current user's attributes to the client after a page refresh. Add the following to app/controllers/api/v1/users_controller
:
module Api
module V1
class UsersController < ApiController
def sync
render json: current_resource_owner.attributes, status: 200
end
end
end
end
Notice that I have added version control to my API to ensure that any changes can be easily managed without breaking the application consuming the API. In this cases it's not such a big deal however as I plan to own both the client application and the API but it's a good habit to stick to.
Lastly I need to setup my route, again with the necessary namespacing:
Rails.application.routes.draw do
use_doorkeeper
devise_for :users,
only: :registrations,
controllers: {
registrations: 'users/registrations'
}
namespace :api do
namespace :v1 do
get 'users/sync', to: 'users#sync'
end
end
end
In order to test this I will again use Postman, this time with a GET request to http://localhost:3000/api/v1/users/sync
but also with the token added to the Headers. In Postman you enter Authorization
for the Header and Bearer fe087c17dd15a84b3c939fbbbd1bbfd196d7ea28cfafbf1d6f15a6c74822ef30
for the Value (obviously changing the token for the use you want to test for). If all goes well then the response body will look something like the following:
{
"id": 1,
"email": "knoxjeffrey@outlook.com",
"encrypted_password": "$2a$10$957PrTHcMt8aLkCQgCjwyOxw24/zkZ1VFJQrIIjiy//T27ZqQL5A2",
"reset_password_token": null,
"reset_password_sent_at": null,
"remember_created_at": null,
"sign_in_count": 1,
"current_sign_in_at": "2015-10-08T00:46:27.456Z",
"last_sign_in_at": "2015-10-08T00:46:27.456Z",
"current_sign_in_ip": {
"family": 30,
"addr": 1,
"mask_addr": 340282366920938463463374607431768211455
},
"last_sign_in_ip": {
"family": 30,
"addr": 1,
"mask_addr": 340282366920938463463374607431768211455
},
"created_at": "2015-10-08T00:46:27.386Z",
"updated_at": "2015-10-08T00:46:27.461Z",
"full_name": "Jeff Knox"
}
You can also check this out from the browser by simply typing http://localhost:3000/api/v1/users/sync?access_token=fe087c17dd15a84b3c939fbbbd1bbfd196d7ea28cfafbf1d6f15a6c74822ef30
and you will see the json response.
Excellent, I've got the basics of a working API and that's as far as I have gone so far. A lot of the concepts in this very pretty foreign to me so I had to do quite a bit of reading to better understand it. I'd really appreciate any feedback to let me know if I've made any glaring errors and especially some help with the CSRF issue I mentioned earlier.
I just wanted to add a bit extra to this to improve my versioning after I read this resource by Abraham Kuri Vargas. Read this article before continuing so you understand what I'm about to write.
He suggested that a way to remove the api/v1
from the URL by using routing constraints and accept headers.
My routes.rb
file will now look as follows:
Rails.application.routes.draw do
use_doorkeeper
devise_for :users,
only: :registrations,
controllers: {
registrations: 'users/registrations'
}
namespace :api, defaults: { format: :json }, path: '/' do
scope module: :v1, constraints: ApiConstraints.new(version: 1, default: true) do
get 'users/sync', to: 'users#sync'
end
end
end
Using path: '/'
removes the need to use api
in the url. v1
uses a constraint to check if a version has been added in the Accept Header by using the class ApiConstraints
at lib/api_constraints.rb
:
class ApiConstraints
def initialize(options)
@version = options[:version]
@default = options[:default]
end
# This is a method of constraints as used in routes.rb and requires a 'true' result
# to follow the route. If false, it moves on through the routes until true
# True will result from either default being specified in routes or if the request header,
# Accept, contains the string in this method.
# the client.
def matches?(req)
@default || req.headers['Accept'].include?("application/vnd.marketplace.v#{@version}")
end
end
I've added some documentation to the matches?
method so you better understand how it works because I needed to do a bit of reading up on it.
My spec for this at lib/spec/api_constraints_spec.rb
is almost exactly the same at what was in the article, just some changes to the host and changing from using should to expect:
require 'rails_helper'
describe ApiConstraints do
let(:api_constraints_v1) { ApiConstraints.new(version: 1) }
let(:api_constraints_v2) { ApiConstraints.new(version: 2, default: true) }
describe "matches?" do
it "returns true when the version matches the 'Accept' header" do
request = double(host: 'http://localhost:3000',
headers: {"Accept" => "application/vnd.marketplace.v1"})
expect(api_constraints_v1.matches?(request)).to be true
end
it "returns the default version when 'default' option is specified" do
request = double(host: 'http://localhost:3000')
expect(api_constraints_v2.matches?(request)).to be true
end
end
end
Excellent, now you can drop the api/v1
from the URL. As you add new versions just set the latest to be the deafult and therefore if people want to use an older version then they need to set the Accept Header to include the version. Also remember to keep the default version at the end of the list of versions. If you have it at the top then it will automatically use that and not check the headers for the request to use an older version.