Skip to content

Instantly share code, notes, and snippets.

@a14m
Created January 16, 2015 06:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save a14m/d997c081a12aa51ee42c to your computer and use it in GitHub Desktop.
Save a14m/d997c081a12aa51ee42c to your computer and use it in GitHub Desktop.
Rails JWT authentication
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
@nazgu1
Copy link

nazgu1 commented Dec 17, 2015

Hello,
I would like to ask how you license this code fragment.

@a14m
Copy link
Author

a14m commented May 1, 2016

MIT

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment