Skip to content

[Login] Backup Codes #10434

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

Open
wants to merge 99 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
268ef86
cryptographically secure backup code generation
rluodev May 13, 2025
cad6cc8
create used scope and make sure that variable scope is limited to nee…
rluodev May 13, 2025
08e9cb9
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev May 14, 2025
89637c9
add backup code deleted_at column
rluodev May 15, 2025
06f55a7
annotate
rluodev May 15, 2025
ae8207b
allow choosing backup code as a login option
rluodev May 15, 2025
c49175d
fix class name
rluodev May 15, 2025
527ddc3
add backup_code authentication factor
rluodev May 15, 2025
f3b93c6
allow 1fa even if 2fa if backup code used
rluodev May 15, 2025
cabcf72
use greater than or equal to ensure that backup code can be used at a…
rluodev May 15, 2025
6410b07
allow redeeming backup code
rluodev May 15, 2025
c836d47
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev May 15, 2025
960938b
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev May 15, 2025
1a30972
Update app/mailers/user/backup_code_mailer.rb
rluodev May 19, 2025
59b3150
Update app/models/login.rb
rluodev May 19, 2025
69e3613
Apply suggestions from code review
rluodev May 19, 2025
d38f4fa
remove acts_as_paranoid
rluodev May 19, 2025
067e3b2
create class hash gen method and use it
rluodev May 19, 2025
e7fd663
add code_used mailer
rluodev May 19, 2025
eacf338
change hash name and add unique index
rluodev May 20, 2025
25a0c72
schema changes
rluodev May 20, 2025
4ba9044
use new field name
rluodev May 20, 2025
2b1eb79
add migrations
rluodev May 20, 2025
ff77265
bang methods
rluodev May 20, 2025
41af4b5
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev May 20, 2025
89b6fc2
resolve merge conflict
rluodev May 20, 2025
fe14484
remove extra end statement
rluodev May 20, 2025
f3a22fd
add user controller methods, routes, and policy
rluodev May 20, 2025
c7526f3
rename few codes left mailer
rluodev May 20, 2025
d5c47f5
change invalidate_backup_code logic
rluodev May 20, 2025
ca44052
change mailer name used
rluodev May 20, 2025
5ed994b
add bottom margin
rluodev May 20, 2025
d9b3ce5
make sure we check if backup codes are available on normal login code…
rluodev May 20, 2025
3d3ecd1
add the actual mailers
rluodev May 20, 2025
43d59c2
add ability to generate and disable backup codes in security settings
rluodev May 20, 2025
2b8d792
add turbo frame partial
rluodev May 20, 2025
ffd1131
lint
rluodev May 20, 2025
7038864
undo irrelevant schema changes
rluodev May 20, 2025
8ce3fe2
one last schema fix
rluodev May 20, 2025
04e3aa4
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
garyhtou May 22, 2025
669a58e
Update edit_security.html.erb
rluodev May 22, 2025
1df7c5d
Update app/views/users/generate_backup_codes.html.erb
rluodev May 25, 2025
b8899c3
apply sam's copy and css edits
rluodev May 25, 2025
8867574
use mail_to helper
rluodev May 25, 2025
0595796
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev May 25, 2025
d00c70c
the great rename
rluodev May 25, 2025
21b539f
mailer rename
rluodev May 25, 2025
506db33
Update app/views/users/generate_backup_codes.html.erb
rluodev May 25, 2025
c3e261c
slight copy edit
rluodev May 25, 2025
d4ed35e
add mailer preview
rluodev May 25, 2025
cb978df
add newline
rluodev May 26, 2025
37708d7
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jun 9, 2025
63e4c5b
sentence case
rluodev Jun 9, 2025
193fcad
rename new code generated mailer and remove redundant mailer and edit…
rluodev Jun 12, 2025
89fd638
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jun 16, 2025
f00eee3
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jun 19, 2025
04aa1cc
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jun 25, 2025
c4fda79
Disallow admins to generate and active backup codes
garyhtou Jun 25, 2025
ba9652a
use `active_backup_codes` selector
rluodev Jun 25, 2025
e8496a8
change mailer order and use `active_backup_codes` selector
rluodev Jun 25, 2025
161f59d
Remove unused mailer view
garyhtou Jun 25, 2025
587170f
use `active_backup_codes` selector in `users_controller.rb`
rluodev Jun 25, 2025
1713510
Apply suggestions from code review
rluodev Jun 25, 2025
c613776
Update app/views/user/backup_code_mailer/no_codes_remaining.html.erb
rluodev Jun 25, 2025
c194275
Update app/models/user.rb
rluodev Jun 30, 2025
432854f
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jun 30, 2025
54a6ed7
fix rubocop
rluodev Jun 30, 2025
c982ec1
Update user.rb
rluodev Jun 30, 2025
0f7e83a
Update app/controllers/users_controller.rb
rluodev Jun 30, 2025
8069fd5
still require 2 methods of auth if backup code used
rluodev Jun 30, 2025
2f97e3f
use transaction
rluodev Jun 30, 2025
2e0b309
fix association
rluodev Jun 30, 2025
f7f1d6e
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jul 3, 2025
33a7f1f
code pairing changes
rluodev Jul 3, 2025
864e4f9
add updated index
rluodev Jul 3, 2025
56b0bc4
next instead of return
rluodev Jul 9, 2025
c5cd3c9
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jul 9, 2025
1096575
update logins_controller_spec.rb
rluodev Jul 9, 2025
9d830b5
update (wrong path)
rluodev Jul 9, 2025
b39c061
Update user.rb
rluodev Jul 10, 2025
b7a9bf9
Update backup_code.rb
rluodev Jul 10, 2025
12ff2cf
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jul 10, 2025
4c7b316
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jul 11, 2025
caf4e87
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jul 12, 2025
b512f6b
get rid of redundant unique index
rluodev Jul 12, 2025
516bef1
annotation
rluodev Jul 12, 2025
be272e8
remove duplicate mailers, change to using backup_codes.active scope, …
rluodev Jul 12, 2025
35591a6
fail loudly instead of ignore error
rluodev Jul 12, 2025
697d1ab
remove lines in mailer preview
rluodev Jul 12, 2025
6fd8668
redirect with flash when user uses their last backup code
rluodev Jul 12, 2025
0a66503
add newline for lint
rluodev Jul 12, 2025
ffff627
update spec to test for proper redirection if user uses last backup_code
rluodev Jul 12, 2025
b56f252
fix spec
rluodev Jul 12, 2025
10608f4
annotation format change
rluodev Jul 12, 2025
a827333
fix flash color
rluodev Jul 12, 2025
598aa63
move backup code activation to be in `user.rb` instead of `users_cont…
rluodev Jul 12, 2025
6116411
Merge branch 'main' into rluodev/6707-login-create-back-up-codes
rluodev Jul 14, 2025
c434605
Update app/models/user.rb
rluodev Jul 14, 2025
5b4e7fc
remove extra space in base mailer case
rluodev Jul 14, 2025
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
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,6 @@ gem "irb"

gem "pstore"

gem "bcrypt", "~> 3.1.7"

gem "prosemirror_to_html"
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ GEM
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
better_html (2.1.1)
actionview (>= 6.0)
Expand Down Expand Up @@ -889,6 +890,7 @@ DEPENDENCIES
awesome_print
aws-sdk-s3
barnes
bcrypt (~> 3.1.7)
blazer
blind_index
bootsnap (>= 1.4.4)
Expand Down
9 changes: 9 additions & 0 deletions app/controllers/logins_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ def complete
else
return redirect_to totp_login_path(@login), flash: { error: "Invalid TOTP code, please try again." }
end
when "backup_code"
if @user.redeem_backup_code!(params[:backup_code])
@login.update(authenticated_with_backup_code: true)
else
return redirect_to backup_code_login_path(@login), flash: { error: "Invalid backup code, please try again." }
end
end

# Clear the flash - this prevents the error message showing up after an unsuccessful -> successful login
Expand All @@ -149,6 +155,8 @@ def complete
@login.update(user_session: sign_in(user: @login.user, fingerprint_info:))
if @user.full_name.blank? || @user.phone_number.blank?
redirect_to edit_user_path(@user.slug, return_to: params[:return_to])
elsif @login.authenticated_with_backup_code && @user.backup_codes.active.size == 0
redirect_to security_user_path(@user), flash: { warning: "You've just used your last backup code, and we recommend generating more." }
else
redirect_to(params[:return_to] || root_path)
end
Expand Down Expand Up @@ -207,6 +215,7 @@ def set_available_methods
@sms_available = @user&.phone_number_verified && [email protected]_with_sms
@webauthn_available = @user&.webauthn_credentials&.any? && [email protected]_with_webauthn
@totp_available = @user&.totp.present? && [email protected]_with_totp
@backup_code_available = @user&.backup_codes_enabled? && [email protected]_with_backup_code
end

def set_return_to
Expand Down
20 changes: 20 additions & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,26 @@ def disable_totp
redirect_back_or_to security_user_path(@user)
end

def generate_backup_codes
@user = params[:id] ? User.friendly.find(params[:id]) : current_user
authorize @user
@previewed_backup_codes = @user.generate_backup_codes!
end

def activate_backup_codes
@user = params[:id] ? User.friendly.find(params[:id]) : current_user
authorize @user
@user.activate_backup_codes!
redirect_back_or_to security_user_path(@user)
end

def disable_backup_codes
@user = params[:id] ? User.friendly.find(params[:id]) : current_user
authorize @user
@user.disable_backup_codes!
redirect_back_or_to security_user_path(@user)
end

def edit_admin
@user = params[:id] ? User.friendly.find(params[:id]) : current_user
set_onboarding
Expand Down
36 changes: 36 additions & 0 deletions app/mailers/user/backup_code_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

class User
class BackupCodeMailer < ApplicationMailer
before_action :set_user

default to: -> { @user.email_address_with_name }

def new_codes_activated
mail subject: "You've generated new backup codes for HCB"
end

def code_used
subject = "You've used a backup code to login to HCB"
case @user.backup_codes.active.size
when 0
subject = "[Action Required] You've used all your backup codes for HCB"
when 1..3
subject = "[Action Requested] You've almost used all your backup codes for HCB"
end
mail subject: subject
end

def backup_codes_disabled
mail subject: "You've disabled your HCB backup codes"
end

private

def set_user
@user = User.find(params[:user_id])
end

end

end
2 changes: 1 addition & 1 deletion app/models/login.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class Login < ApplicationRecord
has_encrypted :browser_token
before_validation :ensure_browser_token

store_accessor :authentication_factors, :sms, :email, :webauthn, :totp, prefix: :authenticated_with
store_accessor :authentication_factors, :sms, :email, :webauthn, :totp, :backup_code, prefix: :authenticated_with

EXPIRATION = 15.minutes

Expand Down
55 changes: 55 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class User < ApplicationRecord

has_many :logins
has_many :login_codes
has_many :backup_codes, class_name: "User::BackupCode", inverse_of: :user, dependent: :destroy
has_many :user_sessions, dependent: :destroy
has_many :organizer_position_invites, dependent: :destroy
has_many :organizer_position_contracts, through: :organizer_position_invites, class_name: "OrganizerPosition::Contract"
Expand Down Expand Up @@ -381,6 +382,60 @@ def only_card_grant_user?
card_grants.size >= 1 && events.size == 0
end

def backup_codes_enabled?
backup_codes.active.any?
end

def generate_backup_codes!
backup_codes.previewed.destroy_all

codes = []
ActiveRecord::Base.transaction do
while codes.size < 10
code = SecureRandom.alphanumeric(10)
next if codes.include?(code)

backup_codes.create!(code: code)
codes << code
end
end

codes
end

def activate_backup_codes!
ActiveRecord::Base.transaction do
backup_codes.active.map(&:mark_discarded!)
backup_codes.previewed.map(&:mark_active!)
end
User::BackupCodeMailer.with(user_id: id).new_codes_activated.deliver_now
end

def redeem_backup_code!(code)
backup_codes.active.each do |backup_code|
next unless backup_code.authenticate_code(code)

ActiveRecord::Base.transaction do
backup_code = User::BackupCode
.lock # performs a SELECT ... FOR UPDATE https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
.active # makes sure that it hasn't already been used
.find(backup_code.id) # will raise `ActiveRecord::NotFound` and abort the transaction
backup_code.mark_used!
return true
end
end

false
end

def disable_backup_codes!
ActiveRecord::Base.transaction do
backup_codes.previewed.destroy_all
backup_codes.active.map(&:mark_discarded!)
end
BackupCodeMailer.with(user_id: id).backup_codes_disabled.deliver_now
end

private

def update_stripe_cardholder
Expand Down
57 changes: 57 additions & 0 deletions app/models/user/backup_code.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

# == Schema Information
#
# Table name: user_backup_codes
#
# id :bigint not null, primary key
# aasm_state :string default("previewed"), not null
# code_digest :text not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_user_backup_codes_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class User
class BackupCode < ApplicationRecord
has_paper_trail

has_secure_password :code

include AASM

belongs_to :user

validates :code_digest, presence: true

aasm do
state :previewed, initial: true
state :active
state :used
state :discarded

event :mark_active do
transitions from: :previewed, to: :active
end
event :mark_used do
transitions from: :active, to: :used

after do
User::BackupCodeMailer.with(user_id: user.id).code_used.deliver_now
end
end
event :mark_discarded do
transitions from: :active, to: :discarded
end
end

end

end
12 changes: 12 additions & 0 deletions app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ def disable_totp?
user.admin? || record == user
end

def generate_backup_codes?
record == user
end

def activate_backup_codes?
record == user
end

def disable_backup_codes?
user.admin? || record == user
end

def edit_address?
user.auditor? || record == user
end
Expand Down
33 changes: 33 additions & 0 deletions app/views/logins/backup_code.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<% title "Enter backup code" %>
<% content_for(:page_class) { "bg-snow" } %>

<div class="flex flex-col flex-1 justify-center max-w-md w-full">
<%= render "header", label: "Sign in to HCB" do %>
Backup code
<% end %>
<%= render "badge", user: @login.user %>
<p>
Please enter one of the backup codes you generated previously.
</p>
<%= form_tag complete_login_path(@login) do %>
<%= text_field :backup_code, "", placeholder: "Enter your backup code", name: "backup_code", class: "!max-w-full w-max", required: true, autofocus: true %>
<%= hidden_field_tag :method, :backup_code %>
<%= hidden_field_tag :fingerprint %>
<%= hidden_field_tag :device_info %>
<%= hidden_field_tag :os_info %>
<%= hidden_field_tag :timezone %>
<%= hidden_field_tag :return_to, @return_to if @return_to %>
<div class="flex flex-row justify-between items-center mt-4 gap-2">
<% if @webauthn_available || @totp_available || @email_available || @sms_available %>
<%= link_to "Sign in another way", choose_login_preference_login_path(@login, return_to: @return_to), class: "block mt-0 no-underline" %>
<% end %>
<button data-webauthn-auth-target="continueButton" type="submit" class="gap-2 btn">
Continue
</button>
</div>
<% end %>
<%= javascript_include_tag "https://cdn.jsdelivr.net/npm/ua-parser-js/dist/ua-parser.min.js" %>
<%= javascript_include_tag "fingerprint.js" %>
</div>
<%= render partial: "environment_banner" %>
<%= render partial: "footer" %>
8 changes: 6 additions & 2 deletions app/views/logins/choose_login_preference.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
@email_available.presence,
@sms_available.presence,
@totp_available.presence,
@webauthn_available.presence
@webauthn_available.presence,
@backup_code_available.presence
].compact %>

<div class="field field--options max-w-full w-full mt-3 <%= "trio" if methods.length == 3 %>">
Expand Down Expand Up @@ -57,10 +58,13 @@
</div>
<%= form.hidden_field :email, value: @email, data: { "webauthn-auth-target" => "loginEmailInput" } %>
<%= form.hidden_field :return_to, value: @return_to if @return_to %>
<div class="flex flex-row justify-between items-center mt-4">
<div class="flex flex-row justify-between items-center my-4">
<%= link_to "Cancel", logout_users_path, method: :delete, class: "no-underline block" %>
<%= form.submit "Continue", data: { "webauthn-auth-target" => "continueButton", "form-disable-target" => "submitButton" } %>
</div>
<% if @backup_code_available %>
<%= link_to "Don't have access to any of these? Use a Backup Code", backup_code_login_path(@login) %>
<% end %>
<% end %>
</div>
<%= render partial: "environment_banner" %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/logins/login_code.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
<p class="h5 muted"><em>Make sure to check your spam folder</em></p>
<% end %>
<div class="flex flex-row justify-between items-center mt-4 gap-2">
<% if @webauthn_available || @totp_available %>
<% if @webauthn_available || @totp_available || @backup_code_available %>
<%= link_to "Sign in another way", choose_login_preference_login_path(@login, return_to: @return_to), class: "block mt-0 no-underline" %>
<% end %>
<button data-webauthn-auth-target="continueButton" type="submit" class="gap-2 btn">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<p>
Hey <%= @user.first_name %>,
</p>

<p>
You've just disabled backup codes for HCB. If you had any unused backup codes, they were invalidated.
</p>

<p>
If this was you, great! If you didn't do this, please <%= mail_to "[email protected]", "contact us", subject: "My backup codes were disabled and it was not me" %> immediately.
</p>

<p>
Thanks,<br>
The HCB Team
</p>
23 changes: 23 additions & 0 deletions app/views/user/backup_code_mailer/code_used.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<p>
Hey <%= @user.first_name %>,
</p>

<p>
You've just used one of your backup codes to login to
<% if @user.backup_codes.active.size == 0 %>
HCB and have none left. We strongly urge you to regenerate more codes at your earliest convenience in your <%= link_to "user settings", security_user_url(@user) %> to ensure you can always access your account.
<% elsif @user.backup_codes.active.size <= 3 %>
HCB and only have <%= @user.backup_codes.active.size %> left. We strongly urge you to regenerate more codes at your earliest convenience in your <%= link_to "user settings", security_user_url(@user) %> to ensure you can always access your account.
<% else %>
HCB.
<% end %>
</p>

<p>
If this was you, great! If you didn't do this, please <%= mail_to "[email protected]", "contact us", subject: "Unauthorized access with backup code" %> immediately.
</p>

<p>
Thanks,<br>
The HCB Team
</p>
16 changes: 16 additions & 0 deletions app/views/user/backup_code_mailer/new_codes_activated.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<p>
Hey <%= @user.first_name %>,
</p>

<p>
You've just generated backup codes for HCB and have 10 codes available. Please make sure to save the codes in a safe place in case you lose access to your account. HCB staff will never ask for your backup codes.
</p>

<p>
If this was you, great! If you didn't do this, please <%= mail_to "[email protected]", "contact us", subject: "Unauthorized backup codes generated" %> immediately.
</p>

<p>
Thanks,<br>
The HCB Team
</p>
Loading