Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Clearance.configure do |config|
config.routes = true
config.httponly = true
config.mailer_sender = "reply@example.com"
config.password_reset_token_expiration_in = nil
config.password_strategy = Clearance::PasswordStrategies::BCrypt
config.redirect_url = "/"
config.url_after_destroy = nil
Expand Down Expand Up @@ -131,6 +132,15 @@ Clearance.configure do |config|
end
```

By default, password reset tokens do not expire. It is recommended to set an expiration time for password reset tokens by changing the `password_reset_token_expiration_in` configuration option:
```ruby
Clearance.configure do |config|
config.password_reset_token_expiration_in = 1.hour
end
```

**Important:** The reset token expiration feature requires the `confirmation_token_created_at` column to exist in your user model. Run `rails generate clearance:install` to generate the appropriate migration file if upgrading from a version of Clearance before 2.12.0.

### Multiple Domain Support

You can support multiple domains, or other special domain configurations by
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/clearance/passwords_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ def find_user_by_id_and_confirmation_token
user_param = Clearance.configuration.user_id_parameter
token = params[:token] || session[:password_reset_token]

Clearance.configuration.user_model
user = Clearance.configuration.user_model
.find_by(id: params[user_param], confirmation_token: token.to_s)

user unless user&.password_reset_token_expired?
end

def email_from_password_params
Expand Down
13 changes: 13 additions & 0 deletions lib/clearance/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ class Configuration
# @return [Module #authenticated? #password=]
attr_accessor :password_strategy

# How long a password reset token is valid. Defaults to `nil` (no
# expiration). Set to an ActiveSupport::Duration to enable expiration, e.g.
#
# config.password_reset_token_expiration_in = 2.hours
#
# When set, `forgot_password!` records the time the token was issued.
# Tokens with no recorded issue time (e.g. issued before upgrading or
# before running the migration) are treated as expired, forcing users to
# re-request a reset.
Comment on lines +77 to +80

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this is too much explanation for a configuration variable.

# @return [ActiveSupport::Duration, nil]
attr_accessor :password_reset_token_expiration_in

# The default path Clearance will redirect signed in users to.
# Defaults to `"/"`. This can often be overridden for specific scenarios by
# overriding controller methods that rely on it.
Expand Down Expand Up @@ -161,6 +173,7 @@ def initialize
@httponly = true
@same_site = nil
@mailer_sender = "reply@example.com"
@password_reset_token_expiration_in = nil
@redirect_url = "/"
@url_after_destroy = nil
@url_after_denied_access_when_signed_out = nil
Expand Down
49 changes: 47 additions & 2 deletions lib/clearance/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ module Clearance
#
# class User
# include Clearance::User
#
# # ...
# end
#
Expand Down Expand Up @@ -47,6 +46,11 @@ module Clearance
# @return [String] The value used to identify this user in the password
# reset link.
#
# @!attribute confirmation_token_created_at
# @return [Time, nil] When the current confirmation_token was issued.
# Used to enforce {Configuration#password_reset_token_expiration_in}.
# Nil for tokens issued before this column was added.
#
# @!attribute [r] password
# @return [String] Transient (non-persisted) attribute that is set when
# updating a user's password. Only the {#encrypted_password} is persisted.
Expand Down Expand Up @@ -215,12 +219,40 @@ def update_password(new_password)

if valid?
self.confirmation_token = nil
clear_confirmation_token_created_at
generate_remember_token
end

save
end

# Returns true if the password reset token has expired according to the
# configured {Configuration#password_reset_token_expiration_in}.
#
# Always returns false when expiration is not configured (the default),
# preserving existing behaviour.
#
# Returns true — treating the token as expired — when expiration is
# configured but the column does not yet exist (pre-migration) or the
# timestamp is nil (token issued before the column was added). This is
# intentional: if an operator has opted in to expiration, silently skipping
# it would undermine the security intent.
#
# @return [Boolean]
def password_reset_token_expired?
expiration = Clearance.configuration.password_reset_token_expiration_in
return false unless expiration

unless self.class.column_names.include?("confirmation_token_created_at")
raise "The `confirmation_token_created_at` column is required to " \
"check for expired password reset tokens. Please run " \
"`rails generate clearance:install` to add the necessary migration."
end

confirmation_token_created_at.nil? ||
confirmation_token_created_at < expiration.ago
end

private

# Sets the email on this instance to the value returned by
Expand Down Expand Up @@ -257,12 +289,25 @@ def skip_password_validation?
end

# Sets the {#confirmation_token} on the instance to a new value generated by
# {Token.new}. The change is not automatically persisted. If you would like
# {Token.new}, and stamps {#confirmation_token_created_at} if the column
# exists. The change is not automatically persisted. If you would like
# to generate and save in a single method call, use {#forgot_password!}.
#
# @return [String] The new confirmation token
def generate_confirmation_token
self.confirmation_token = Clearance::Token.new
if self.class.column_names.include?("confirmation_token_created_at")
self.confirmation_token_created_at = Time.current
end
end

# Clears {#confirmation_token_created_at} if the column exists.
#
# @return [void]
def clear_confirmation_token_created_at
if self.class.column_names.include?("confirmation_token_created_at")
self.confirmation_token_created_at = nil
end
end

# Sets the {#remember_token} on the instance to a new value generated by
Expand Down
1 change: 1 addition & 0 deletions lib/generators/clearance/install/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def new_columns
email: "t.string :email",
encrypted_password: "t.string :encrypted_password, limit: 128",
confirmation_token: "t.string :confirmation_token, limit: 128",
confirmation_token_created_at: "t.datetime :confirmation_token_created_at",
remember_token: "t.string :remember_token, limit: 128"
}.reject { |column| existing_users_columns.include?(column.to_s) }
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class CreateUsers < ActiveRecord::Migration<%= migration_version %>
t.string :email, null: false
t.string :encrypted_password, limit: 128, null: false
t.string :confirmation_token, limit: 128
t.datetime :confirmation_token_created_at
t.string :remember_token, limit: 128, null: false
end

Expand Down
14 changes: 14 additions & 0 deletions spec/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,18 @@
expect(Clearance.configuration.rotate_csrf_on_sign_in?).to be false
end
end

describe "#password_reset_token_expiration_in" do
it "returns nil when unset" do
expect(Clearance.configuration.password_reset_token_expiration_in).to be_nil
end

it "returns 2.hours when set to 2.hours" do
Clearance.configure do |config|
config.password_reset_token_expiration_in = 2.hours
end

expect(Clearance.configuration.password_reset_token_expiration_in).to eq 2.hours
end
end
end
75 changes: 75 additions & 0 deletions spec/controllers/passwords_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,32 @@
expect(session[:password_reset_token]).to eq(user.confirmation_token)
end
end

context "token has expired" do
before do
Clearance.configure do |config|
config.password_reset_token_expiration_in = 2.hours
end
end

after do
Clearance.configure do |config|
config.password_reset_token_expiration_in = nil
end
end

it "renders the new password reset form with a flash alert" do
user = create(:user, :with_forgotten_password, confirmation_token_created_at: 3.hours.ago)

get :edit, params: {
user_id: user,
token: user.confirmation_token
}

expect(response).to render_template(:new)
expect(flash.now[:alert]).to match(I18n.t("flashes.failure_when_forbidden"))
end
end
end

describe "#update" do
Expand Down Expand Up @@ -294,6 +320,55 @@
expect(current_user).to be_nil
end
end

context "token has expired" do
before do
Clearance.configure do |config|
config.password_reset_token_expiration_in = 2.hours
end
end

after do
Clearance.configure do |config|
config.password_reset_token_expiration_in = nil
end
end

it "re-renders the password edit form" do
user = create(:user, :with_forgotten_password, confirmation_token_created_at: 3.hours.ago)

put :update, params: update_parameters(
user,
new_password: "new_password"
)

expect(response).to render_template(:new)
expect(flash.now[:alert]).to match(I18n.t("flashes.failure_when_forbidden"))
end

it "does not update the password" do
user = create(:user, :with_forgotten_password, confirmation_token_created_at: 3.hours.ago)
old_encrypted_password = user.encrypted_password

put :update, params: update_parameters(
user,
new_password: "new_password"
)

expect(user.reload.encrypted_password).to eq old_encrypted_password
end

it "does not sign in user" do
user = create(:user, :with_forgotten_password, confirmation_token_created_at: 3.hours.ago)

put :update, params: update_parameters(
user,
new_password: "new_password"
)

expect(current_user).to be_nil
end
end
end

def update_parameters(user, options = {})
Expand Down
1 change: 1 addition & 0 deletions spec/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
t.string "email", null: false
t.string "encrypted_password", limit: 128, null: false
t.string "confirmation_token", limit: 128
t.datetime "confirmation_token_created_at"
t.string "remember_token", limit: 128, null: false
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email"
Expand Down
1 change: 1 addition & 0 deletions spec/factories/users.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

trait :with_forgotten_password do
confirmation_token { Clearance::Token.new }
confirmation_token_created_at { Time.current }
end

factory :user_with_optional_password, class: "UserWithOptionalPassword" do
Expand Down
Loading
Loading