Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
  • 11 commits
  • 23 files changed
  • 0 commit comments
  • 2 contributors
Showing with 435 additions and 15 deletions.
  1. +44 −0 app/controllers/thincloud/authentication/passwords_controller.rb
  2. +12 −0 app/mailers/thincloud/authentication/passwords_mailer.rb
  3. +11 −0 app/models/thincloud/authentication/identity.rb
  4. +10 −0 app/services/thincloud/authentication/password_reset_workflow.rb
  5. +17 −0 app/services/thincloud/authentication/update_identity_password.rb
  6. +35 −0 app/views/thincloud/authentication/passwords/edit.html.erb
  7. +31 −0 app/views/thincloud/authentication/passwords/new.html.erb
  8. +5 −0 app/views/thincloud/authentication/passwords_mailer/password_reset.html.erb
  9. +4 −0 app/views/thincloud/authentication/sessions/_login_form.html.erb
  10. +2 −0  config/routes.rb
  11. +8 −0 db/migrate/20130505230811_add_password_reset_token_and_password_reset_sent_at_to_identities.rb
  12. +14 −3 lib/thincloud/authentication/authenticatable_controller.rb
  13. +3 −0  lib/thincloud/authentication/engine.rb
  14. +88 −0 test/controllers/thincloud/authentication/passwords_controller_test.rb
  15. +3 −3 test/controllers/thincloud/authentication/registrations_controller_test.rb
  16. +3 −1 test/controllers/thincloud/authentication/sessions_controller_test.rb
  17. +2 −0  test/dummy/config/routes.rb
  18. +10 −8 test/dummy/db/schema.rb
  19. +25 −0 test/mailers/thincloud/authentication/passwords_mailer_test.rb
  20. +14 −0 test/models/identity_test.rb
  21. +41 −0 test/services/password_reset_workflow_test.rb
  22. +40 −0 test/services/update_identity_password_test.rb
  23. +13 −0 test/support/database_cleaner.rb
