Skip to content

Instantly share code, notes, and snippets.

@shreve
Last active August 29, 2015 14:04
Show Gist options
  • Save shreve/198ab6d8049cfeff423d to your computer and use it in GitHub Desktop.
Save shreve/198ab6d8049cfeff423d to your computer and use it in GitHub Desktop.
Self-rolled authentication
class ApplicationController < ActionController::Base
before_action :session_hook
protected
# Models don't have access to the request's session, so we sneak it in here.
# Shh, don't tell anyone.
def session_hook
accessor = instance_variable_get :@_request
Session.send(:define_method, "session", proc { accessor.session })
Session.send(:define_method, "reset_session", proc { accessor.reset_session })
end
end
class SessionsController < ApplicationController
include ActionView::Helpers::TextHelper
before_action :require_no_account, only: [:new, :create]
before_action :set_session
def new
flash.alert = @session.error_message if @session.error_message
end
def create
# Preserve the guest's cart before resetting the session
existing_cart = current_cart
if @session.login(session_params[:email], session_params[:password])
existing_cart.belongs_to(current_account)
redirect_to login_complete_url, flash: { success: t('login.success') }
else
flash.alert = @session.error_message if @session.error_message
render :new
end
end
def destroy
@session.destroy
redirect_to root_url, flash: { success: t('logout.message') }
end
private
def session_params
params.require(:session).permit(:email, :password)
end
def set_session
@session = Session.new
end
def login_complete_url
if session[:return_to_location]
session.delete(:return_to_location)
else
root_url
end
end
end
class Account < Sugutoys::Model
scope :with_email, ->(email) { where('lower(email) = ?', email.downcase) }
has_secure_password
end
class Session
# Errors, Rails helper compatability
extend ActiveModel::Naming
# Capitalize and pluralize methods
include ActionView::Helpers::TextHelper
def initialize
build
@errors = ActiveModel::Errors.new(self)
end
attr_reader :errors
def login(email, password)
# This call replaces activemodels' validate
# e.g. validates :email, :password, presence: true
validate(email, password)
if can_attempt_login_now? and errors.blank?
# Find the account by email, or create a new one with that address
account = Account.with_email(email).first_or_initialize(email: email)
# If you just created a new account, don't even bother.
# authenticate is provided by has_secure_password
if account.persisted? and account.authenticate(password)
set_account(account)
true # Login Successful
else
record_failure(account)
false # Login Unsuccessful
end
end
end
# Set the flash message. Don't worry about field-specific errors here.
def error_message
if !can_attempt_login_now?
too_many_failures_message
elsif (session[:consecutive_failed_logins] > 0) || errors.any?
I18n.t('login.failure')
end
end
def account
@account ||= (Account.where(id: session[:account_id]).first || Account.new)
end
def email
account.email
end
def refresh
destroy and build
end
def destroy
reset_session
end
def too_many_failures_message
if too_many_failures?
I18n.t('login.too_many_failures', time_left: pluralize(minutes_left_to_next_attempt, 'minute'))
end
end
# Rails Compatability
# these deal with how to display attributes as form elements, and how to
# route requests using form_for(@session)
def to_key; nil; end
class << self
def model_name; ActiveModel::Name.new(Session); end
def human_attribute_name(attr, options = {}); capitalize(attr); end
def lookup_ancestors; [self]; end
end
private
# Set default values for the session variables we use
def build
session[:account_id] ||= false
session[:consecutive_failed_logins] ||= 0
session[:last_failed_login_attempt] ||= 100.years.ago
end
# validates :email, :password, presence: true
def validate(email, password)
errors.add(:email, I18n.t('errors.messages.blank')) if email.blank?
errors.add(:password, I18n.t('errors.messages.blank')) if password.blank?
end
# Login success behavior
def set_account(account)
@account = account
refresh # prevent session hijacking
session[:account_id] = account.id
end
# Login failure behavior
def record_failure(account)
if account.new_record?
errors.add(:email, I18n.t('login.no_account_with_that_email'))
else
errors.add(:password, I18n.t('errors.messages.incorrect'))
# Only expose email which we know is in the system
@account = Account.new(email: email)
end
session[:consecutive_failed_logins] += 1
session[:last_failed_login_attempt] = Time.now
end
# Please seek immediate psychiatric help if you can't understand
# what's happening here.
def can_attempt_login_now?
!too_many_failures? or !last_failure_too_recent?
end
def too_many_failures?
session[:consecutive_failed_logins] > 5
end
def last_failure_too_recent?
session[:last_failed_login_attempt].to_time > 5.minutes.ago
end
def minutes_left_to_next_attempt
next_attempt_allowed_at = session[:last_failed_login_attempt].to_time + 5.minutes
seconds_left = (next_attempt_allowed_at - Time.now).seconds
(seconds_left / 60).ceil
end
end
Rails.application.routes.draw do
# Session routes
# Everyone logs in from the same place (no manage/sessions routes).
get '/login', to: 'sessions#new', as: :login
post '/login', to: 'sessions#create', as: :session
match '/logout', to: 'sessions#destroy', as: :logout, via: [:get, :delete]
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment