Reputation: 947
I have difficulties to understand and also properly implement User Authentication in APIs. In other words, I have serious problem to understand the integration of Grape API with front-end frameworks such as Backbone.js, AngularJS or Ember.js.
I'm trying to pivot all different approaches and read a lot about that, but Google returns me truly bad resources and it seems to me, like there is no really good article on this topic - Rails and User authentication with Devise and front-end frameworks.
I will describe my current pivot and I hope you can provide me some feedback on my implementation and maybe point me to the right direction.
Current implementation
I have backend Rails REST API with following Gemfile(I will purposely shorten all file code)
gem 'rails', '4.1.6'
gem 'mongoid', '~> 4.0.0'
gem 'devise'
gem 'grape'
gem 'rack-cors', :require => 'rack/cors'
My current implementation has only APIs with following Routes(routes.rb):
api_base /api API::Base
GET /:version/posts(.:format)
GET /:version/posts/:id(.:format)
POST /:version/posts(.:format)
DELETE /:version/posts/:id(.:format)
POST /:version/users/authenticate(.:format)
POST /:version/users/register(.:format)
DELETE /:version/users/logout(.:format)
I created have following model user.rb
class User
include Mongoid::Document
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable
field :email, type: String, default: ""
field :encrypted_password, type: String, default: ""
field :authentication_token, type: String
before_save :ensure_authentication_token!
def ensure_authentication_token!
self.authentication_token ||= generate_authentication_token
end
private
def generate_authentication_token
loop do
token = Devise.friendly_token
break token unless User.where(authentication_token: token).first
end
end
end
In my controllers I created following folder structure: controllers->api->v1 and I have created following shared module Authentication (authentication.rb)
module API
module V1
module Authentication
extend ActiveSupport::Concern
included do
before do
error!("401 Unauthorized", 401) unless authenticated?
end
helpers do
def warden
env['warden']
end
def authenticated?
return true if warden.authenticated?
params[:access_token] && @user = User.find_by(authentication_token: params[:access_token])
end
def current_user
warden.user || @user
end
end
end
end
end
end
So every time when I want to ensure, that my resource will be called with Authentication Token, I can simply add this by calling: include API::V1::Authentication
to the Grape resource:
module API
module V1
class Posts < Grape::API
include API::V1::Defaults
include API::V1::Authentication
Now I have another Grape resource called Users(users.rb) and here I implement methods for authentication, registration and logout.(I think that I mix here apples with pears, and I should extract the login/logout process to another Grape resource - Session).
module API
module V1
class Users < Grape::API
include API::V1::Defaults
resources :users do
desc "Authenticate user and return user object, access token"
params do
requires :email, :type => String, :desc => "User email"
requires :password, :type => String, :desc => "User password"
end
post 'authenticate' do
email = params[:email]
password = params[:password]
if email.nil? or password.nil?
error!({:error_code => 404, :error_message => "Invalid email or password."}, 401)
return
end
user = User.find_by(email: email.downcase)
if user.nil?
error!({:error_code => 404, :error_message => "Invalid email or password."}, 401)
return
end
if !user.valid_password?(password)
error!({:error_code => 404, :error_message => "Invalid email or password."}, 401)
return
else
user.ensure_authentication_token!
user.save
status(201){status: 'ok', token: user.authentication_token }
end
end
desc "Register user and return user object, access token"
params do
requires :first_name, :type => String, :desc => "First Name"
requires :last_name, :type => String, :desc => "Last Name"
requires :email, :type => String, :desc => "Email"
requires :password, :type => String, :desc => "Password"
end
post 'register' do
user = User.new(
first_name: params[:first_name],
last_name: params[:last_name],
password: params[:password],
email: params[:email]
)
if user.valid?
user.save
return user
else
error!({:error_code => 404, :error_message => "Invalid email or password."}, 401)
end
end
desc "Logout user and return user object, access token"
params do
requires :token, :type => String, :desc => "Authenticaiton Token"
end
delete 'logout' do
user = User.find_by(authentication_token: params[:token])
if !user.nil?
user.remove_authentication_token!
status(200)
{
status: 'ok',
token: user.authentication_token
}
else
error!({:error_code => 404, :error_message => "Invalid token."}, 401)
end
end
end
end
end
end
I realize that I present here a ton of code and it might not make sense, but this is what I currently have and I'm able to use the authentication_token
for calls against my API which are protected by module Authentication
.
I feel like this solution is not good, but I really looking for easier way how to achieve user authentication through APIs. I have several questions which I listed below.
Questions
Token
which has expiration time, etc. But I think this is almost like reinventing wheel, because for this purpose I can implement OAuth2. I would like to have lighter solution.I apologize for such a long post, but I hope that more people has the same problem and it might help me to find more answers on my questions.
Upvotes: 23
Views: 15884
Reputation: 25054
As pointed out by @PSR, a simple token-based auth is not safe. The proper solution would use refresh tokens and access tokens as described e.g. in The Ultimate Guide to handling JWTs on frontend clients.
However, nowadays we can use SameSite
and HttpOnly
cookies which makes session-based authentication for webapps safe & simple.
In config/initializers/session_store.rb
# Use safe SameSite cookies. HttpOnly is already the default.
Rails.application.config.session_store :cookie_store, same_site: :strict
In the Grape API base
# Enable session middleware for auth: https://stackoverflow.com/a/35428068/2771889
use ActionDispatch::Session::CookieStore
helpers do
def session
env['rack.session']
end
end
The API
helpers Devise::Controllers::SignInOut
resource :users do
params do
requires :user, type: Hash do
requires :email
requires :password
end
end
post :login do
user = User.find_by(email: params[:user][:email])
if user&.valid_password?(params[:user][:password])
sign_in(user)
{ user_id: user.id }
else
error!('Invalid email/password combination', 401)
end
end
end
Two caveats:
SameSite
cookies: https://caniuse.com/same-site-cookie-attributeUpvotes: 0
Reputation: 513
Although I like the question and the answer given by @MZaragoza I think it is worth noting that token_authentical has been removed from Devise for a reason! Use of the tokens are vulnerable for timing attacks. See also this post and Devise's blog Therefor I haven't upvoted @MZaragoza's answer.
In case you use your API in combination with Doorkeeper, you could do something similar, but instead of checking for the authentication_token in the User table/model you look for the token in the OauthAccessTokens table, i.e.
def authenticated
return true if warden.authenticated?
params[:access_token] && @user = OauthAccessToken.find_by_token(params[:access_token]).user
end
This is more safe, because that token (i.e. the actual access_token) exists only for a certain amount of time.
Note in order to be able to do this you must have a User model and OauthAccessToken model, with:
class User < ActiveRecord::Base
has_many :oauth_access_tokens
end
class OauthAccessToken < ActiveRecord::Base
belongs_to :user, foreign_key: 'resource_owner_id'
end
EDIT: Please also note that generally you should not include the access_token in the URL: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-bearer-16#section-2.3
Upvotes: 5
Reputation: 10111
Add token_authenticable to devise modules (works with devise versions <=3.2)
In user.rb add :token_authenticatable to the list of devise modules, it should look something like below:
class User < ActiveRecord::Base
# ..code..
devise :database_authenticatable,
:token_authenticatable,
:invitable,
:registerable,
:recoverable,
:rememberable,
:trackable,
:validatable
attr_accessible :name, :email, :authentication_token
before_save :ensure_authentication_token
# ..code..
end
Generate Authentication token on your own (If devise version > 3.2)
class User < ActiveRecord::Base
# ..code..
devise :database_authenticatable,
:invitable,
:registerable,
:recoverable,
:rememberable,
:trackable,
:validatable
attr_accessible :name, :email, :authentication_token
before_save :ensure_authentication_token
def ensure_authentication_token
self.authentication_token ||= generate_authentication_token
end
private
def generate_authentication_token
loop do
token = Devise.friendly_token
break token unless User.where(authentication_token: token).first
end
end
Add migration for authentiction token
rails g migration add_auth_token_to_users
invoke active_record
create db/migrate/20141101204628_add_auth_token_to_users.rb
Edit migration file to add :authentication_token column to users
class AddAuthTokenToUsers < ActiveRecord::Migration
def self.up
change_table :users do |t|
t.string :authentication_token
end
add_index :users, :authentication_token, :unique => true
end
def self.down
remove_column :users, :authentication_token
end
end
Run migrations
rake db:migrate
Generate token for existing users
We need to call save on every instance of user that will ensure authentication token is present for each user.
User.all.each(&:save)
Secure Grape API using auth token
You need to add below code to the API::Root in-order to add token based authentication. If you are unware of API::Root then please read Building RESTful API using Grape
In below example, We are authenticating user based on two scenarios – If user is logged on to the web app then use the same session – If session is not available and auth token is passed then find user based on the token
# lib/api/root.rb
module API
class Root < Grape::API
prefix 'api'
format :json
rescue_from :all, :backtrace => true
error_formatter :json, API::ErrorFormatter
before do
error!("401 Unauthorized", 401) unless authenticated
end
helpers do
def warden
env['warden']
end
def authenticated
return true if warden.authenticated?
params[:access_token] && @user = User.find_by_authentication_token(params[:access_token])
end
def current_user
warden.user || @user
end
end
mount API::V1::Root
mount API::V2::Root
end
end
Upvotes: 22