44 app/controllers/thincloud/authentication/passwords_controller.rb
View
@@ -0,0 +1,44 @@
+module Thincloud::Authentication
+ # Public: Handle password reset management
+ class PasswordsController < ApplicationController
+
+ before_filter :find_identity, only: [:edit, :update]
+
+ layout Thincloud::Authentication.configuration.layout
+
+ def new
+ render
+ end
+
+ def create
+ PasswordResetWorkflow.call(params[:email])
+ redirect_to login_url,
+ notice: "Email sent with password reset instructions."
+ end
+
+ def edit
+ render
+ end
+
+ def update
+ if UpdateIdentityPassword.call(@identity, identity_params)
+ login_as @identity.user
+ redirect_to after_password_update_path
+ else
+ render :edit
+ end
+ end
+
+
+ private
+
+ def find_identity
+ @identity = Identity.find_by_password_reset_token!(params[:id])
+ end
+
+ def identity_params
+ params.require(:identity).permit(:password, :password_confirmation)
+ end
+
+ end
+end
12 app/mailers/thincloud/authentication/passwords_mailer.rb
View
@@ -0,0 +1,12 @@
+module Thincloud::Authentication
+ # Public: Email methods for Password events
+ class PasswordsMailer < ActionMailer::Base
+ default from: Thincloud::Authentication.configuration.mailer_sender
+
+ # Password reset notification
+ def password_reset(identity_id)
+ @identity = Identity.find(identity_id)
+ mail to: @identity.email, subject: "Password Reset"
+ end
+ end
+end
11 app/models/thincloud/authentication/identity.rb
View
@@ -77,5 +77,16 @@ def apply_omniauth(omniauth)
self.email = info["email"] if info["email"] && self.email.blank?
self
end
+
+ # Public: Generate a password reset token
+ #
+ # Returns: true
+ #
+ # Raises: ActiveRecord::RecordInvalid
+ def generate_password_token!
+ self.password_reset_token = SecureRandom.urlsafe_base64
+ self.password_reset_sent_at = Time.zone.now
+ save!
+ end
end
end
10 app/services/thincloud/authentication/password_reset_workflow.rb
View
@@ -0,0 +1,10 @@
+module Thincloud::Authentication
+ # Public: Execute the workflow steps to reset a password for an Identity
+ class PasswordResetWorkflow
+ def self.call(email)
+ return unless identity = Identity.find_by_email(email)
+ identity.generate_password_token!
+ PasswordsMailer.password_reset(identity.id).deliver
+ end
+ end
+end
17 app/services/thincloud/authentication/update_identity_password.rb
View
@@ -0,0 +1,17 @@
+module Thincloud::Authentication
+ # Public: Execute the workflow steps to reset a password for an Identity
+ class UpdateIdentityPassword
+
+ def self.call(identity, params)
+ identity.password = params[:password]
+ identity.password_confirmation = params[:password_confirmation]
+ identity.password_reset_token = nil
+ identity.password_reset_sent_at = nil
+ identity.save!
+ rescue ActiveRecord::RecordInvalid
+ identity.reload
+ false
+ end
+
+ end
+end
35 app/views/thincloud/authentication/passwords/edit.html.erb
View
@@ -0,0 +1,35 @@
+<%= form_for @identity, url: password_url(id: @identity.password_reset_token), method: :put do |f| %>
+ <fieldset>
+ <legend>Password Reset</legend>
+
+ <div class="control-group">
+ <%= f.label :password, "Password", class: "control-label" %>
+
+ <div class="controls">
+ <div class="input-prepend">
+ <span class="add-on"><i class="icon-envelope"></i></span>
+ <%= f.password_field :password %>
+ </div>
+ </div>
+ </div>
+
+ <div class="control-group">
+ <%= f.label :password_confirmation, "Password Confirmation", class: "control-label" %>
+
+ <div class="controls">
+ <div class="input-prepend">
+ <span class="add-on"><i class="icon-envelope"></i></span>
+ <%= f.password_field :password_confirmation %>
+ </div>
+ </div>
+ </div>
+
+ <div class="control-group">
+ <div class="controls">
+ <%= button_tag type: "submit", class: "btn btn-large btn-primary" do %>
+ <i class="icon-ok icon-white"></i> Submit
+ <% end %>
+ </div>
+ </div>
+ </fieldset>
+<% end %>
31 app/views/thincloud/authentication/passwords/new.html.erb
View
@@ -0,0 +1,31 @@
+<%= form_tag passwords_url, method: :post do %>
+ <fieldset>
+ <legend>Password Reset</legend>
+
+ <div class="control-group">
+ <%= label_tag :email, "Email", class: "control-label" %>
+
+ <div class="controls">
+ <div class="input-prepend">
+ <span class="add-on"><i class="icon-envelope"></i></span>
+ <%= email_field_tag :email %>
+ </div>
+ </div>
+ </div>
+
+ <div class="control-group">
+ <div class="controls">
+ <%= button_tag type: "submit", class: "btn btn-large btn-primary" do %>
+ <i class="icon-ok icon-white"></i> Submit
+ <% end %>
+
+ or
+
+ <%= link_to login_url, class: "btn btn-large" do %>
+ <i class="icon-user"></i> Login
+ <% end %>
+ </div>
+ </div>
+
+ </fieldset>
+<% end %>
5 app/views/thincloud/authentication/passwords_mailer/password_reset.html.erb
View
@@ -0,0 +1,5 @@
+To reset your password, click the URL below.
+
+<%= edit_password_url(@identity.password_reset_token) %>
+
+If you did not request your password to be reset, just ignore this email and your password will continue to stay the same.
4 app/views/thincloud/authentication/sessions/_login_form.html.erb
View
@@ -42,5 +42,9 @@
<% end %>
</div>
</div>
+
+ <div class="control-group">
+ <%= link_to "Forgot Password?", new_password_url %>
+ </div>
</fieldset>
<% end %>
2  config/routes.rb
View
@@ -10,5 +10,7 @@
get "signup", to: "registrations#new", as: "signup"
get "verify/:token", to: "registrations#verify", as: "verify_token"
+ resources :passwords, only: [:new, :edit, :create, :update]
+
root to: "sessions#new"
end
8 db/migrate/20130505230811_add_password_reset_token_and_password_reset_sent_at_to_identities.rb
View
@@ -0,0 +1,8 @@
+class AddPasswordResetTokenAndPasswordResetSentAtToIdentities < ActiveRecord::Migration
+ def change
+ add_column :thincloud_authentication_identities, :password_reset_token,
+ :string, default: nil
+ add_column :thincloud_authentication_identities, :password_reset_sent_at,
+ :datetime, default: nil
+ end
+end
17 lib/thincloud/authentication/authenticatable_controller.rb
View
@@ -17,8 +17,8 @@ module AuthenticatableController
#
# Returns: An instance of `User` or `nil`.
def current_user
- return nil if session[:uid].blank?
- @current_user ||= User.find(session[:uid])
+ return nil if cookies.signed[:uid].blank?
+ @current_user ||= User.find(cookies.signed[:uid])
end
# Protected: Determine if the current request has a logged in user.
@@ -48,7 +48,11 @@ def authenticate!
# Returns: The `id` of the provided user.
def login_as(user)
reset_session # avoid session fixation
- session[:uid] = user.id
+ cookies.signed[:uid] = {
+ value: user.id,
+ secure: request.ssl?,
+ httponly: true
+ }
end
# Protected: Clear the session of an authenticated user.
@@ -56,6 +60,7 @@ def login_as(user)
# Returns: A new empty session instance.
def logout
reset_session
+ cookies.delete(:uid)
end
# Protected: Provides the URL to redirect to after logging in.
@@ -86,6 +91,12 @@ def after_verification_path
main_app.root_url
end
+ # Protected: Provides the URL to redirect to after a password update.
+ #
+ # Returns: A string.
+ def after_password_update_path
+ main_app.root_url
+ end
end
3  lib/thincloud/authentication/engine.rb
View
@@ -1,3 +1,6 @@
+require "rails"
+require "strong_parameters"
+
module Thincloud
module Authentication
88 test/controllers/thincloud/authentication/passwords_controller_test.rb
View
@@ -0,0 +1,88 @@
+require "minitest_helper"
+
+module Thincloud::Authentication
+ describe PasswordsController do
+ describe "GET new" do
+ before { get :new }
+
+ it { assert_response :success }
+ it { assert_template :new }
+ end
+
+ describe "POST create" do
+ before do
+ PasswordResetWorkflow.expects(:call).with("foo@bar.com")
+ post :create, email: "foo@bar.com"
+ end
+
+ it { assert_response :redirect }
+ it { assert_redirected_to login_url }
+ it {
+ flash[:notice].must_equal(
+ "Email sent with password reset instructions."
+ )
+ }
+ end
+
+ describe "GET edit" do
+ describe "with an invalid id" do
+ it "raises an exception" do
+ -> {
+ get :edit, id: "invalid"
+ }.must_raise(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ describe "with a valid id" do
+ let(:identity) { Identity.new(password_reset_token: "abc123") }
+
+ before do
+ Identity.stubs(:find_by_password_reset_token!).with("abc123").returns(
+ identity
+ )
+ get :edit, id: "abc123"
+ end
+
+ it { assert_response :success }
+ it { assert_template :edit }
+ it { assigns[:identity].must_equal identity }
+ end
+ end
+
+ describe "PUT update" do
+ before do
+ attrs = {
+ name: "test", email: "foo@bar.com", password: "test123",
+ password_confirmation: "test123", password_reset_token: "abc123",
+ password_reset_sent_at: 1.hour.ago, user_id: User.create.id
+ }
+ @identity = Identity.create!(attrs)
+ end
+
+ describe "with invalid identity attributes" do
+ before do
+ put :update, id: "abc123", identity: {
+ password: "xxx1", password_confirmation: "xxx2"
+ }
+ end
+
+ it { assert_response :success }
+ it { assert_template :edit }
+ it { assigns[:identity].must_equal @identity }
+ it { assigns[:identity].errors[:password].wont_be_empty }
+ end
+
+ describe "with valid identity attributes" do
+ before do
+ put :update, id: "abc123", identity: {
+ password: "p@ssw0rd1", password_confirmation: "p@ssw0rd1"
+ }
+ end
+
+ it { assert_response :redirect }
+ it { assert_redirected_to "/" }
+ end
+ end
+
+ end
+end
6 test/controllers/thincloud/authentication/registrations_controller_test.rb
View
@@ -41,7 +41,7 @@ module Thincloud::Authentication
post :create
end
- it { session[:uid].wont_be_nil }
+ it { cookies.signed[:uid].must_equal user.id }
it { assert_response :redirect }
it { assert_redirected_to "/" }
it { flash[:notice].must_equal "You have been logged in." }
@@ -110,7 +110,7 @@ module Thincloud::Authentication
it { assert_response :redirect }
it { assert_redirected_to "/" }
- it { session[:uid].must_be_nil }
+ it { cookies.signed[:uid].must_be_nil }
it { flash[:notice].must_equal "Check your email to verify your registration." }
it { User.count.must_equal 1 }
it { Identity.count.must_equal 1 }
@@ -138,7 +138,7 @@ module Thincloud::Authentication
it { assert_response :redirect }
it { assert_redirected_to "/" }
- it { session[:uid].must_equal assigns[:identity].user.id }
+ it { cookies.signed[:uid].must_equal assigns[:identity].user.id }
it { flash[:alert].must_be_nil }
it { User.count.must_equal 1 }
it { Identity.count.must_equal 1 }
4 test/controllers/thincloud/authentication/sessions_controller_test.rb
View
@@ -9,6 +9,7 @@ module Thincloud::Authentication
it { assert_response :success }
it { assert_template :new }
+ it { cookies.signed[:uid].must_be_nil }
end
describe "when logged in" do
@@ -26,6 +27,7 @@ module Thincloud::Authentication
it { assert_redirected_to "/" }
it { flash[:notice].must_equal "You have been logged out." }
+ it { cookies.signed[:uid].must_be_nil }
end
describe "GET authenticated" do
@@ -40,7 +42,7 @@ module Thincloud::Authentication
describe "logged in" do
before do
User.stubs(:find).with(123).returns(User.new)
- session[:uid] = 123
+ cookies.signed[:uid] = 123
get :authenticated
end
2  test/dummy/config/routes.rb
View
@@ -14,6 +14,8 @@
resources :registrations, only: [:new, :create]
get "signup", to: "registrations#new", as: "signup"
get "verify/:token", to: "registrations#verify", as: "verify_token"
+
+ resources :passwords, only: [:new, :edit, :create, :update]
end
end
18 test/dummy/db/schema.rb
View
@@ -11,19 +11,21 @@
#
# It's strongly recommended to check this file into your version control system.
-ActiveRecord::Schema.define(:version => 20120919182522) do
+ActiveRecord::Schema.define(:version => 20130505230811) do
create_table "thincloud_authentication_identities", :force => true do |t|
- t.integer "user_id", :null => false
- t.string "provider", :default => "identity", :null => false
+ t.integer "user_id", :null => false
+ t.string "provider", :default => "identity", :null => false
t.string "uid"
- t.string "name", :null => false
- t.string "email", :null => false
- t.string "password_digest", :null => false
+ t.string "name", :null => false
+ t.string "email", :null => false
+ t.string "password_digest", :null => false
t.string "verification_token"
t.datetime "verified_at"
- t.datetime "created_at", :null => false
- t.datetime "updated_at", :null => false
+ t.datetime "created_at", :null => false
+ t.datetime "updated_at", :null => false
+ t.string "password_reset_token"
+ t.datetime "password_reset_sent_at"
end
add_index "thincloud_authentication_identities", ["email"], :name => "index_thincloud_authentication_identities_on_email"
25 test/mailers/thincloud/authentication/passwords_mailer_test.rb
View
@@ -0,0 +1,25 @@
+require "minitest_helper"
+
+module Thincloud::Authentication
+ describe PasswordsMailer do
+
+ describe "#password_reset" do
+ let(:identity) do
+ attrs = { email: "email@example.com", password_reset_token: "abc123" }
+ Identity.new(attrs).tap{ |i| i.id = 999 }
+ end
+
+ let(:mail) { PasswordsMailer.password_reset(identity.id) }
+
+ before do
+ Identity.stubs(:find).with(999).returns(identity)
+ end
+
+ it { mail.subject.must_equal "Password Reset" }
+ it { mail.to.must_equal ["email@example.com"] }
+ it { mail.from.must_equal ["app@example.com"] }
+ it { mail.body.encoded.must_match "To reset your password, click the URL below." }
+ it { mail.body.encoded.must_match "/passwords/abc123/edit" }
+ end
+ end
+end
14 test/models/identity_test.rb
View
@@ -104,5 +104,19 @@ module Thincloud::Authentication
it { identity.provider.must_equal "linkedin" }
it { identity.uid.must_equal "xxsdflkjsdf" }
end
+
+ describe "#generate_password_token!" do
+ before do
+ Identity.any_instance.stubs(:save!)
+ end
+
+ it "generates a token and records the time" do
+ identity.password_reset_token.must_be_nil
+ identity.password_reset_sent_at.must_be_nil
+ identity.generate_password_token!
+ identity.password_reset_token.wont_be_nil
+ identity.password_reset_sent_at.wont_be_nil
+ end
+ end
end
end
41 test/services/password_reset_workflow_test.rb
View
@@ -0,0 +1,41 @@
+require "minitest_helper"
+
+module Thincloud::Authentication
+ describe PasswordResetWorkflow do
+ subject { PasswordResetWorkflow }
+
+ it { subject.must_respond_to :call }
+
+ describe "without a valid email" do
+ before do
+ Identity.expects(:find_by_email).with("foo@bar.com")
+ Identity.any_instance.expects(:generate_password_token!).never
+ PasswordsMailer.expects(:password_reset).never
+ end
+
+ it { subject.call("foo@bar.com") }
+ end
+
+ describe "with a valid email" do
+ let(:identity) do
+ attrs = { email: "foo@bar.com", password_reset_token: "abc123" }
+ Identity.new(attrs).tap { |i| i.id = 999 }
+ end
+
+ before do
+ Identity.stubs(:find).with(999).returns(identity)
+ Identity.stubs(:find_by_email).with("foo@bar.com").returns(identity)
+ identity.expects(:generate_password_token!)
+ end
+
+ it "sends an email with the reset token" do
+ subject.call("foo@bar.com")
+ email = ActionMailer::Base.deliveries.first
+ email.to.must_include "foo@bar.com"
+ email.subject.must_equal "Password Reset"
+ email.body.encoded.must_match "/passwords/abc123/edit"
+ end
+ end
+
+ end
+end
40 test/services/update_identity_password_test.rb
View
@@ -0,0 +1,40 @@
+require "minitest_helper"
+
+module Thincloud::Authentication
+ describe UpdateIdentityPassword do
+ subject { UpdateIdentityPassword }
+
+ it { subject.must_respond_to :call }
+
+ before do
+ attrs = {
+ name: "test", email: "foo@bar.com", user_id: User.create.id,
+ password: "test123", password_confirmation: "test123",
+ password_reset_token: "abc123", password_reset_sent_at: 1.hour.ago
+ }
+ @identity = Identity.create!(attrs)
+ end
+
+ describe "with an invalid password combination" do
+ it "does not update the identity" do
+ password_params = { password: "foo", password_confirmation: "bar" }
+ subject.call(@identity, password_params).must_equal false
+ @identity.errors[:password].wont_be_empty
+ @identity.reload.password_reset_token.wont_be_nil
+ @identity.password_reset_sent_at.wont_be_nil
+ end
+ end
+
+ describe "with a valid password combination" do
+ it "does updates the identity" do
+ password_params = {
+ password: "s3kr1t123", password_confirmation: "s3kr1t123"
+ }
+ subject.call(@identity, password_params).must_equal true
+ @identity.errors[:password].must_be_empty
+ @identity.reload.password_reset_token.must_be_nil
+ @identity.password_reset_sent_at.must_be_nil
+ end
+ end
+ end
+end
13 test/support/database_cleaner.rb
View
@@ -0,0 +1,13 @@
+require "database_cleaner"
+
+DatabaseCleaner.strategy = :truncation
+
+class MiniTest::Spec
+ before :each do
+ DatabaseCleaner.start
+ end
+
+ after :each do
+ DatabaseCleaner.clean
+ end
+end

No commit comments for this range

Something went wrong with that request. Please try again.