Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Merge pull request #32 from phlipper/password-reset

Password Reset Functionality
  • Loading branch information...
commit 347fcf1e24f0bce60abf6b5079667a99eeab3ada 2 parents 013dd8d + 072ebdb
@elskwid elskwid authored
Showing with 418 additions and 8 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. +6 −0 lib/thincloud/authentication/authenticatable_controller.rb
  13. +88 −0 test/controllers/thincloud/authentication/passwords_controller_test.rb
  14. +2 −0  test/dummy/config/routes.rb
  15. +10 −8 test/dummy/db/schema.rb
  16. +25 −0 test/mailers/thincloud/authentication/passwords_mailer_test.rb
  17. +14 −0 test/models/identity_test.rb
  18. +41 −0 test/services/password_reset_workflow_test.rb
  19. +40 −0 test/services/update_identity_password_test.rb
  20. +13 −0 test/support/database_cleaner.rb
View
44 app/controllers/thincloud/authentication/passwords_controller.rb
@@ -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
View
12 app/mailers/thincloud/authentication/passwords_mailer.rb
@@ -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
View
11 app/models/thincloud/authentication/identity.rb
@@ -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
View
10 app/services/thincloud/authentication/password_reset_workflow.rb
@@ -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
View
17 app/services/thincloud/authentication/update_identity_password.rb
@@ -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
View
35 app/views/thincloud/authentication/passwords/edit.html.erb
@@ -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 %>
View
31 app/views/thincloud/authentication/passwords/new.html.erb
@@ -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 %>
View
5 app/views/thincloud/authentication/passwords_mailer/password_reset.html.erb
@@ -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.
View
4 app/views/thincloud/authentication/sessions/_login_form.html.erb
@@ -42,5 +42,9 @@
<% end %>
</div>
</div>
+
+ <div class="control-group">
+ <%= link_to "Forgot Password?", new_password_url %>
+ </div>
</fieldset>
<% end %>
View
2  config/routes.rb
@@ -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
View
8 db/migrate/20130505230811_add_password_reset_token_and_password_reset_sent_at_to_identities.rb
@@ -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
View
6 lib/thincloud/authentication/authenticatable_controller.rb
@@ -91,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
View
88 test/controllers/thincloud/authentication/passwords_controller_test.rb
@@ -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
View
2  test/dummy/config/routes.rb
@@ -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
View
18 test/dummy/db/schema.rb
@@ -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"
View
25 test/mailers/thincloud/authentication/passwords_mailer_test.rb
@@ -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
View
14 test/models/identity_test.rb
@@ -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
View
41 test/services/password_reset_workflow_test.rb
@@ -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
View
40 test/services/update_identity_password_test.rb
@@ -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
View
13 test/support/database_cleaner.rb
@@ -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
Please sign in to comment.
Something went wrong with that request. Please try again.