Last active
July 4, 2018 15:12
-
-
Save epintos/9a9c253dab63082e946d72f07e546334 to your computer and use it in GitHub Desktop.
Authentication For Rails Training
This file contains 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_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 |
This file contains 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/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 |
This file contains 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/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 |
This file contains 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/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 |
This file contains 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/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 |
This file contains 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/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 |
This file contains 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
# .... | |
gem 'versionist' | |
gem 'jwt' | |
# .... |
This file contains 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 | |
# ... | |
# 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 | |
# ... |
This file contains 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/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 |
This file contains 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 | |
# ... | |
# Hooks | |
before_validation :generate_verification_code, on: :create | |
def generate_verification_code | |
self.verification_code = AuthenticableEntity.verification_code | |
end | |
# ... |
This file contains 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
# 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 |
This case can be removed as it will only enter on any error.
Change this line with:
decoded_auth_token = authentication_manager.decoded_auth_token
access_token = authentication_manager.renew_access_token(decoded_auth_token)
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
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?