Created
January 16, 2015 06:30
-
-
Save a14m/d997c081a12aa51ee42c to your computer and use it in GitHub Desktop.
Rails JWT authentication
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# app/controllers/api/v1/api_controller.rb | |
# Base API controller class | |
class Api::V1::ApiController < ApplicationController | |
before_action :http_authorization_header?, :authenticate_request, :set_current_user | |
protected | |
# Bad Request if http authorization header missing | |
def http_authorization_header? | |
fail BadRequestError, 'errors.missing_auth_header' unless authorization_header | |
true | |
end | |
def authenticate_request | |
decoded_token ||= | |
AuthenticationToken.decode(authorization_header) | |
@auth_token ||= | |
AuthenticationToken.where(id: decoded_token[:id]).first unless decoded_token.nil? | |
fail UnauthorizedError, 'errors.invalid_auth_token' if @auth_token.nil? | |
fail AuthenticationTimeoutError, 'error.auth_expired' if @auth_token.expired? | |
end | |
def set_current_user | |
@current_user ||= @auth_token.user | |
end | |
# JWT's are stored in the Authorization header using this format: | |
# Bearer some_random_string.encoded_payload.another_random_string | |
def authorization_header | |
return @authorization_header if defined? @authorization_header | |
@authorization_header = | |
begin | |
if request.headers['Authorization'].present? | |
request.headers['Authorization'].split(' ').last | |
else | |
nil | |
end | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# app/controllers/application_controller.rb | |
# Base Application Controller | |
class ApplicationController < ActionController::Base | |
include Render | |
# The API responds only to JSON | |
respond_to :json | |
# Prevent CSRF attacks by raising an exception. | |
# For APIs, you may want to use :null_session instead. | |
# default to protect_from_forgery with: :exception | |
protect_from_forgery with: :null_session | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# app/models/authentication_token.rb | |
class AuthenticationToken | |
include Mongoid::Document | |
belongs_to :user | |
field :token, type: String | |
def self.encode(payload) | |
JWT.encode(payload, Rails.application.secrets.secret_key_base) | |
end | |
def self.decode(token) | |
payload = JWT.decode(token, Rails.application.secrets.secret_key_base)[0] | |
DecodedAuthToken.new(payload) | |
rescue | |
# It will raise an error if it is not a token that was generated | |
# with our secret key or if the user changes the contents of the payload | |
Rails.logger.info "Decoding token failed" | |
nil | |
end | |
# generate and save new authentication token for the user | |
def self.generate(user, exp) | |
@auth_token = user.authentication_tokens.create | |
payload = { id: @auth_token.id.to_s, exp: exp.to_i } | |
@auth_token.token = self.encode payload | |
@auth_token.save! | |
@auth_token | |
end | |
# check if a token can be used or not | |
def expired? | |
@decoded_token ||= AuthenticationToken.decode token | |
@decoded_token && @decoded_token.expired? | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# app/controllers/api/v1/authentications_controller.rb | |
class Api::V1::AuthenticationsController < Api::V1::ApiController | |
skip_before_action :http_authorization_header?, :authenticate_request, :set_current_user, | |
only: [:sign_up, :sign_in] | |
before_filter :find_token, only: [:show, :destroy] | |
decorates_assigned :auth_token | |
def sign_up | |
# creating the current user from registration request | |
@current_user = User.create!(registration_params) | |
generate_auth_token(auth_params) | |
render status: 201 | |
end | |
def sign_in | |
# getting the current user from sign in request | |
@current_user ||= User.find_by_credentials(auth_params) if auth_params[:email] | |
fail UnauthorizedError, 'errors.invalid_credentials' unless @current_user | |
generate_auth_token(auth_params) | |
render status: 201 | |
end | |
def sign_out | |
# this auth token is assigned via api controller from headers | |
@auth_token.destroy! | |
head status: 204 | |
end | |
def index | |
@auth_tokens = @current_user.authentication_tokens.all | |
@auth_tokens = AuthenticationTokenDecorator.decorate_collection(@auth_tokens) | |
end | |
def show | |
end | |
def destroy | |
# this auth token is assigned via find_token private method | |
@auth_token.destroy! | |
head status: 204 | |
end | |
private | |
def find_token | |
@auth_token = @current_user.authentication_tokens.find(params[:id]) | |
end | |
def auth_params | |
params.permit(:email, :password, :remember_me) | |
end | |
def registration_params | |
fail BadRequestError, 'errors.missing_email_field' if params[:users][:email].nil? | |
params.require(:users).permit(:email, :password, :password_confirmation) | |
end | |
def generate_auth_token(params) | |
exp = params[:remember_me] == 'true' ? 6.months.from_now : 6.hours.from_now | |
@auth_token = AuthenticationToken.generate(@current_user, exp) | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# spec/requests/api/v1/authentications_controller_spec.rb | |
require 'rails_helper' | |
RSpec.describe Api::V1::AuthenticationsController, type: :request do | |
describe 'POST authentications#sign_up' do | |
let(:user) { Fabricate.attributes_for(:user) } | |
it 'Returns 201' do | |
post '/auth/sign_up', { | |
users: { | |
email: user[:email], | |
password: user[:password], | |
password_confirmation: user[:password] | |
} | |
}, | |
{ | |
HTTP_CONTENT_TYPE: 'application/json', | |
HTTP_ACCEPT: "application/vnd.tameny+json; version=1" | |
} | |
expect(response.status).to eq 201 | |
expect(response).to render_template('sign_up') | |
end | |
it 'Returns 400' do | |
post '/auth/sign_up', { | |
users: { | |
password: user[:password], | |
password_confirmation: user[:password] | |
} | |
}, | |
{ | |
HTTP_CONTENT_TYPE: 'application/json', | |
HTTP_ACCEPT: "application/vnd.tameny+json; version=1" | |
} | |
expect(response.status).to eq 400 | |
end | |
describe 'Returns 422' do | |
it 'Duplicate E-Mail' do | |
user = Fabricate(:user) | |
post '/auth/sign_up', { | |
users: { | |
email: user.email, | |
password: user.password, | |
password_confirmation: user.password | |
} | |
}, | |
{ | |
HTTP_CONTENT_TYPE: 'application/json', | |
HTTP_ACCEPT: "application/vnd.tameny+json; version=1" | |
} | |
expect(response.status).to eq 422 | |
end | |
it 'Weak password' do | |
user[:password] = 'weak' | |
post '/auth/sign_up', { | |
users: { | |
email: user[:email], | |
password: user[:password], | |
password_confirmation: user[:password] | |
} | |
}, | |
{ | |
HTTP_CONTENT_TYPE: 'application/json', | |
HTTP_ACCEPT: "application/vnd.tameny+json; version=1" | |
} | |
expect(response.status).to eq 422 | |
end | |
it 'Non matching password' do | |
post '/auth/sign_up', { | |
users: { | |
email: user[:email], | |
password: user[:password], | |
password_confirmation: 'invalid_password_confirmation' | |
} | |
}, | |
{ | |
HTTP_CONTENT_TYPE: 'application/json', | |
HTTP_ACCEPT: "application/vnd.tameny+json; version=1" | |
} | |
expect(response.status).to eq 422 | |
end | |
end | |
end | |
describe 'POST authentications#sign_in' do | |
let(:user) { Fabricate(:user) } | |
describe 'with request params' do | |
it 'Returns 201' do | |
post '/auth/sign_in', { | |
email: user.email, password: user.password | |
}, request_headers(user) | |
expect(response.status).to eq 201 | |
expect(response).to render_template('sign_in') | |
expect(user.authentication_tokens.count).to eq 2 | |
end | |
it 'Returns 401' do | |
post '/auth/sign_in', { | |
email: user.email, password: 'invalid' | |
}, request_headers(user) | |
expect(response.status).to eq 401 | |
end | |
end | |
describe 'with request headers' do | |
it 'Returns 201' do | |
post '/auth/sign_in', { | |
email: user.email, password: user.password | |
}, request_headers(user) | |
expect(response.status).to eq 201 | |
expect(response).to render_template('sign_in') | |
expect(user.authentication_tokens.count).to eq 2 | |
end | |
it 'Returns 401' do | |
post '/auth/sign_in', { | |
email: user.email, password: 'invalid' | |
}, request_headers(user) | |
expect(response.status).to eq 401 | |
end | |
end | |
end | |
describe 'DELETE authentications#sign_out' do | |
let(:user) { Fabricate(:user) } | |
it 'Returns 204' do | |
delete '/auth/sign_out', {}, request_headers(user) | |
user.reload | |
expect(response.status).to eq 204 | |
expect(user.authentication_tokens.count).to eq 0 | |
end | |
it 'Returns 401' do | |
delete '/auth/sign_out', {}, { | |
HTTP_CONTENT_TYPE: 'application/json', | |
HTTP_ACCEPT: "application/vnd.tameny+json; version=1", | |
AUTHORIZATION: 'Bearer Invalid' | |
} | |
user.reload | |
expect(response.status).to eq 401 | |
expect(user.authentication_tokens.first.expired?).to be false | |
end | |
end | |
describe 'GET authentications#index' do | |
let(:user) { Fabricate(:user) } | |
it 'Returns 200' do | |
get '/auth/', {}, request_headers(user) | |
expect(response.status).to eq 200 | |
expect(response).to render_template('index') | |
end | |
end | |
describe 'GET authentications#show' do | |
let(:user) { Fabricate(:user) } | |
it 'Returns 200' do | |
auth = user.authentication_tokens.last | |
get "/auth/#{user.authentication_tokens.last.id.to_s}", {}, request_headers(user) | |
expect(response.status).to eq 200 | |
expect(response).to render_template('show') | |
end | |
it 'Returns 404' do | |
get "/auth/invalid", {}, request_headers(user) | |
expect(response.status).to eq 404 | |
end | |
end | |
describe 'DELETE authentications#destroy' do | |
let(:user) { Fabricate(:user) } | |
it 'Returns 204' do | |
auth = AuthenticationToken.generate(user) | |
delete "/auth/#{auth.id}", {}, request_headers(user) | |
expect(response.status).to eq 204 | |
end | |
it 'Returns 404' do | |
delete "/auth/invalid", {}, request_headers(user) | |
expect(response.status).to eq 404 | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# lib/decode_auth_token.rb | |
# Class extending the default authentication token response functionality | |
class DecodedAuthToken < HashWithIndifferentAccess | |
def expired? | |
self[:exp] <= Time.now.to_i | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# spec/support/request_helpers.rb | |
module RequestHelpers | |
def request_headers(user, v = 1) | |
{ | |
HTTP_CONTENT_TYPE: 'application/json', | |
HTTP_ACCEPT: "application/vnd.tameny+json; version=#{v}", | |
AUTHORIZATION: "Bearer #{user.authentication_tokens.first.token}" | |
} | |
end | |
def json | |
@json = JSON.parse(response.body) | |
end | |
end | |
# also include this request helper in the `spec/rails_helper.rb` by adding this | |
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } | |
RSpec.configure do |config| | |
#... yada yada | |
config.include RequestHelpers, type: :request | |
#... yada yada | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# config/routes.rb | |
Rails.application.routes.draw do | |
api vendor_string: 'tameny', default_version: 1, path: '', format: 'json' do | |
version 1 do | |
cache as: 'v1' do | |
resources :authentications, path:'/auth', only: [:index, :show, :destroy] do | |
collection do | |
post 'sign_up' | |
post 'sign_in' | |
delete 'sign_out' | |
end | |
end | |
end | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# app/models/user.rb | |
class User | |
include Mongoid::Document | |
include Mongoid::Token | |
include ActiveModel::SecurePassword | |
has_many :authentication_tokens | |
## Validations | |
has_secure_password | |
validates :email, uniqueness: true, presence: true | |
validates :password, length: { minimum: 8 } | |
validates_confirmation_of :password | |
## Indexes | |
index({ email: 1 }, { unique: true, name: "email_index" }) | |
## Database authenticatable | |
field :email, type: String, default: "" | |
field :password_digest, type: String | |
def self.find_by_credentials(params) | |
@user = User.find_by(email: params[:email]) | |
@user.authenticate(params[:password]) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
MIT