Skip to content

Commit 1a2f41f

Browse files
rluodevgaryhtougithub-actions[bot]davidcornu
authored
[Login] Backup Codes (#10434)
## Summary of the problem <!-- Why these changes are being made? What problem does it solve? Link any related issues to provide more details. --> People might get locked out of their accounts with no way to get back in, which is bad. ## Describe your changes <!-- Explain your thought process to the solution and provide a quick summary of the changes. --> Added backup codes, securely generated, SHA-512 BLT seasoned with fresh S&P. <!-- If there are any visual changes, please attach images, videos, or gifs. --> <img width="969" alt="Screenshot 2025-05-24 at 11 11 25 PM" src="https://github.com/user-attachments/assets/bf632d3c-3999-46c0-af88-30643fb4f3b2" /> <img width="969" alt="Screenshot 2025-05-24 at 11 11 48 PM" src="https://github.com/user-attachments/assets/c807d0b5-04ad-4133-b653-c396087b778d" /> <img width="969" alt="Screenshot 2025-05-24 at 11 07 44 PM" src="https://github.com/user-attachments/assets/5194a9ab-18af-4c51-84c5-78e71616f3fa" /> <img width="460" alt="Screenshot 2025-05-24 at 11 12 30 PM" src="https://github.com/user-attachments/assets/cf2f04c2-a36a-425a-9466-8215ad1260c8" /> <img width="460" alt="Screenshot 2025-05-24 at 11 12 55 PM" src="https://github.com/user-attachments/assets/613c4f8c-73cf-4671-b369-7d87291dd723" /> <img width="969" alt="Screenshot 2025-05-24 at 11 10 55 PM" src="https://github.com/user-attachments/assets/ab2112a0-237a-4371-8087-fcadf0ae8bb9" /> <img width="565" alt="Screenshot 2025-05-24 at 11 24 17 PM" src="https://github.com/user-attachments/assets/bcf4e726-9e66-480d-8d50-c2d60d3de981" /> <img width="565" alt="Screenshot 2025-05-24 at 11 27 51 PM" src="https://github.com/user-attachments/assets/378a523d-d127-4227-a28a-8473573c2d29" /> <img width="565" alt="Screenshot 2025-05-24 at 11 29 08 PM" src="https://github.com/user-attachments/assets/0aaff1cb-d87d-439c-8ac5-fdc64bb53634" /> <img width="565" alt="Screenshot 2025-05-24 at 11 23 22 PM" src="https://github.com/user-attachments/assets/1d4eca81-7326-47b4-945c-42f9dbbf83f0" /> <img width="565" alt="Screenshot 2025-05-24 at 11 23 48 PM" src="https://github.com/user-attachments/assets/d96d7e8f-3aec-4c18-bd20-7496f3e1b7ae" /> <img width="565" alt="Screenshot 2025-05-24 at 11 25 59 PM" src="https://github.com/user-attachments/assets/333a7e99-7f39-4342-be18-c4037d738a7c" /> --------- Co-authored-by: Gary Tou <[email protected]> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: David Cornu <[email protected]>
1 parent cbf8bb2 commit 1a2f41f

22 files changed

+459
-4
lines changed

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,6 @@ gem "irb"
228228

229229
gem "pstore"
230230

231+
gem "bcrypt", "~> 3.1.7"
232+
231233
gem "prosemirror_to_html"

Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ GEM
173173
multi_json (~> 1)
174174
statsd-ruby (~> 1.1)
175175
base64 (0.3.0)
176+
bcrypt (3.1.20)
176177
benchmark (0.4.1)
177178
better_html (2.1.1)
178179
actionview (>= 6.0)
@@ -884,6 +885,7 @@ DEPENDENCIES
884885
awesome_print
885886
aws-sdk-s3
886887
barnes
888+
bcrypt (~> 3.1.7)
887889
blazer
888890
blind_index
889891
bootsnap (>= 1.4.4)

app/controllers/logins_controller.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ def complete
142142
else
143143
return redirect_to totp_login_path(@login), flash: { error: "Invalid TOTP code, please try again." }
144144
end
145+
when "backup_code"
146+
if @user.redeem_backup_code!(params[:backup_code])
147+
@login.update(authenticated_with_backup_code: true)
148+
else
149+
return redirect_to backup_code_login_path(@login), flash: { error: "Invalid backup code, please try again." }
150+
end
145151
end
146152

147153
# Clear the flash - this prevents the error message showing up after an unsuccessful -> successful login
@@ -155,6 +161,8 @@ def complete
155161
redirect_to program_path(@referral_program)
156162
elsif @user.full_name.blank? || @user.phone_number.blank?
157163
redirect_to edit_user_path(@user.slug, return_to: params[:return_to])
164+
elsif @login.authenticated_with_backup_code && @user.backup_codes.active.size == 0
165+
redirect_to security_user_path(@user), flash: { warning: "You've just used your last backup code, and we recommend generating more." }
158166
else
159167
redirect_to(params[:return_to] || root_path)
160168
end
@@ -214,6 +222,7 @@ def set_available_methods
214222
@sms_available = @user&.phone_number_verified && !@login.authenticated_with_sms
215223
@webauthn_available = @user&.webauthn_credentials&.any? && !@login.authenticated_with_webauthn
216224
@totp_available = @user&.totp.present? && !@login.authenticated_with_totp
225+
@backup_code_available = @user&.backup_codes_enabled? && !@login.authenticated_with_backup_code
217226
end
218227

219228
def set_return_to

app/controllers/users_controller.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,26 @@ def disable_totp
189189
redirect_back_or_to security_user_path(@user)
190190
end
191191

192+
def generate_backup_codes
193+
@user = params[:id] ? User.friendly.find(params[:id]) : current_user
194+
authorize @user
195+
@previewed_backup_codes = @user.generate_backup_codes!
196+
end
197+
198+
def activate_backup_codes
199+
@user = params[:id] ? User.friendly.find(params[:id]) : current_user
200+
authorize @user
201+
@user.activate_backup_codes!
202+
redirect_back_or_to security_user_path(@user)
203+
end
204+
205+
def disable_backup_codes
206+
@user = params[:id] ? User.friendly.find(params[:id]) : current_user
207+
authorize @user
208+
@user.disable_backup_codes!
209+
redirect_back_or_to security_user_path(@user)
210+
end
211+
192212
def edit_admin
193213
@user = params[:id] ? User.friendly.find(params[:id]) : current_user
194214
set_onboarding
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
class User
4+
class BackupCodeMailer < ApplicationMailer
5+
before_action :set_user
6+
7+
default to: -> { @user.email_address_with_name }
8+
9+
def new_codes_activated
10+
mail subject: "You've generated new backup codes for HCB"
11+
end
12+
13+
def code_used
14+
subject = "You've used a backup code to login to HCB"
15+
case @user.backup_codes.active.size
16+
when 0
17+
subject = "[Action Required] You've used all your backup codes for HCB"
18+
when 1..3
19+
subject = "[Action Requested] You've almost used all your backup codes for HCB"
20+
end
21+
mail subject: subject
22+
end
23+
24+
def backup_codes_disabled
25+
mail subject: "You've disabled your HCB backup codes"
26+
end
27+
28+
private
29+
30+
def set_user
31+
@user = User.find(params[:user_id])
32+
end
33+
34+
end
35+
36+
end

app/models/login.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class Login < ApplicationRecord
3636
has_encrypted :browser_token
3737
before_validation :ensure_browser_token
3838

39-
store_accessor :authentication_factors, :sms, :email, :webauthn, :totp, prefix: :authenticated_with
39+
store_accessor :authentication_factors, :sms, :email, :webauthn, :totp, :backup_code, prefix: :authenticated_with
4040

4141
EXPIRATION = 15.minutes
4242

app/models/user.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class User < ApplicationRecord
7979

8080
has_many :logins
8181
has_many :login_codes
82+
has_many :backup_codes, class_name: "User::BackupCode", inverse_of: :user, dependent: :destroy
8283
has_many :user_sessions, dependent: :destroy
8384
has_many :organizer_position_invites, dependent: :destroy
8485
has_many :organizer_position_contracts, through: :organizer_position_invites, class_name: "OrganizerPosition::Contract"
@@ -381,6 +382,60 @@ def only_card_grant_user?
381382
card_grants.size >= 1 && events.size == 0
382383
end
383384

385+
def backup_codes_enabled?
386+
backup_codes.active.any?
387+
end
388+
389+
def generate_backup_codes!
390+
backup_codes.previewed.destroy_all
391+
392+
codes = []
393+
ActiveRecord::Base.transaction do
394+
while codes.size < 10
395+
code = SecureRandom.alphanumeric(10)
396+
next if codes.include?(code)
397+
398+
backup_codes.create!(code: code)
399+
codes << code
400+
end
401+
end
402+
403+
codes
404+
end
405+
406+
def activate_backup_codes!
407+
ActiveRecord::Base.transaction do
408+
backup_codes.active.map(&:mark_discarded!)
409+
backup_codes.previewed.map(&:mark_active!)
410+
end
411+
User::BackupCodeMailer.with(user_id: id).new_codes_activated.deliver_now
412+
end
413+
414+
def redeem_backup_code!(code)
415+
backup_codes.active.each do |backup_code|
416+
next unless backup_code.authenticate_code(code)
417+
418+
ActiveRecord::Base.transaction do
419+
backup_code = User::BackupCode
420+
.lock # performs a SELECT ... FOR UPDATE https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
421+
.active # makes sure that it hasn't already been used
422+
.find(backup_code.id) # will raise `ActiveRecord::NotFound` and abort the transaction
423+
backup_code.mark_used!
424+
return true
425+
end
426+
end
427+
428+
false
429+
end
430+
431+
def disable_backup_codes!
432+
ActiveRecord::Base.transaction do
433+
backup_codes.previewed.destroy_all
434+
backup_codes.active.map(&:mark_discarded!)
435+
end
436+
BackupCodeMailer.with(user_id: id).backup_codes_disabled.deliver_now
437+
end
438+
384439
private
385440

386441
def update_stripe_cardholder

app/models/user/backup_code.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
# == Schema Information
4+
#
5+
# Table name: user_backup_codes
6+
#
7+
# id :bigint not null, primary key
8+
# aasm_state :string default("previewed"), not null
9+
# code_digest :text not null
10+
# created_at :datetime not null
11+
# updated_at :datetime not null
12+
# user_id :bigint not null
13+
#
14+
# Indexes
15+
#
16+
# index_user_backup_codes_on_user_id (user_id)
17+
#
18+
# Foreign Keys
19+
#
20+
# fk_rails_... (user_id => users.id)
21+
#
22+
class User
23+
class BackupCode < ApplicationRecord
24+
has_paper_trail
25+
26+
has_secure_password :code
27+
28+
include AASM
29+
30+
belongs_to :user
31+
32+
validates :code_digest, presence: true
33+
34+
aasm do
35+
state :previewed, initial: true
36+
state :active
37+
state :used
38+
state :discarded
39+
40+
event :mark_active do
41+
transitions from: :previewed, to: :active
42+
end
43+
event :mark_used do
44+
transitions from: :active, to: :used
45+
46+
after do
47+
User::BackupCodeMailer.with(user_id: user.id).code_used.deliver_now
48+
end
49+
end
50+
event :mark_discarded do
51+
transitions from: :active, to: :discarded
52+
end
53+
end
54+
55+
end
56+
57+
end

app/policies/user_policy.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ def disable_totp?
2525
user.admin? || record == user
2626
end
2727

28+
def generate_backup_codes?
29+
record == user
30+
end
31+
32+
def activate_backup_codes?
33+
record == user
34+
end
35+
36+
def disable_backup_codes?
37+
user.admin? || record == user
38+
end
39+
2840
def edit_address?
2941
user.auditor? || record == user
3042
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<% title "Enter backup code" %>
2+
<% content_for(:page_class) { "bg-snow" } %>
3+
4+
<div class="flex flex-col flex-1 justify-center max-w-md w-full">
5+
<%= render "header", label: "Sign in to HCB" do %>
6+
Backup code
7+
<% end %>
8+
<%= render "badge", user: @login.user %>
9+
<p>
10+
Please enter one of the backup codes you generated previously.
11+
</p>
12+
<%= form_tag complete_login_path(@login) do %>
13+
<%= text_field :backup_code, "", placeholder: "Enter your backup code", name: "backup_code", class: "!max-w-full w-max", required: true, autofocus: true %>
14+
<%= hidden_field_tag :method, :backup_code %>
15+
<%= hidden_field_tag :fingerprint %>
16+
<%= hidden_field_tag :device_info %>
17+
<%= hidden_field_tag :os_info %>
18+
<%= hidden_field_tag :timezone %>
19+
<%= hidden_field_tag :return_to, @return_to if @return_to %>
20+
<div class="flex flex-row justify-between items-center mt-4 gap-2">
21+
<% if @webauthn_available || @totp_available || @email_available || @sms_available %>
22+
<%= link_to "Sign in another way", choose_login_preference_login_path(@login, return_to: @return_to), class: "block mt-0 no-underline" %>
23+
<% end %>
24+
<button data-webauthn-auth-target="continueButton" type="submit" class="gap-2 btn">
25+
Continue
26+
</button>
27+
</div>
28+
<% end %>
29+
<%= javascript_include_tag "https://cdn.jsdelivr.net/npm/ua-parser-js/dist/ua-parser.min.js" %>
30+
<%= javascript_include_tag "fingerprint.js" %>
31+
</div>
32+
<%= render partial: "environment_banner" %>
33+
<%= render partial: "footer" %>

0 commit comments

Comments
 (0)