Skip to content

Instantly share code, notes, and snippets.

@epintos
Last active July 4, 2018 15:12
Show Gist options
  • Save epintos/9a9c253dab63082e946d72f07e546334 to your computer and use it in GitHub Desktop.
Save epintos/9a9c253dab63082e946d72f07e546334 to your computer and use it in GitHub Desktop.
Authentication For Rails Training
# app/controllers/api_controller.rb
class ApiController < ApplicationController
rescue_from ActionController::ParameterMissing, with: :render_nothing_bad_req
rescue_from ActiveRecord::RecordNotFound, with: :render_nothing_bad_req
protect_from_forgery with: :null_session
before_action :current_user, :authenticate_request
private
# Serializer methods
def default_serializer_options
{ root: false }
end
def current_user
@current_user ||= authentication_manager.current_user
end
def authentication_manager
@authentication_manager ||= AuthenticationManager.new(request.headers)
end
def authenticate_request
data = authentication_manager.authenticate_request
format_authentication_data(data)
end
# This is a before action method. Returns false to stop from executing the other
# before action methods when it fails
def format_authentication_data(data)
return unless data.present?
response.headers.merge!(data[:headers]) if data[:headers].present?
return unless data[:body].present?
render json: data[:body], status: status_for_response(data[:code])
false
end
def status_for_response(code)
case code
when AuthenticationManager::NOT_AUTH_CODE
401
when AuthenticationManager::TOKEN_EXPIRED_CODE
401
when AuthenticationManager::SUCCESS_CODE
200
end
end
def render_nothing_bad_req
head :bad_request
end
end
# app/authentication/authenticable_entity.rb
class AuthenticableEntity
MAXIMUM_USEFUL_DATES = 30.days
EXPIRATION_DATES = 2.days
WARNING_EXPIRATION_DATES = 5.hours
RENEW_ID_CHARACTERS = 32
VERIFICATION_CODE_CHARACTERS = 64
class << self
def generate_access_token(entity)
renew_id = Devise.friendly_token(RENEW_ID_CHARACTERS)
payload = { "#{entity.class.name.underscore}_id" => entity.id }
payload = add_secure_attrs(payload, renew_id, entity)
{ token: AuthenticationTokenManager.encode(payload), renew_id: renew_id }
end
def renew_access_token(decoded_auth_token)
payload = decoded_auth_token
now = Time.zone.now
payload[:expiration_date] = expiration_date(now)
payload[:warning_expiration_date] = warning_expiration_date(now)
AuthenticationTokenManager.encode(payload)
end
def verification_code
Devise.friendly_token(VERIFICATION_CODE_CHARACTERS)
end
private
def add_secure_attrs(payload, renew_id, client)
now = Time.zone.now
payload.merge!(
verification_code: client.verification_code,
renew_id: renew_id,
maximum_useful_date: maximum_useful_date(now),
expiration_date: expiration_date(now),
warning_expiration_date: warning_expiration_date(now)
)
end
def maximum_useful_date(now)
(now + MAXIMUM_USEFUL_DATES).to_i
end
def expiration_date(now)
(now + EXPIRATION_DATES).to_i
end
def warning_expiration_date(now)
(now + WARNING_EXPIRATION_DATES).to_i
end
end
private_class_method :add_secure_attrs, :maximum_useful_date, :expiration_date,
:warning_expiration_date
end
# app/authentication/authentication_decoded_token.rb
class AuthenticationDecodedToken < HashWithIndifferentAccess
def expired?
return false unless self[:expiration_date].present?
Time.zone.now.to_i > self[:expiration_date]
end
def valid_verification_code?
return true unless self[:verification_code].present?
User.find(self[:user_id]).verification_code == self[:verification_code]
end
def warning_expiration_date_reached?
return false unless self[:warning_expiration_date].present?
Time.zone.now.to_i >= self[:warning_expiration_date]
end
def valid_renew_id?(renew_id)
return true unless self[:renew_id].present? && renew_id.present?
renew_id == self[:renew_id]
end
def able_to_renew?
return true unless self[:expiration_date].present? && self[:maximum_useful_date].present?
self[:expiration_date] < self[:maximum_useful_date]
end
end
# app/authentication/authentication_manager.rb
class AuthenticationManager
NOT_AUTH_CODE = 1
TOKEN_EXPIRED_CODE = 2
SUCCESS_CODE = 3
attr_reader :headers
delegate :warning_expiration_date_reached?, to: :decoded_auth_token
delegate :able_to_renew?, to: :decoded_auth_token
delegate :valid_renew_id?, to: :decoded_auth_token
def initialize(headers)
@headers = headers
end
def current_user
return nil unless decoded_auth_token.present?
@current_user ||= User.find_by(id: decoded_auth_token[:user_id])
end
def authenticate_request
return auth_token_expired_response if auth_token_expired?
return not_authenticated_response if current_user.nil?
return invalid_verification_code_response if auth_token_invalid_verification_code?
return expiration_warning_response if auth_token_warning_expiration_date_reached?
end
def authenticate_admin_request
return auth_token_expired_response if auth_token_expired?
return not_authenticated_response if current_user.nil?
end
def decoded_auth_token
@decoded_auth_token ||= AuthenticationTokenManager.decode(authorization_header)
end
def renew_access_token(decoded_auth_token)
AuthenticableEntity.renew_access_token(decoded_auth_token)
end
private
def auth_token_expired_response
{ body: { error: 'Auth token is expired' }, code: TOKEN_EXPIRED_CODE }
end
def not_authenticated_response
{ body: { error: 'Not Authorized' }, code: NOT_AUTH_CODE }
end
def invalid_verification_code_response
{ body: { error: 'Not Authorized' }, code: NOT_AUTH_CODE }
end
def expiration_warning_response
{
body: {}, code: SUCCESS_CODE, headers: {
'X-Expiration-Warning' => decoded_auth_token[:expiration_date].to_s
}
}
end
def auth_token_invalid_verification_code?
decoded_auth_token && current_user.present? && !decoded_auth_token.valid_verification_code?
end
def auth_token_warning_expiration_date_reached?
decoded_auth_token && decoded_auth_token.warning_expiration_date_reached?
end
def auth_token_expired?
decoded_auth_token && decoded_auth_token.expired?
end
def authorization_header
return @authorization_header if defined? @authorization_header
return nil unless headers['Authorization'].present?
@authorization_header = headers['Authorization'].split(' ').last
end
end
# app/authentication/authentication_token_manager.rb
class AuthenticationTokenManager
class << self
def encode(payload)
JWT.encode(payload, Rails.application.secrets.secret_key_base)
end
def decode(token)
payload = JWT.decode(token, Rails.application.secrets.secret_key_base)[0]
AuthenticationDecodedToken.new(payload)
rescue
nil
end
end
end
# app/authentication/authentication_unique_token.rb
class AuthenticationUniqueToken
class << self
# I'm not checking the uniqueness because its unlikely to happen
def generate
SecureRandom.hex(16)
end
end
end
# ....
gem 'versionist'
gem 'jwt'
# ....
# config/routes.rb
# ...
# API Endpoints
api_version(module: 'api/v1', path: { value: 'api/v1' }, defaults: { format: :json }) do
resources :users do
collection do
resources :sessions, only: [:create] do
collection do
post :renew
post :invalidate_all
end
end
end
end
end
# ...
# app/controllers/api/v1/sessions_controller.rb
module Api
module V1
class SessionsController < ApplicationController
skip_before_action :current_user, :authenticate_request, except: [:renew, :invalidate_all]
def create
if authenticated_user?
token_data = AuthenticableEntity.generate_access_token(user)
render json: {
access_token: token_data[:token], renew_id: token_data[:renew_id]
}, status: :ok
else
render_error('Invalid email or password', :unauthorized)
end
end
# TODO: Refactor and remove rubocop exception
# rubocop:disable Metrics/AbcSize
def renew
if !authentication_manager.warning_expiration_date_reached?
render_error('Warning expiration date has not been reached', :forbidden)
elsif !authentication_manager.valid_renew_id?(renew_token_params[:renew_id])
render_error('Invalid renew_id', :unauthorized)
elsif !authentication_manager.able_to_renew?
render_error('Access token is not valid anymore', :unauthorized)
else
access_token = authentication_manager.renew_access_token(current_user)
render json: { access_token: access_token }, status: :ok
end
end
# rubocop:enable Metrics/AbcSize
def invalidate_all
current_user.generate_verification_code
if current_user.save
head :ok
else
render json: { error: 'Error invalidating all tokens' }, status: 500
end
end
private
def render_error(error_message, status)
render json: { error: error_message }, status: status
end
def authenticated_user?
user.present? && user.valid_password?(authenticate_params[:password])
end
def user
@user ||= User.find_by(email: authenticate_params[:email])
end
def authenticate_params
params.require(:sessions).permit(:email, :password)
end
def renew_token_params
params.require(:sessions).permit(:renew_id)
end
def authentication_manager
@authentication_manager ||= AuthenticationManager.new(request.headers)
end
end
end
end
# app/models/user.rb
# ...
# Hooks
before_validation :generate_verification_code, on: :create
def generate_verification_code
self.verification_code = AuthenticableEntity.verification_code
end
# ...
# db/migrate/XXXX_add_verification_code_to_users.rb
class AddVerificationCodeToUsers < ActiveRecord::Migration[5.0]
def change
add_column :users, :verification_code, :string, null: false
end
end
@alebian
Copy link

alebian commented Mar 2, 2017

application_controller.rb line 6:
current_user could be taken out from here: if any child controller calls for current_user, the method will be called "lazily" and it won't be called at all by the endpoint that don't need the user info (saving a db query). Also this saves the need of excepting the call in sessions_controller.rb line 5.

sessions_controller.rb line 5:
Instead of except it would be better to use only: [:create]

Migration of users:
Do you think will be necessary to fill already created users with some value? Because you are setting null: false in the database. Also that validation is not written in the model.

user.rb line 4:
I think you should remove this completely. It is better to explicitly handle this in the creation of the token

authenticable_entity.rb line 17:
Shouldn't the renew_id be generated again after the renew of the token?

@mishuagopian
Copy link

mishuagopian commented Mar 13, 2017

This case can be removed as it will only enter on any error.

@mishuagopian
Copy link

Change this line with:

decoded_auth_token = authentication_manager.decoded_auth_token
access_token = authentication_manager.renew_access_token(decoded_auth_token)

@petaalcala
Copy link

I'm not really sure but I think we should change the inheritance of SessionController from ApplicationController to ApiController, the current user method is not defined in ApplicationController

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