forgot password implemented + refactoring

did committed Nov 10, 2016
commit 6ce6273ecf3e62f8ac0120752fda4a425ac01ace
Showing 11 changed files with 332 additions and 45 deletions
locomotive/steam/middlewares/auth.rb b/lib/locomotive/steam/middlewares/auth.rb +108 -43
@@ @@ -7,43 +7,61 @@ module Locomotive::Steam
# - reset password
# - sign out
#
+ # It is also in charge to load the current authenticated resource
+ # from the session and put it in the liquid context.
+ #
class Auth < ThreadSafe
include Helpers
- ACTIONS = %w(sign_in sign_out send_password_reset reset_password)
-
def _call
load_authenticated_entry
- case params[:auth_action]
- when 'sign_in' then sign_in
- when 'sign_out' then sign_out
- end
+ auth_options = AuthOptions.new(params)
- # if ACTIONS.include?(params[:auth])
- # sign_in if params[:auth] == 'sign_in'
- # end
+ return unless auth_options.valid?
+
+ send(:"#{auth_options.action}", auth_options)
end
private
- def load_authenticated_entry
- if (entry_id = request.session[:authenticated_entry_id]) &&
- (entry_type = request.session[:authenticated_entry_type])
+ def sign_in(options)
+ return if authenticated?
- env['authenticated_entry'] = find_entry(entry_type, entry_id)
+ status, entry = services.auth.sign_in(options)
- liquid_assigns["current_#{entry_type.singularize}"] = decorate_entry(env['authenticated_entry'])
+ if status == :signed_in
+ store_authenticated(entry)
+ redirect_to options.callback || mounted_on
end
+
+ append_message(status)
end
- def find_entry(type, id)
- begin
- repositories.content_entry.with(type).find(id)
- rescue Exception => e
- log "Unable to find the authenticated entry: #{type}, #{id}"
- nil
+ def sign_out(options)
+ return unless authenticated?
+
+ store_authenticated(nil)
+
+ append_message(:signed_out)
+ end
+
+ def forgot_password(options)
+ return if authenticated?
+
+ status = services.auth.forgot_password(options, default_liquid_context)
+
+ append_message(status)
+ end
+
+ def load_authenticated_entry
+ entry_type = request.session[:authenticated_entry_type]
+ entry_id = request.session[:authenticated_entry_id]
+
+ if entry = services.auth.find_authenticated_resource(entry_type, entry_id)
+ env['authenticated_entry'] = entry
+ liquid_assigns["current_#{entry_type.singularize}"] = entry
end
end
@@ @@ -51,39 +69,86 @@ module Locomotive::Steam
!!env['authenticated_entry']
end
- def sign_out
- return unless authenticated?
+ def store_authenticated(entry)
+ type = entry ? entry.content_type.slug : request.session[:authenticated_entry_type]
- type = request.session[:authenticated_entry_type]
+ request.session[:authenticated_entry_type] = type
+ request.session[:authenticated_entry_id] = entry.try(:_id)
- request.session[:authenticated_entry_id] = nil
- request.session[:authenticated_entry_type] = nil
+ liquid_assigns["current_#{type.singularize}"] = entry
+ end
- liquid_assigns["current_#{type.singularize}"] = nil
- liquid_assigns['auth_signed_out'] = 'auth_signed_out'
+ def append_message(message)
+ message ||= 'error'
+ liquid_assigns["auth_#{message}"] = "auth_#{message}"
end
- def sign_in
- unless authenticated?
- if type = repositories.content_type.by_slug(params[:auth_content_type])
- id_field = params[:auth_id_field] || :email
+ class AuthOptions
- entry = repositories.content_entry.with(type).all(id_field => params[:auth_id]).first
+ ACTIONS = %w(sign_in sign_out forgot_password reset_password)
- if entry && entry.password == params[:auth_password]
- request.session[:authenticated_entry_id] = entry._id
- request.session[:authenticated_entry_type] = type.slug
+ attr_reader :params
- liquid_assigns["current_#{type.slug.singularize}"] = decorate_entry(entry)
+ def initialize(params)
+ @params = params
+ end
- redirect_to params[:auth_callback] || mounted_on
- else
- liquid_assigns['auth_wrong_credentials'] = 'auth_wrong_credentials'
- end
- else
- log "'#{params[:auth_content_type]}' is not a content type for authentication."
- end
+ def valid?
+ ACTIONS.include?(action)
end
+
+ def action
+ params[:auth_action]
+ end
+
+ def type
+ params[:auth_content_type]
+ end
+
+ def id_field
+ params[:auth_id_field] || :email
+ end
+
+ def password_field
+ params[:auth_password_field].try(:to_sym) || :password
+ end
+
+ def id
+ params[:auth_id]
+ end
+
+ def password
+ params[:auth_password]
+ end
+
+ def callback
+ params[:auth_callback]
+ end
+
+ def reset_password_url
+ params[:auth_reset_password_url]
+ end
+
+ def from
+ params[:auth_email_from] || 'support@locomotivecms.com'
+ end
+
+ def subject
+ params[:auth_email_subject] || 'Instructions for changing your password'
+ end
+
+ def email_handle
+ params[:auth_email_handle]
+ end
+
+ def smtp
+ {
+ address: params[:auth_email_smtp_address],
+ user_name: params[:auth_email_smtp_user_name],
+ password: params[:auth_email_smtp_password]
+ }
+ end
+
end
end
locomotive/steam/middlewares/thread_safe.rb b/lib/locomotive/steam/middlewares/thread_safe.rb +10 -0
@@ @@ -74,6 +74,16 @@ module Locomotive::Steam::Middlewares
Locomotive::Steam::Decorators::I18nDecorator.new(entry, locale, default_locale)
end
+ def default_liquid_context
+ ::Liquid::Context.new({ 'site' => site.to_liquid }, {}, {
+ request: request,
+ locale: locale,
+ site: site,
+ services: services,
+ repositories: services.repositories
+ }, true)
+ end
+
end
end
locomotive/steam/services.rb b/lib/locomotive/steam/services.rb +5 -1
@@ @@ -79,7 +79,7 @@ module Locomotive
end
register :url_builder do
- Steam::UrlBuilderService.new(current_site, locale, request)
+ Steam::UrlBuilderService.new(current_site, locale, request, page_finder)
end
register :theme_asset_url do
@@ @@ -118,6 +118,10 @@ module Locomotive
Steam::EmailService.new(page_finder, liquid_parser, asset_host, configuration.mode == :test)
end
+ register :auth do
+ Steam::AuthService.new(content_entry, email)
+ end
+
register :cache do
Steam::NoCacheService.new
end
locomotive/steam/services/auth_service.rb b/lib/locomotive/steam/services/auth_service.rb +78 -0
@@ @@ -0,0 +1,78 @@
+ module Locomotive
+ module Steam
+
+ class AuthService
+
+ attr_accessor_initialize :entries, :email_service
+
+ def find_authenticated_resource(type, id)
+ entries.find(type, id)
+ end
+
+ def sign_in(options)
+ entry = entries.all(options.type, options.id_field => options.id).first
+
+ if entry
+ hashed_password = entry[:"#{options.password_field}_hash"]
+ password = ::BCrypt::Engine.hash_secret(options.password, entry.send(options.password_field).try(:salt))
+ same_password = secure_compare(password, hashed_password)
+
+ return [:signed_in, entry] if same_password
+ end
+
+ :wrong_credentials
+ end
+
+ # options is an instance of the AuthOptions class
+ def forgot_password(options, context)
+ entry = entries.all(options.type, options.id_field => options.id).first
+
+ if entry.nil?
+ :wrong_email
+ else
+ entries.update_decorated_entry(entry, {
+ '_auth_reset_token' => SecureRandom.hex,
+ '_auth_reset_sent_at' => Time.zone.now
+ })
+
+ context['reset_password_url'] = options.reset_password_url + '?token=' + entry['_auth_reset_token']
+ context[options.type.singularize] = entry
+
+ send_reset_password_instructions(options, context)
+
+ :reset_password_instructions_sent
+ end
+ end
+
+ private
+
+ def send_reset_password_instructions(options, context)
+ email_options = { from: options.from, to: options.id, subject: options.subject, smtp: options.smtp }
+
+ if options.email_handle
+ email_options[:page_handle] = options.email_handle
+ else
+ email_options[:body] = <<-EMAIL
+ Hi,
+ To reset your password please follow the link below: #{context['reset_password_url']}.
+ Thanks!
+ EMAIL
+ end
+
+ email_service.send_email(email_options, context)
+ end
+
+ # https://github.com/plataformatec/devise/blob/88724e10adaf9ffd1d8dbfbaadda2b9d40de756a/lib/devise.rb#L485
+ def secure_compare(a, b)
+ return false if a.blank? || b.blank? || a.bytesize != b.bytesize
+ l = a.unpack "C#{a.bytesize}"
+
+ res = 0
+ b.each_byte { |byte| res |= byte ^ l.shift }
+ res == 0
+ end
+
+ end
+
+ end
+ end
locomotive/steam/services/content_entry_service.rb b/lib/locomotive/steam/services/content_entry_service.rb +16 -0
@@ @@ -56,6 +56,22 @@ module Locomotive
end
end
+ def update_decorated_entry(decorated_entry, attributes)
+ with_repository(decorated_entry.content_type) do |_repository|
+ entry = decorated_entry.__getobj__
+
+ puts clean_attributes(attributes).inspect
+
+ entry.change(clean_attributes(attributes))
+
+ _repository.update(entry)
+
+ logEntryOperation(decorated_entry.content_type.slug, decorated_entry)
+
+ decorated_entry
+ end
+ end
+
def delete(type_slug, id_or_slug)
with_repository(type_slug) do |_repository|
entry = _repository.by_slug(id_or_slug) || _repository.find(id_or_slug)
locomotive/steam/services/url_builder_service.rb b/lib/locomotive/steam/services/url_builder_service.rb +7 -1
@@ @@ -3,9 +3,15 @@ module Locomotive
class UrlBuilderService
- attr_accessor_initialize :site, :current_locale, :request
+ attr_accessor_initialize :site, :current_locale, :request, :page_finder
+
+ def absolute_url_for(page, locale = nil)
+ request.base_url + url_for(page, locale)
+ end
def url_for(page, locale = nil)
+ page = page_finder.by_handle(page) if page.is_a?(String)
+
prefix(_url_for(page, locale))
end
spec/fixtures/default/app/views/pages/account/forgot_password.liquid +39 -0
@@ @@ -0,0 +1,39 @@
+ ---
+ title: Forgot password
+ published: true
+ listed: false
+ handle: forgot_password
+ ---
+ {% extends 'index' %}
+
+ {% block content %}
+
+ <h1>Forgot your password</h1>
+
+ {% if current_account %}
+ <div class="alert alert-warning">
+ You're already authenticated!
+ </div>
+ {% else %}
+ {% if auth_reset_password_instructions_sent %}
+ {{ auth_reset_password_instructions_sent | translate }}
+ {% else %}
+ <form action="{% path_to 'forgot_password' %}" method="POST">
+ <input type="hidden" name="auth_action" value="forgot_password" />
+ <input type="hidden" name="auth_content_type" value="accounts" />
+ <input type="hidden" name="auth_id_field" value="email" />
+ <input type="hidden" name="auth_callback" value="{% path_to sign_in %}" />
+
+ {% if auth_wrong_email %}
+ {{ auth_wrong_email | translate }}
+ {% endif %}
+
+ <label for="auth-email">Your E-mail</label>
+ <input type="email" id="auth-email" placeholder="Email" name="auth_id" value="{{ params.auth_id }}">
+
+ <button type="submit" class="btn btn-default">Submit</button>
+ </form>
+ {% endif %}
+ {% endif %}
+
+ {% endblock %}
spec/fixtures/default/app/views/pages/account/reset_password.liquid +1 -0
@@ @@ -0,0 +1 @@
+ reset_password.liquid
spec/fixtures/default/app/views/pages/emails/reset_password.liquid +15 -0
@@ @@ -0,0 +1,15 @@
+ ---
+ title: Reset password instructions
+ listed: false
+ published: true
+ handle: reset_password_instructions
+ ---
+ Hello {{ account.name }},
+
+ To reset your password please follow the link below:
+
+ {{ reset_password_url }}
+
+ If you need help or have any questions, please visit https://www.locomotivecms.com
+ Thanks!
+ Locomotive
spec/fixtures/default/config/translations.yml +8 -0
@@ @@ -9,3 +9,11 @@ auth_wrong_credentials:
auth_signed_out:
en: You've been signed out
fr: Vous avez été déconnecté(e)
+
+ auth_wrong_email:
+ en: Your email is unknown
+ fr: Votre email est inconnu
+
+ auth_reset_password_instructions_sent:
+ en: "The instructions for changing your password have been emailed to you"
+ fr: "Les instructions pour modifier votre mot de passe viennent de vous être envoyées"
spec/integration/server/auth_spec.rb +45 -0
@@ @@ -90,6 +90,51 @@ describe 'Authentication' do
end
+ describe 'forgot password action' do
+
+ let(:email) { '' }
+
+ let(:params) { {
+ auth_action: 'forgot_password',
+ auth_content_type: 'accounts',
+ auth_id_field: 'email',
+ auth_id: email,
+ auth_reset_password_url: 'http://acme.com/account/reset-password',
+ auth_callback: '/account/sign-in',
+ auth_email_from: 'support@acme.com',
+ auth_email_handle: 'reset_password_instructions',
+ auth_email_smtp_address: 'smtp.nowhere.net',
+ auth_email_smtp_user_name: 'jane',
+ auth_email_smtp_password: 'easyone'
+ } }
+
+ it 'renders the forgot password page with an error message' do
+ forgot_password
+ expect(last_response.status).to eq 200
+ expect(last_response.body).to include 'Forgot your password'
+ expect(last_response.body).to include 'Your email is unknown'
+ end
+
+ context 'with an known email' do
+
+ let(:email) { 'john@doe.net' }
+
+ it 'sends an email to the account' do
+ forgot_password
+ expect(last_response.status).to eq 200
+ expect(last_response.body).to include "The instructions for changing your password have been emailed to you"
+ end
+
+ end
+
+ def forgot_password(follow_redirect = false)
+ post '/account/forgot-password', params
+ follow_redirect! if follow_redirect
+ last_response
+ end
+
+ end
+
end