Last active
August 29, 2015 14:04
-
-
Save shreve/198ab6d8049cfeff423d to your computer and use it in GitHub Desktop.
Self-rolled authentication
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
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 |
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
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 |
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
class Account < Sugutoys::Model | |
scope :with_email, ->(email) { where('lower(email) = ?', email.downcase) } | |
has_secure_password | |
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
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 |
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
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