Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add expiring, databaseless password reset tokens (alternative solution) #823

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 16 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
The noteworthy changes for each Clearance version are included here. For a
complete changelog, see the git history for each version via the version links.

## [2.0.0] - Unreleased

### Removed
- Removed `User#confirmation_token`, `User#forgot_password!`, and
`User#generate_confirmation_token` as part of the change to expiring,
databaseless password reset tokens.

### Changed
- Password resets now use expiring signed tokens that do not require persistence
to the `users` table. By default, the tokens are generated with
`ActiveSupport::MessageVerifier` and expire in 15 minutes.

### Added
- `rails generate clearance:upgrade` generator to prepare your Clearance 1.x
project for Clearance 2.0

## [2.0.0.beta1] - April 12, 2019

### Removed
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ Clearance.configure do |config|
config.routes = true
config.httponly = false
config.mailer_sender = "reply@example.com"
config.tokenizer = Clearance::Tokenizer
config.password_reset_time_limit = 15.minutes
config.password_strategy = Clearance::PasswordStrategies::BCrypt
config.redirect_url = "/"
config.rotate_csrf_on_sign_in = false
Expand Down Expand Up @@ -130,6 +132,15 @@ Clearance.configure do |config|
end
```

The password reset link contained in the email is configured to expire in 15
minutes. You can change this with the `password_reset_time_limit` configuration.

```ruby
Clearance.configure do |config|
config.password_reset_time_limit = 1.hour
end
```

### Integrating with Rack Applications

Clearance adds its session to the Rack environment hash so middleware and other
Expand Down
49 changes: 14 additions & 35 deletions app/controllers/clearance/passwords_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,23 @@ def new

def create
if user = find_user_for_create
user.forgot_password!
deliver_email(user)
end

render template: "passwords/create"
end

def edit
@user = find_user_for_edit

if params[:token]
session[:password_reset_token] = params[:token]
redirect_to url_for
else
render template: "passwords/edit"
end
@user = find_user_by_password_reset_token(params[:user_id], params[:token])
render template: "passwords/edit"
end

def update
@user = find_user_for_update
@user = find_user_by_password_reset_token(params[:user_id], params[:token])

if @user.update_password password_reset_params
sign_in @user
redirect_to url_after_update
session[:password_reset_token] = nil
if @user.update_password(password_reset_params)
sign_in(@user)
redirect_to(url_after_update)
else
flash_failure_after_update
render template: "passwords/edit"
Expand All @@ -44,46 +36,33 @@ def update
private

def deliver_email(user)
mail = ::ClearanceMailer.change_password(user)
mail.deliver_later
::ClearanceMailer.change_password(user).deliver_later
end

def password_reset_params
params[:password_reset][:password]
end

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.
find_by_id_and_confirmation_token params[user_param], token.to_s
end

def find_user_for_create
Clearance.configuration.user_model.
find_by_normalized_email params[:password][:email]
end

def find_user_for_edit
find_user_by_id_and_confirmation_token
end

def find_user_for_update
find_user_by_id_and_confirmation_token
def find_user_by_password_reset_token(user_id, token)
@user ||= Clearance::PasswordResetToken.find_user(user_id, token)

Choose a reason for hiding this comment

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

Naming/MemoizedInstanceVariableName: Memoized variable @user does not match method name find_user_by_password_reset_token. Use @find_user_by_password_reset_token instead.

end

def ensure_existing_user
unless find_user_by_id_and_confirmation_token
flash_failure_when_forbidden
unless find_user_by_password_reset_token(params[:user_id], params[:token])
flash_failure_when_invalid
render template: "passwords/new"
end
end

def flash_failure_when_forbidden
flash.now[:alert] = translate(:forbidden,
def flash_failure_when_invalid
flash.now[:alert] = translate(:failure_when_password_reset_invalid,
scope: [:clearance, :controllers, :passwords],
default: t("flashes.failure_when_forbidden"))
default: t("flashes.failure_when_password_reset_invalid"))
end

def flash_failure_after_update
Expand Down
8 changes: 8 additions & 0 deletions app/mailers/clearance_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
class ClearanceMailer < ActionMailer::Base
def change_password(user)
@user = user
@token = generate_password_reset_token(@user)

mail(
from: Clearance.configuration.mailer_sender,
to: @user.email,
Expand All @@ -10,4 +12,10 @@ def change_password(user)
),
)
end

private

def generate_password_reset_token(user)
Clearance::PasswordResetToken.generate_for(user)
end
end
2 changes: 1 addition & 1 deletion app/views/clearance_mailer/change_password.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<p>
<%= link_to t(".link_text", default: "Change my password"),
edit_user_password_url(@user, token: @user.confirmation_token.html_safe) %>
edit_user_password_url(@user, token: @token) %>
</p>

<p><%= raw t(".closing") %></p>
2 changes: 1 addition & 1 deletion app/views/clearance_mailer/change_password.text.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<%= t(".opening") %>

<%= edit_user_password_url(@user, token: @user.confirmation_token.html_safe) %>
<%= edit_user_password_url(@user, token: @token) %>

<%= raw t(".closing") %>
4 changes: 2 additions & 2 deletions app/views/passwords/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<p><%= t(".description") %></p>

<%= form_for :password_reset,
url: user_password_path(@user, token: @user.confirmation_token),
html: { method: :put } do |form| %>
url: user_password_path(@user, token: params[:token]),
html: { method: :patch } do |form| %>
<div class="password-field">
<%= form.label :password %>
<%= form.password_field :password %>
Expand Down
2 changes: 2 additions & 0 deletions config/locales/clearance.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ en:
failure_when_forbidden: Please double check the URL or try submitting
the form again.
failure_when_not_signed_in: Please sign in to continue.
failure_when_password_reset_invalid: Your password reset token has expired
or is invalid.
helpers:
label:
password:
Expand Down
1 change: 0 additions & 1 deletion db/migrate/20110111224543_create_clearance_users.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ def self.up
t.timestamps null: false
t.string :email, null: false
t.string :encrypted_password, limit: 128, null: false
t.string :confirmation_token, limit: 128
t.string :remember_token, limit: 128, null: false
end

Expand Down
3 changes: 1 addition & 2 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@

ActiveRecord::Schema.define(version: 20110111224543) do

create_table "users", force: true do |t|
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "email", null: false
t.string "encrypted_password", limit: 128, null: false
t.string "confirmation_token", limit: 128
t.string "remember_token", limit: 128, null: false
end

Expand Down
1 change: 1 addition & 0 deletions lib/clearance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require 'clearance/engine'
require 'clearance/password_strategies'
require 'clearance/constraints'
require "clearance/password_reset_token"

module Clearance
end
14 changes: 14 additions & 0 deletions lib/clearance/configuration.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require 'clearance/tokenizer'

Choose a reason for hiding this comment

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

Style/StringLiterals: Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.


module Clearance
class Configuration
# Controls whether the sign up route is enabled.
Expand Down Expand Up @@ -47,6 +49,16 @@ class Configuration
# @return [String]
attr_accessor :mailer_sender

# Used to generate and verify secure encrypted tokens
# Defaults to `Clearance::Tokenizer`
# @return [#new #generate #valid?]
attr_accessor :tokenizer

# Determines how long password reset emails are valid for
# Defaults to 15 minutes
# @return [Integer]
attr_accessor :password_reset_time_limit

# The password strategy to use when authenticating and setting passwords.
# Defaults to {Clearance::PasswordStrategies::BCrypt}.
# @return [Module #authenticated? #password=]
Expand Down Expand Up @@ -104,6 +116,8 @@ def initialize
@cookie_path = '/'
@httponly = true
@mailer_sender = 'reply@example.com'
@tokenizer = Clearance::Tokenizer
@password_reset_time_limit = 15.minutes
@redirect_url = '/'
@rotate_csrf_on_sign_in = nil
@routes = true
Expand Down
37 changes: 37 additions & 0 deletions lib/clearance/password_reset_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
module Clearance
class PasswordResetToken
def self.generate_for(user)
new(user).generate
end

def self.find_user(user_id, token)
user = Clearance.configuration.user_model.find_by(id: user_id)
user if user && new(user).valid?(token)
end

def initialize(user)
@user = user
@tokenizer = Clearance.configuration.tokenizer.new(user)
end

def generate
tokenizer.generate(user.id, expires_in: expires_in)
end

def valid?(token)
tokenizer.valid?(token)
end

def to_s
generate
end

private

attr_reader :user, :tokenizer

def expires_in
Clearance.configuration.password_reset_time_limit
end
end
end
39 changes: 39 additions & 0 deletions lib/clearance/tokenizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
module Clearance
# Generate secure token used for password reset tokens.
class Tokenizer
# Initialize new tokenizer which will be able to generate
# and verify token validity for any given user
def initialize(user)
# key must be 32 bytes, see: https://github.com/rails/rails/pull/25192
key_generator = Rails.application.key_generator
key = key_generator.generate_key(user.encrypted_password, 32)
@encryptor = ActiveSupport::MessageEncryptor.new(key)
end

# Generate secure encrypted token valid for the user
# @return [String]
def generate(payload, expires_in: nil)
encryptor.encrypt_and_sign([payload, expires_in&.from_now])
end

# Verify that token are valid for the given user
# @return [Boo]
def valid?(token)
_, expires_at = decrypt(token)

(expires_at || 1.hour.from_now).future?
end

private

attr_reader :encryptor

def decrypt(token)
begin

Choose a reason for hiding this comment

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

Style/RedundantBegin: Redundant begin block detected.

encryptor.decrypt_and_verify(token)
rescue ActiveSupport::MessageVerifier::InvalidSignature
[nil, 1.hour.ago]
end
end
end
end