From 9f76b68ebcf4997dd34ac176def81d7a8932c8fc Mon Sep 17 00:00:00 2001
From: EchoEkhi
Date: Mon, 18 May 2026 03:54:33 +0100
Subject: [PATCH 1/6] AO3-7442 Add TOTP 2FA for users
---
app/controllers/application_controller.rb | 2 +-
app/controllers/users/sessions_controller.rb | 80 +++++++++++++++++-
app/controllers/users/totp_controller.rb | 83 +++++++++++++++++++
app/models/user.rb | 43 +++++++++-
app/views/preferences/index.html.erb | 20 +++++
app/views/users/sessions/totp.html.erb | 17 ++++
app/views/users/totp/confirm_disable.html.erb | 17 ++++
app/views/users/totp/new.html.erb | 17 ++++
.../users/totp/reauthenticate_create.erb | 38 +++++++++
.../users/totp/show_backup_codes.html.erb | 33 ++++++++
config/config.yml | 2 +
config/initializers/devise.rb | 2 +
config/locales/controllers/en.yml | 24 ++++++
config/locales/views/en.yml | 48 +++++++++++
config/routes.rb | 6 ++
...17234000_add_devise_two_factor_to_users.rb | 12 +++
16 files changed, 441 insertions(+), 3 deletions(-)
create mode 100644 app/controllers/users/totp_controller.rb
create mode 100644 app/views/users/sessions/totp.html.erb
create mode 100644 app/views/users/totp/confirm_disable.html.erb
create mode 100644 app/views/users/totp/new.html.erb
create mode 100644 app/views/users/totp/reauthenticate_create.erb
create mode 100644 app/views/users/totp/show_backup_codes.html.erb
create mode 100644 db/migrate/20260517234000_add_devise_two_factor_to_users.rb
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 089bf55e286..7c84063f3fe 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -218,7 +218,7 @@ def load_tos_popup
end
include PathCleaner
- # Warning: Admin 2FA bypasses this method in SessionsController#authenticate_admin_with_otp_two_factor
+ # Warning: The User and Admin 2FA login flows bypass this method
def after_sign_in_path_for(resource)
if resource.respond_to?(:pwned?) && resource.pwned?
set_flash_message! :alert, :warn_pwned
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
index 7eb3b941222..d27ac3a40de 100644
--- a/app/controllers/users/sessions_controller.rb
+++ b/app/controllers/users/sessions_controller.rb
@@ -1,8 +1,12 @@
class Users::SessionsController < Devise::SessionsController
-
layout "session"
before_action :admin_logout_required
+ prepend_before_action :authenticate_with_totp_two_factor,
+ if: -> { action_name == "create" && totp_two_factor_enabled? }
+
+ protect_from_forgery with: :exception, prepend: true, except: :destroy
+
# POST /users/login
def create
super do |resource|
@@ -28,4 +32,78 @@ def destroy
redirect_to relative_path(params[:return_to]) || root_path
end
+
+ # Two-Factor Authentication
+ def authenticate_with_totp_two_factor
+ user = self.resource = find_user
+
+ if params[:totp_attempt].present? && session[:otp_user_id]
+ authenticate_user_with_otp_two_factor(user)
+ elsif user&.valid_password?(user_params[:password])
+ prompt_for_otp_two_factor(user)
+ end
+ end
+
+ private
+
+ def valid_totp_attempt?(user)
+ user.validate_and_consume_otp!(params[:totp_attempt]) ||
+ user.invalidate_otp_backup_code!(params[:totp_attempt])
+ end
+
+ def prompt_for_otp_two_factor(user)
+ @user = user
+
+ session[:otp_user_id] = user.id
+
+ session[:pwned] = user.respond_to?(:password_pwned?) && user.password_pwned?(user_params[:password]) if params[:user] && params[:user][:password]
+
+ render "users/sessions/totp"
+ end
+
+ def authenticate_user_with_otp_two_factor(user)
+ if valid_totp_attempt?(user)
+ pwned = session[:pwned]
+ # Remove any lingering user data from login
+ session.delete(:otp_user_id)
+ session.delete(:pwned)
+
+ user.save!
+
+ flash[:notice] = t("devise.sessions.signed_in")
+ sign_in(user, event: :authentication)
+
+ # Set the user_credentials flag cookie
+ # because this login flow bypasses ensure_user_credentials#ensure_user_credentials
+ cookies[:user_credentials] = { value: 1, expires: 1.year.from_now } unless cookies[:user_credentials]
+
+ if pwned
+ # Set the pwned flash notice
+ # because this login flow bypasses ApplicationController#after_sign_in_path_for
+ set_flash_message! :alert, :warn_pwned
+ redirect_to change_password_user_path(user)
+ else
+ redirect_to root_path
+ end
+ else
+ flash.now[:error] = t("users.sessions.invalid_totp")
+ prompt_for_otp_two_factor(user)
+ end
+ end
+
+ def user_params
+ params.require(:user).permit(:login, :password, :remember_me)
+ end
+
+ def find_user
+ if session[:otp_user_id]
+ User.find(session[:otp_user_id])
+ elsif user_params[:login]
+ User.find_by(login: user_params[:login])
+ end
+ end
+
+ def totp_two_factor_enabled?
+ find_user&.totp_enabled?
+ end
end
diff --git a/app/controllers/users/totp_controller.rb b/app/controllers/users/totp_controller.rb
new file mode 100644
index 00000000000..9121e070bfd
--- /dev/null
+++ b/app/controllers/users/totp_controller.rb
@@ -0,0 +1,83 @@
+class Users::TotpController < ApplicationController
+ before_action :check_totp_disabled, only: [:new, :reauthenticate_create, :create]
+ before_action :check_totp_enabled, only: [:confirm_disable, :disable]
+
+ def new
+ @page_subtitle = t(".page_title")
+ end
+
+ def reauthenticate_create
+ unless current_user.valid_password?(params[:password_check])
+ flash[:error] = t(".incorrect_password")
+ redirect_to new_user_totp_path and return
+ end
+
+ current_user.generate_totp_secret_if_missing!
+ @page_subtitle = t(".page_title")
+ end
+
+ def create
+ if current_user.validate_and_consume_otp!(params[:totp_attempt])
+ current_user.enable_totp!
+
+ flash[:notice] = t(".success")
+ redirect_to show_backup_codes_user_totp_path
+ else
+ flash.now[:error] = t(".incorrect_code")
+ render action: :new and return
+ end
+ end
+
+ def show_backup_codes
+ unless current_user.totp_enabled?
+ flash[:error] = t(".not_enabled")
+ redirect_to new_user_totp_path and return
+ end
+
+ @page_subtitle = t(".page_title")
+ @backup_codes = current_user.generate_otp_backup_codes!
+ current_user.save!
+ end
+
+ def confirm_disable
+ @page_subtitle = t(".page_title")
+ end
+
+ def disable
+ unless current_user.valid_password?(params[:password_check])
+ flash.now[:error] = t(".incorrect_password")
+ render action: :confirm_disable and return
+ end
+
+ if current_user.disable_totp!
+ flash[:notice] = t(".success")
+ else
+ flash[:error] = t(".failure")
+ end
+
+ redirect_to user_preferences_path
+ end
+
+ private
+
+ def require_user_owner
+ return if params[:user_id] == current_user.login
+
+ flash[:error] = t("users.totp.access.permission_denied_generic")
+ redirect_to root_path
+ end
+
+ def check_totp_enabled
+ return if current_user.totp_enabled?
+
+ flash[:error] = t("users.totp.already_disabled")
+ redirect_to user_preferences_path
+ end
+
+ def check_totp_disabled
+ return unless current_user.totp_enabled?
+
+ flash[:error] = t("users.totp.already_enabled")
+ redirect_to user_preferences_path
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index f3b74d0cb77..56db10a930c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -14,6 +14,10 @@ class User < ApplicationRecord
:validatable,
:lockable,
:recoverable,
+ :two_factor_authenticatable,
+ :two_factor_backupable,
+ otp_backup_code_length: ArchiveConfig.USER_TOTP_BACKUP_CODE_LENGTH,
+ otp_number_of_backup_codes: ArchiveConfig.USER_TOTP_BACKUP_CODE_COUNT,
reset_password_keys: [:email]
devise :pwned_password unless Rails.env.test?
@@ -36,7 +40,7 @@ class User < ApplicationRecord
has_one :invitation, as: :invitee
has_many :user_invite_requests, dependent: :destroy
- attr_accessor :invitation_token
+ attr_accessor :invitation_token, :otp_plain_backup_codes
before_create :create_default_associateds
# attr_accessible :invitation_token
after_create :mark_invitation_redeemed, :remove_from_queue
@@ -175,6 +179,43 @@ class User < ApplicationRecord
has_many :log_items, dependent: :destroy
validates_associated :log_items
+ #####################################
+ # TOTP 2FA
+ #####################################
+
+ serialize :otp_backup_codes, type: Array, coder: YAML, yaml: { permitted_classes: [String] }
+
+ # Generate a TOTP secret it it does not already exist
+ def generate_totp_secret_if_missing!
+ return unless otp_secret.nil?
+
+ update!(otp_secret: User.generate_otp_secret)
+ end
+
+ # Ensure that the user is prompted for their TOTP when they login
+ def enable_totp!
+ update!(otp_required_for_login: true)
+ end
+
+ # Disable the use of TOTP-based two-factor.
+ def disable_totp!
+ update!(
+ otp_required_for_login: false,
+ otp_secret: nil,
+ otp_backup_codes: nil
+ )
+ end
+
+ # Whether this user has TOTP enabled.
+ def totp_enabled?
+ otp_required_for_login
+ end
+
+ # URI for TOTP two-factor QR code
+ def totp_qr_code_uri
+ otp_provisioning_uri(login, issuer: ArchiveConfig.APP_NAME)
+ end
+
def canonicalize_email
self.canonical_email = EmailCanonicalizer.canonicalize(self.email) if self.email
end
diff --git a/app/views/preferences/index.html.erb b/app/views/preferences/index.html.erb
index fa7cfb6e8e2..84b8301b513 100644
--- a/app/views/preferences/index.html.erb
+++ b/app/views/preferences/index.html.erb
@@ -12,6 +12,13 @@
<%= link_to t(".navigation.blocked_users"), user_blocked_users_path %>
<%= link_to t(".navigation.muted_users"), user_muted_users_path %>
<%= link_to t(".navigation.change_username"), change_username_user_path(@user) %>
+
+ <% if current_user.totp_enabled? %>
+ <%= link_to t(".navigation.disable_two_step_verification"), confirm_disable_user_totp_path %>
+ <% else %>
+ <%= link_to t(".navigation.enable_two_step_verification"), new_user_totp_path %>
+ <% end %>
+
<%= link_to t(".navigation.change_password"), change_password_user_path(@user) %>
<%= link_to t(".navigation.change_email"), change_email_user_path(@user) %>
@@ -19,6 +26,19 @@
<%= form_for(@preference, url: user_preference_path(@user, @preference), autocomplete: "off") do |f| %>
+
+ <%= t(".account_security.legend") %>
+ <%= t(".account_security.heading") %>
+
+ <% if current_user.totp_enabled? %>
+ <%= link_to t(".account_security.disable_two_step_verification"), confirm_disable_user_totp_path %>
+ <%= link_to ts("Re-generate two-step verification backup codes"), show_backup_codes_user_totp_path %>
+ <% else %>
+ <%= link_to t(".account_security.enable_two_step_verification"), new_user_totp_path %>
+ <% end %>
+ <%= link_to t(".account_security.change_password"), change_password_user_path(@user) %>
+
+
<%= t(".privacy.legend") %>
<%= t(".privacy.heading") %> <%= link_to_help_modal(help_preferences_privacy_path, t(".privacy.help_title")) %>
diff --git a/app/views/users/sessions/totp.html.erb b/app/views/users/sessions/totp.html.erb
new file mode 100644
index 00000000000..b20334bdfa4
--- /dev/null
+++ b/app/views/users/sessions/totp.html.erb
@@ -0,0 +1,17 @@
+<%= form_with(url: session_path(resource), method: :post, class: "totp simple confirm") do |form| %>
+
+
+
+ <%= t(".page_heading") %>
+ <%= t(".enter_totp") %>
+
+
+
+
+ <%= form.label :totp_attempt, t(".totp_label") %>
+ <%= form.text_field :totp_attempt %>
+ <%= submit_tag t(".submit") %>
+
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/users/totp/confirm_disable.html.erb b/app/views/users/totp/confirm_disable.html.erb
new file mode 100644
index 00000000000..71282bbbb8b
--- /dev/null
+++ b/app/views/users/totp/confirm_disable.html.erb
@@ -0,0 +1,17 @@
+<%= form_with(url: disable_user_totp_path, method: :post, class: "password simple confirm") do |form| %>
+
+
+
+ <%= t(".page_heading") %>
+ <%= t(".required_before_disabling") %>
+
+
+
+
+ <%= form.label :password_check, t(".password_label") %>
+ <%= form.password_field :password_check %>
+ <%= form.submit t(".submit") %>
+
+
+
+<% end %>
diff --git a/app/views/users/totp/new.html.erb b/app/views/users/totp/new.html.erb
new file mode 100644
index 00000000000..7b3f850148e
--- /dev/null
+++ b/app/views/users/totp/new.html.erb
@@ -0,0 +1,17 @@
+<%= form_with(url: reauthenticate_create_user_totp_path, method: :post, class: "password simple confirm") do |form| %>
+
+
+
+ <%= t(".page_heading") %>
+ <%= t(".required_before_enabling") %>
+
+
+
+
+ <%= form.label :password_check, t(".password_label") %>
+ <%= form.password_field :password_check %>
+ <%= form.submit t(".submit") %>
+
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/users/totp/reauthenticate_create.erb b/app/views/users/totp/reauthenticate_create.erb
new file mode 100644
index 00000000000..bd1cf191316
--- /dev/null
+++ b/app/views/users/totp/reauthenticate_create.erb
@@ -0,0 +1,38 @@
+
+<%= t(".page_heading") %>
+
+
+ <%= t(".about.heading") %>
+ <%= t(".about.general_info") %>
+ <%= t(".about.effects_of_enabling", app_name: ArchiveConfig.APP_SHORT_NAME) %>
+
+
+
+
+
+ <%= t(".app_setup.heading") %>
+ <%= t(".app_setup.instructions") %>
+
+
<%= qr_code_as_svg(current_user.totp_qr_code_uri) %>
+
+
+
+
+
+ <%= t(".enter_code.heading") %>
+ <%= t(".enter_code.instructions") %>
+ <%= form_with(url: user_totp_path, method: :post, class: "totp simple confirm") do |form| %>
+
+ <%= form.label :totp_attempt, t(".enter_code.label") %>
+ <%= form.text_field :totp_attempt %>
+ <%= form.submit t(".enter_code.submit"), data: { disable_with: t(".enter_code.please_wait") } %>
+
+ <% end %>
+
+
diff --git a/app/views/users/totp/show_backup_codes.html.erb b/app/views/users/totp/show_backup_codes.html.erb
new file mode 100644
index 00000000000..521ad003d5b
--- /dev/null
+++ b/app/views/users/totp/show_backup_codes.html.erb
@@ -0,0 +1,33 @@
+
+<%= t(".page_heading") %>
+
+<%= t(".write_codes_down") %>
+
+
+
+
+ <% @backup_codes.each do |code| %>
+
+ <%= code %>
+
+ <% end %>
+
+
+
+
+ <%= t(".copy_to_clipboard") %>
+
+ <%= link_to t(".finish"), user_preferences_path %>
+
+
+
+<% content_for :footer_js do %>
+
+<% end %>
diff --git a/config/config.yml b/config/config.yml
index dba41f3485b..a94be58a481 100644
--- a/config/config.yml
+++ b/config/config.yml
@@ -124,6 +124,8 @@ ADMIN_PASSWORD_LENGTH_MIN: 10
ADMIN_PASSWORD_LENGTH_MAX: 72
ADMIN_TOTP_BACKUP_CODE_LENGTH: 16
ADMIN_TOTP_BACKUP_CODE_COUNT: 10
+USER_TOTP_BACKUP_CODE_LENGTH: 16
+USER_TOTP_BACKUP_CODE_COUNT: 10
# The maximum number of tags you can add to a collection
COLLECTION_TAGS_MAX: 10
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index b46e58a708e..afd72bd694a 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -4,6 +4,8 @@
config.warden do |manager|
manager.default_strategies(scope: :admin).unshift :two_factor_authenticatable
manager.default_strategies(scope: :admin).unshift :two_factor_backupable
+ manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
+ manager.default_strategies(scope: :user).unshift :two_factor_backupable
end
# The secret key used by Devise. Devise uses this key to generate
diff --git a/config/locales/controllers/en.yml b/config/locales/controllers/en.yml
index 880ebc12684..bc32510ad58 100644
--- a/config/locales/controllers/en.yml
+++ b/config/locales/controllers/en.yml
@@ -384,9 +384,33 @@ en:
page_title: Account Created
new:
page_title: Create Account
+ sessions:
+ invalid_totp: Incorrect two-step verification code. If you no longer have access to your authenticator app, you can enter one of your backup codes instead.
status:
ban_notice_html: Your account has been banned. You are not permitted to post or edit content on AO3. Please check your email or %{contact_abuse_link} for more information.
suspension_notice_html: Your account has been suspended until %{suspended_until}. You cannot post, edit, or delete content until your suspension has ended. Please check your email or %{contact_abuse_link} for more information.
+ totp:
+ access:
+ permission_denied_generic: Sorry, you don't have permission to access the page you were trying to reach.
+ already_disabled: TOTP two-step verification is already disabled.
+ already_enabled: TOTP two-step verification is already enabled.
+ confirm_disable:
+ page_title: Disable Two-Step Verification
+ create:
+ incorrect_code: Incorrect verification code. Your code may have expired, or you may need to set up your authenticator app again.
+ success: Successfully enabled two-step verification; please make note of your backup codes.
+ disable:
+ failure: Could not disable two-step verification.
+ incorrect_password: Your password was incorrect. Please try again or, if you've forgotten your password, log out and reset your password via the link on the login form.
+ success: Successfully disabled two-step verification.
+ new:
+ page_title: Confirm Password
+ reauthenticate_create:
+ incorrect_password: Your password was incorrect. Please try again or, if you've forgotten your password, log out and reset your password via the link on the login form.
+ page_title: Enable Two-Step Verification
+ show_backup_codes:
+ not_enabled: Please enable two-step verification first.
+ page_title: Two-Step Verification Backup Codes
works:
create:
draft_notice_html: Draft was successfully created. It will be %{scheduled_for_deletion_bold} on %{deletion_date}.
diff --git a/config/locales/views/en.yml b/config/locales/views/en.yml
index 630a3291f93..f94b1ff22f9 100644
--- a/config/locales/views/en.yml
+++ b/config/locales/views/en.yml
@@ -2587,6 +2587,12 @@ en:
page_heading: Matching for %{collection_title}
preferences:
index:
+ account_security:
+ change_password: Change password
+ disable_two_step_verification: Disable two-step verification
+ enable_two_step_verification: Enable two-step verification
+ heading: Account Security
+ legend: Account Security
browser_page_title_format: Browser page title format
collections_challenges_gifts:
allow_collection_invitation: Allow others to invite my works to collections.
@@ -2628,7 +2634,9 @@ en:
change_email: Change Email
change_password: Change Password
change_username: Change Username
+ disable_two_step_verification: Disable Two-Step Verification
edit_my_profile: Edit My Profile
+ enable_two_step_verification: Enable Two-Step Verification
landmark: Navigation
manage_my_pseuds: Manage My Pseuds
muted_users: Muted Users
@@ -3189,6 +3197,11 @@ en:
password: 'Password:'
remember_me: Remember Me
username_or_email: 'Username or email:'
+ totp:
+ enter_totp: Enter the verification code from your authenticator app to finish logging in to your account.
+ page_heading: Two-Step Verification
+ submit: Log In
+ totp_label: 6-digit verification code
show:
login_banner:
contact_abuse: contact our Policy & Abuse team
@@ -3230,6 +3243,41 @@ en:
co_creator_requests: Co-Creator Requests (%{count})
related_works: Related Works (%{related_works_number})
sign_ups: Sign-ups (%{signup_number})
+ totp:
+ confirm_disable:
+ page_heading: Disable Two-Step Verification
+ password_label: Password
+ required_before_disabling: Before disabling two-step verification, you need to verify your password.
+ submit: Disable Two-Step Verification
+ new:
+ page_heading: Confirm Password
+ password_label: Password
+ required_before_enabling: Before enabling two-step verification, you need to verify your password.
+ submit: Continue
+ reauthenticate_create:
+ about:
+ effects_of_enabling: When two-step verification is enabled, logging in to your %{app_name} account will require both your password and a verification code from your authenticator app.
+ general_info: Two-step verification, also called two-factor authentication, helps prevent other people from accessing your account even if they know your password. All you need to set it up is an authenticator app such as 1Password, Google Authenticator, or Twilio Authy.
+ heading: About Two-Step Verification
+ app_setup:
+ heading: 'Step 1: Set up your authenticator app'
+ instructions: Use your authenticator app to scan the QR code or enter the manual setup key. If you are using the mobile device the app is already installed on, click the setup link. The app will give you a 6-digit code, which you'll use in the next step.
+ key:
+ format_note: Spaces and capitalization don't matter
+ heading: Setup Key
+ enter_code:
+ heading: 'Step 2: Enter your 6-digit code'
+ instructions: Enter the 6-digit code from your app to enable two-step verification.
+ label: 6-digit code
+ please_wait: Please wait...
+ submit: Enable Two-Step Verification
+ page_heading: Set Up Two-Step Verification
+ show_backup_codes:
+ backup_codes_copied: Backup Codes Copied!
+ copy_to_clipboard: Copy Backup Codes to Clipboard
+ finish: Finish
+ page_heading: Two-Step Verification Backup Codes
+ write_codes_down: 'Keep these backup codes in a safe and secure place in case you lose access to your authenticator app:'
works:
adult:
caution: This work could have adult content. If you continue, you have agreed that you are willing to see such content.
diff --git a/config/routes.rb b/config/routes.rb
index 5c5c951551f..73cf43a3362 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -310,6 +310,12 @@
end
resources :nominations, controller: "tag_set_nominations", only: [:index]
resources :preferences, only: [:index, :update]
+ resource :totp, controller: "users/totp", only: [:create, :new] do
+ get :show_backup_codes
+ get :confirm_disable
+ post :disable
+ post :reauthenticate_create
+ end
resource :profile, only: [:show, :edit, :update], controller: "profile" do
collection do
get :pseuds
diff --git a/db/migrate/20260517234000_add_devise_two_factor_to_users.rb b/db/migrate/20260517234000_add_devise_two_factor_to_users.rb
new file mode 100644
index 00000000000..10fd0bcf44f
--- /dev/null
+++ b/db/migrate/20260517234000_add_devise_two_factor_to_users.rb
@@ -0,0 +1,12 @@
+class AddDeviseTwoFactorToUsers < ActiveRecord::Migration[8.1]
+ uses_departure! if Rails.env.staging? || Rails.env.production?
+
+ def change
+ change_table :users, bulk: true do |t|
+ t.column :otp_secret, :string
+ t.column :consumed_timestep, :integer
+ t.column :otp_required_for_login, :boolean
+ t.column :otp_backup_codes, :text
+ end
+ end
+end
From 1414ed7067b08bcc777f0e55330239a6d487c451 Mon Sep 17 00:00:00 2001
From: EchoEkhi
Date: Mon, 18 May 2026 04:06:10 +0100
Subject: [PATCH 2/6] Add copy key button
---
.../users/totp/reauthenticate_create.erb | 24 ++++++++++++++++---
config/locales/views/en.yml | 4 +++-
.../site/2.0/22-system-messages.css | 6 +++++
3 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/app/views/users/totp/reauthenticate_create.erb b/app/views/users/totp/reauthenticate_create.erb
index bd1cf191316..a56905743c7 100644
--- a/app/views/users/totp/reauthenticate_create.erb
+++ b/app/views/users/totp/reauthenticate_create.erb
@@ -16,10 +16,17 @@
<%= qr_code_as_svg(current_user.totp_qr_code_uri) %>
@@ -36,3 +43,14 @@
<% end %>
+
+<% content_for :footer_js do %>
+
+<% end %>
\ No newline at end of file
diff --git a/config/locales/views/en.yml b/config/locales/views/en.yml
index f94b1ff22f9..e684384efc7 100644
--- a/config/locales/views/en.yml
+++ b/config/locales/views/en.yml
@@ -3261,8 +3261,10 @@ en:
heading: About Two-Step Verification
app_setup:
heading: 'Step 1: Set up your authenticator app'
- instructions: Use your authenticator app to scan the QR code or enter the manual setup key. If you are using the mobile device the app is already installed on, click the setup link. The app will give you a 6-digit code, which you'll use in the next step.
+ instructions: Use your authenticator app to scan the QR code or enter the manual setup key. If you are using the mobile device the app is already installed on, click the import settings button. The app will give you a 6-digit code, which you'll use in the next step.
key:
+ copied: Copied!
+ copy_to_clipboard: Copy
format_note: Spaces and capitalization don't matter
heading: Setup Key
enter_code:
diff --git a/public/stylesheets/site/2.0/22-system-messages.css b/public/stylesheets/site/2.0/22-system-messages.css
index 6c0247f6d7a..1498d2b877f 100644
--- a/public/stylesheets/site/2.0/22-system-messages.css
+++ b/public/stylesheets/site/2.0/22-system-messages.css
@@ -123,6 +123,12 @@ from the main flow. */
float: none;
}
+.annotation .actions {
+ float: none;
+ margin-block-end: 0;
+ padding-block-end: 0;
+}
+
/* contexts */
.actions + .footnote {
From 97f69cce2df0d26d0c6407f3a0eba5b553a8a848 Mon Sep 17 00:00:00 2001
From: EchoEkhi
Date: Mon, 18 May 2026 05:59:27 +0100
Subject: [PATCH 3/6] Add tests
---
app/controllers/users/totp_controller.rb | 7 +
features/step_definitions/user_steps.rb | 19 ++
.../authenticate_users_with_totp.feature | 170 +++++++++++
.../controllers/users/totp_controller_spec.rb | 272 ++++++++++++++++++
4 files changed, 468 insertions(+)
create mode 100644 features/users/authenticate_users_with_totp.feature
create mode 100644 spec/controllers/users/totp_controller_spec.rb
diff --git a/app/controllers/users/totp_controller.rb b/app/controllers/users/totp_controller.rb
index 9121e070bfd..04b9c0ed14d 100644
--- a/app/controllers/users/totp_controller.rb
+++ b/app/controllers/users/totp_controller.rb
@@ -1,7 +1,14 @@
class Users::TotpController < ApplicationController
+ before_action :load_user
+ before_action :check_ownership_or_admin
before_action :check_totp_disabled, only: [:new, :reauthenticate_create, :create]
before_action :check_totp_enabled, only: [:confirm_disable, :disable]
+ def load_user
+ @user = User.find_by!(login: params[:user_id])
+ @check_ownership_of = @user
+ end
+
def new
@page_subtitle = t(".page_title")
end
diff --git a/features/step_definitions/user_steps.rb b/features/step_definitions/user_steps.rb
index 595cd2472e8..9240ccb456e 100644
--- a/features/step_definitions/user_steps.rb
+++ b/features/step_definitions/user_steps.rb
@@ -412,3 +412,22 @@ def get_series_name(age, classname, name)
invitation_id = User.find_by(login: login).invitation.id
step %{I should see "Invitation: #{invitation_id}"}
end
+
+Given "user {string} has TOTP 2FA enabled" do |login|
+ user = User.find_by(login: login) || FactoryBot.create(:user, login: login)
+ user.generate_totp_secret_if_missing!
+ user.enable_totp!
+end
+
+When "I fill in a valid TOTP two-step verification code for user {string}" do |login|
+ user = User.find_by(login: login)
+ fill_in "totp_attempt", with: user.current_otp
+end
+
+When "I fill in a valid TOTP recovery code for user {string}" do |login|
+ user = User.find_by(login: login)
+ codes = user.generate_otp_backup_codes!
+ user.save!
+ fill_in "totp_attempt", with: codes.first
+ @used_totp_recovery_code = codes.first
+end
diff --git a/features/users/authenticate_users_with_totp.feature b/features/users/authenticate_users_with_totp.feature
new file mode 100644
index 00000000000..1b6fc4975ff
--- /dev/null
+++ b/features/users/authenticate_users_with_totp.feature
@@ -0,0 +1,170 @@
+@admin
+Feature: Authenticate Users With TOTP 2FA
+ Scenario: Users can enable TOTP 2FA
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And I am logged in as "user" with password "testpassword"
+ When I follow "My Preferences"
+ Then I should see "Enable Two-Step Verification"
+ When I follow "Enable Two-Step Verification"
+ Then I should see "Confirm Password"
+ When I fill in "Password" with "testpassword"
+ And I press "Continue"
+ Then I should see "Set Up Two-Step Verification"
+ When I fill in a valid TOTP two-step verification code for user "user"
+ And I press "Enable Two-Step Verification"
+ Then I should see "Successfully enabled two-step verification; please make note of your backup codes."
+ And I should see "Finish"
+ When I follow "Finish"
+ Then I should see "Set My Preferences"
+
+ Scenario: Users cannot enable TOTP 2FA if they provide a wrong password
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And I am logged in as "user" with password "testpassword"
+ When I follow "My Preferences"
+ Then I should see "Enable Two-Step Verification"
+ When I follow "Enable Two-Step Verification"
+ Then I should see "Confirm Password"
+ When I fill in "Password" with "wrongpassword"
+ And I press "Continue"
+ Then I should see "Your password was incorrect."
+ And I should not see "Successfully enabled two-step verification, please make note of your backup codes."
+ And I should not see "Finish"
+
+ Scenario: Users cannot enable TOTP 2FA if they provide a wrong code
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And I am logged in as "user" with password "testpassword"
+ When I follow "My Preferences"
+ Then I should see "Enable Two-Step Verification"
+ When I follow "Enable Two-Step Verification"
+ Then I should see "Confirm Password"
+ When I fill in "Password" with "testpassword"
+ And I press "Continue"
+ Then I should see "Set Up Two-Step Verification"
+ When I fill in "6-digit code" with "000000"
+ And I press "Enable Two-Step Verification"
+ Then I should see "Incorrect verification code. Your code may have expired, or you may need to set up your authenticator app again."
+ And I should not see "Successfully enabled two-step verification, please make note of your backup codes."
+ And I should not see "Finish"
+
+ Scenario: Users can disable TOTP 2FA
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And I am logged in as "user" with password "testpassword"
+ And user "user" has TOTP 2FA enabled
+ When I follow "My Preferences"
+ And I follow "Disable two-step verification"
+ Then I should see "Disable Two-Step Verification"
+ When I fill in "Password" with "testpassword"
+ And I press "Disable"
+ Then I should see "Successfully disabled two-step verification."
+
+ Scenario: Admins cannot disable TOTP 2FA if they provide a wrong password
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And I am logged in as "user" with password "testpassword"
+ And user "user" has TOTP 2FA enabled
+ When I follow "My Preferences"
+ And I follow "Disable two-step verification"
+ Then I should see "Disable Two-Step Verification"
+ When I fill in "Password" with "wrongpassword"
+ And I press "Disable"
+ Then I should see "Your password was incorrect."
+ And I should not see "Successfully disabled two-step verification."
+
+ Scenario: Users can regenerate TOTP backup codes
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And I am logged in as "user" with password "testpassword"
+ And user "user" has TOTP 2FA enabled
+ When I follow "My Preferences"
+ And I follow "Re-generate two-step verification backup codes"
+ Then I should see "Two-Step Verification Backup Codes"
+ When I follow "Finish"
+ Then I should see "Set My Preferences"
+
+ Scenario: Users with TOTP 2FA enabled can log in after providing their code
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And user "user" has TOTP 2FA enabled
+ When I go to the login page
+ And I fill in "Username or email" with "user"
+ And I fill in "Password" with "testpassword"
+ And I press "Log In"
+ Then I should see "Enter the verification code from your authenticator app"
+ When I fill in a valid TOTP two-step verification code for user "user"
+ And I press "Log In" within "div#main"
+ Then I should see "Successfully logged in"
+
+ Scenario: Users with TOTP 2FA enabled can log in after providing their recovery code
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And user "user" has TOTP 2FA enabled
+ When I go to the admin login page
+ And I fill in "Username or email" with "user"
+ And I fill in "Password" with "testpassword"
+ And I press "Log In"
+ Then I should see "Enter the verification code from your authenticator app"
+ When I fill in a valid TOTP recovery code for user "user"
+ And I press "Log In" within "div#main"
+ Then I should see "Successfully logged in"
+
+ Scenario: Users with TOTP 2FA enabled cannot log in with a used recovery code
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And user "user" has TOTP 2FA enabled
+ When I go to the login page
+ And I fill in "Username or email" with "user"
+ And I fill in "Password" with "testpassword"
+ And I press "Log In"
+ Then I should see "Enter the verification code from your authenticator app"
+ When I fill in a valid TOTP recovery code for user "user"
+ And I press "Log In" within "div#main"
+ Then I should see "Successfully logged in"
+ When I log out
+ And I go to the admin login page
+ And I fill in "Username or email" with "user"
+ And I fill in "Password" with "testpassword"
+ And I press "Log In"
+ And I fill in a used TOTP recovery code
+ And I press "Log In" within "div#main"
+ Then I should see "Incorrect two-step verification code."
+ And I should not see "Successfully logged in"
+
+ Scenario: Users with TOTP 2FA enabled should not be prompted for their code if they enter invalid credentials
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And user "user" has TOTP 2FA enabled
+ When I go to the login page
+ And I fill in "Username or email" with "user"
+ And I fill in "Password" with "wrongpassword"
+ And I press "Log In"
+ Then I should not see "Enter the verification code from your authenticator app"
+ And I should see "The password or username you entered doesn't match our records."
+
+ Scenario: Users with TOTP 2FA enabled cannot log in after providing a wrong code
+ Given the following activated users exist
+ | login | password |
+ | user | testpassword |
+ And user "user" has TOTP 2FA enabled
+ When I go to the login page
+ And I fill in "Username or email" with "user"
+ And I fill in "Password" with "testpassword"
+ And I press "Log In"
+ Then I should see "Enter the verification code from your authenticator app"
+ When I fill in "6-digit verification code" with "000000"
+ And I press "Log In" within "div#main"
+ Then I should see "Incorrect two-step verification code."
+ And I should not see "Successfully logged in"
diff --git a/spec/controllers/users/totp_controller_spec.rb b/spec/controllers/users/totp_controller_spec.rb
new file mode 100644
index 00000000000..c629d83b870
--- /dev/null
+++ b/spec/controllers/users/totp_controller_spec.rb
@@ -0,0 +1,272 @@
+require "spec_helper"
+
+describe Users::TotpController do
+ include LoginMacros
+ include RedirectExpectationHelper
+
+ describe "GET #new" do
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user, otp_required_for_login: true) }
+
+ it "denies access to guest users" do
+ get :new, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach. Please log in.")
+ end
+
+ it "denies access to other users" do
+ fake_login_known_user(other_user)
+ get :new, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+
+ context "when logged in as user" do
+ it "allows access to their own page when TOTP is disabled" do
+ fake_login_known_user(user)
+ get :new, params: { user_id: user.login }
+ expect(response).to have_http_status(:success)
+ end
+
+ it "denies access to their own page when TOTP is enabled" do
+ fake_login_known_user(other_user)
+ get :new, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_preferences_path(other_user), "TOTP two-step verification is already enabled.")
+ end
+
+ it "denies access to other's pages" do
+ fake_login_known_user(user)
+ get :new, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_path(other_user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+ end
+ end
+
+ describe "POST #reauthenticate_create" do
+ let(:user) { create(:user, password: "correct_password") }
+ let(:other_user) { create(:user, otp_required_for_login: true) }
+
+ it "denies access to guest users" do
+ post :reauthenticate_create, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach. Please log in.")
+ end
+
+ it "denies access to other users" do
+ fake_login_known_user(other_user)
+ post :reauthenticate_create, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+
+ context "when logged in as user" do
+ before do
+ user.generate_totp_secret_if_missing!
+ end
+
+ it "continues to enabling TOTP form when the password is correct" do
+ fake_login_known_user(user)
+ post :reauthenticate_create, params: { user_id: user.login, password_check: "correct_password" }
+ expect(flash).to be_empty
+ expect(response).to render_template(:reauthenticate_create)
+ end
+
+ it "denies access when password is wrong" do
+ fake_login_known_user(user)
+ post :reauthenticate_create, params: { user_id: user.login, password_check: "wrong_password" }
+ expect(user.reload.totp_enabled?).to be_falsey
+ expect(flash[:error]).to eq("Your password was incorrect. Please try again or, if you've forgotten your password, log out and reset your password via the link on the login form.")
+ end
+
+ it "denies access when TOTP is enabled" do
+ fake_login_known_user(other_user)
+ post :reauthenticate_create, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_preferences_path(other_user), "TOTP two-step verification is already enabled.")
+ end
+
+ it "denies access to other's pages" do
+ fake_login_known_user(user)
+ post :reauthenticate_create, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_path(other_user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+ end
+ end
+
+ describe "POST #create" do
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user, otp_required_for_login: true) }
+
+ it "denies access to guest users" do
+ post :create, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach. Please log in.")
+ end
+
+ it "denies access to other users" do
+ fake_login
+ post :create, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+
+ context "when logged in as user" do
+ before do
+ user.generate_totp_secret_if_missing!
+ end
+
+ it "allows enabling TOTP" do
+ fake_login_known_user(user)
+ post :create, params: { user_id: user.login, totp_attempt: user.current_otp }
+ expect(user.reload.totp_enabled?).to be_truthy
+ it_redirects_to_with_notice(show_backup_codes_user_totp_path, "Successfully enabled two-step verification; please make note of your backup codes.")
+ end
+
+ it "denies access when TOTP code is wrong" do
+ fake_login_known_user(user)
+ post :create, params: { user_id: user.login, totp_attempt: "000000" }
+ expect(user.reload.totp_enabled?).to be_falsey
+ expect(flash[:error]).to eq("Incorrect verification code. Your code may have expired, or you may need to set up your authenticator app again.")
+ end
+
+ it "denies access when TOTP is enabled" do
+ fake_login_known_user(other_user)
+ post :create, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_preferences_path(other_user), "TOTP two-step verification is already enabled.")
+ end
+
+ it "denies access to other's pages" do
+ fake_login_known_user(user)
+ post :create, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_path(other_user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+ end
+ end
+
+ describe "GET #show_backup_codes" do
+ let(:user) { create(:user, otp_required_for_login: true) }
+ let(:other_user) { create(:user) }
+
+ it "denies access to guest users" do
+ get :show_backup_codes, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach. Please log in.")
+ end
+
+ it "denies access to other users" do
+ fake_login
+ get :show_backup_codes, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+
+ context "when logged in as user" do
+ before do
+ user.generate_totp_secret_if_missing!
+ end
+
+ it "shows the backup codes once" do
+ fake_login_known_user(user)
+ get :show_backup_codes, params: { user_id: user.login }
+ expect(response).to have_http_status(:success)
+ end
+
+ it "denies access to other's pages" do
+ fake_login_known_user(user)
+ get :show_backup_codes, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_path(other_user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+
+ it "denies access when TOTP is disabled" do
+ fake_login_known_user(other_user)
+ get :show_backup_codes, params: { user_id: other_user.login }
+ it_redirects_to_with_error(new_user_totp_path, "Please enable two-step verification first.")
+ end
+ end
+ end
+
+ describe "GET #confirm_disable" do
+ let(:user) { create(:user, otp_required_for_login: true) }
+ let(:other_user) { create(:user) }
+
+ it "denies access to guest users" do
+ get :confirm_disable, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach. Please log in.")
+ end
+
+ it "denies access to other users" do
+ fake_login
+ get :confirm_disable, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+
+ context "when logged in as user" do
+ before do
+ user.generate_totp_secret_if_missing!
+ user.generate_otp_backup_codes!
+ user.save!
+ end
+
+ it "allows access to their own page when TOTP is enabled" do
+ fake_login_known_user(user)
+ get :confirm_disable, params: { user_id: user.login }
+ expect(response).to have_http_status(:success)
+ end
+
+ it "denies access when TOTP is disabled" do
+ fake_login_known_user(other_user)
+ get :confirm_disable, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_preferences_path(other_user),
+ "TOTP two-step verification is already disabled.")
+ end
+
+ it "denies access to other's pages" do
+ fake_login_known_user(user)
+ get :confirm_disable, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_path(other_user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+ end
+ end
+
+ describe "POST #disable_totp" do
+ let(:user) { create(:user, password: "correct_password", otp_required_for_login: true) }
+ let(:other_user) { create(:user) }
+
+ it "denies access to guest users" do
+ post :disable, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach. Please log in.")
+ end
+
+ it "denies access to other users" do
+ fake_login
+ post :disable, params: { user_id: user.login }
+ it_redirects_to_with_error(user_path(user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+
+ context "when logged in as user" do
+ before do
+ user.generate_totp_secret_if_missing!
+ user.generate_otp_backup_codes!
+ user.save!
+ end
+
+ it "allows disabling TOTP" do
+ fake_login_known_user(user)
+ post :disable, params: { user_id: user.login, password_check: "correct_password" }
+ expect(user.reload.totp_enabled?).to be_falsey
+ it_redirects_to_with_notice(user_preferences_path(user), "Successfully disabled two-step verification.")
+ end
+
+ it "denies access when password is wrong" do
+ fake_login_known_user(user)
+ post :disable, params: { user_id: user.login, password_check: "wrong_password" }
+ expect(user.reload.totp_enabled?).to be_truthy
+ expect(flash[:error]).to eq("Your password was incorrect. Please try again or, if you've forgotten your password, log out and reset your password via the link on the login form.")
+ end
+
+ it "denies access when TOTP is disabled" do
+ fake_login_known_user(other_user)
+ post :disable, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_preferences_path(other_user),
+ "TOTP two-step verification is already disabled.")
+ end
+
+ it "denies access to other's pages" do
+ fake_login_known_user(user)
+ post :disable, params: { user_id: other_user.login }
+ it_redirects_to_with_error(user_path(other_user), "Sorry, you don't have permission to access the page you were trying to reach.")
+ end
+ end
+ end
+end
From acf66e3c774465653f410b0e99762ace40f214bb Mon Sep 17 00:00:00 2001
From: EchoEkhi
Date: Mon, 18 May 2026 06:11:42 +0100
Subject: [PATCH 4/6] typos
---
app/controllers/users/sessions_controller.rb | 4 ++--
app/views/preferences/index.html.erb | 2 +-
app/views/users/totp/new.html.erb | 2 +-
app/views/users/totp/reauthenticate_create.erb | 2 +-
config/locales/views/en.yml | 1 +
5 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
index d27ac3a40de..dae22c0b9f8 100644
--- a/app/controllers/users/sessions_controller.rb
+++ b/app/controllers/users/sessions_controller.rb
@@ -37,7 +37,7 @@ def destroy
def authenticate_with_totp_two_factor
user = self.resource = find_user
- if params[:totp_attempt].present? && session[:otp_user_id]
+ if params.key?(:totp_attempt) && session[:otp_user_id]
authenticate_user_with_otp_two_factor(user)
elsif user&.valid_password?(user_params[:password])
prompt_for_otp_two_factor(user)
@@ -74,7 +74,7 @@ def authenticate_user_with_otp_two_factor(user)
sign_in(user, event: :authentication)
# Set the user_credentials flag cookie
- # because this login flow bypasses ensure_user_credentials#ensure_user_credentials
+ # because this login flow bypasses ApplicationController#ensure_user_credentials
cookies[:user_credentials] = { value: 1, expires: 1.year.from_now } unless cookies[:user_credentials]
if pwned
diff --git a/app/views/preferences/index.html.erb b/app/views/preferences/index.html.erb
index 84b8301b513..b66ae5dc6df 100644
--- a/app/views/preferences/index.html.erb
+++ b/app/views/preferences/index.html.erb
@@ -32,7 +32,7 @@
<% if current_user.totp_enabled? %>
<%= link_to t(".account_security.disable_two_step_verification"), confirm_disable_user_totp_path %>
- <%= link_to ts("Re-generate two-step verification backup codes"), show_backup_codes_user_totp_path %>
+ <%= link_to t(".account_security.regenerate_backup_codes"), show_backup_codes_user_totp_path %>
<% else %>
<%= link_to t(".account_security.enable_two_step_verification"), new_user_totp_path %>
<% end %>
diff --git a/app/views/users/totp/new.html.erb b/app/views/users/totp/new.html.erb
index 7b3f850148e..1cbd48df241 100644
--- a/app/views/users/totp/new.html.erb
+++ b/app/views/users/totp/new.html.erb
@@ -14,4 +14,4 @@
-<% end %>
\ No newline at end of file
+<% end %>
diff --git a/app/views/users/totp/reauthenticate_create.erb b/app/views/users/totp/reauthenticate_create.erb
index a56905743c7..c5c64ac62b3 100644
--- a/app/views/users/totp/reauthenticate_create.erb
+++ b/app/views/users/totp/reauthenticate_create.erb
@@ -53,4 +53,4 @@
document.getElementById("copy-totp-setup-key").textContent = "<%= t(".app_setup.key.copied") %>";
}
-<% end %>
\ No newline at end of file
+<% end %>
diff --git a/config/locales/views/en.yml b/config/locales/views/en.yml
index e684384efc7..0a75a24a4c4 100644
--- a/config/locales/views/en.yml
+++ b/config/locales/views/en.yml
@@ -2593,6 +2593,7 @@ en:
enable_two_step_verification: Enable two-step verification
heading: Account Security
legend: Account Security
+ regenerate_backup_codes: Re-generate two-step verification backup codes
browser_page_title_format: Browser page title format
collections_challenges_gifts:
allow_collection_invitation: Allow others to invite my works to collections.
From e196565a88f995dc7d89f887acb138e9cc69429b Mon Sep 17 00:00:00 2001
From: EchoEkhi
Date: Mon, 18 May 2026 06:23:06 +0100
Subject: [PATCH 5/6] Fix test
---
app/views/preferences/index.html.erb | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/views/preferences/index.html.erb b/app/views/preferences/index.html.erb
index b66ae5dc6df..16a8ccfd569 100644
--- a/app/views/preferences/index.html.erb
+++ b/app/views/preferences/index.html.erb
@@ -13,7 +13,7 @@
<%= link_to t(".navigation.muted_users"), user_muted_users_path %>
<%= link_to t(".navigation.change_username"), change_username_user_path(@user) %>
- <% if current_user.totp_enabled? %>
+ <% if @user.totp_enabled? %>
<%= link_to t(".navigation.disable_two_step_verification"), confirm_disable_user_totp_path %>
<% else %>
<%= link_to t(".navigation.enable_two_step_verification"), new_user_totp_path %>
@@ -30,7 +30,7 @@
<%= t(".account_security.legend") %>
<%= t(".account_security.heading") %>
- <% if current_user.totp_enabled? %>
+ <% if @user.totp_enabled? %>
<%= link_to t(".account_security.disable_two_step_verification"), confirm_disable_user_totp_path %>
<%= link_to t(".account_security.regenerate_backup_codes"), show_backup_codes_user_totp_path %>
<% else %>
From f98be92dcef87f10200f65e3d0b2f262304b1153 Mon Sep 17 00:00:00 2001
From: EchoEkhi
Date: Mon, 18 May 2026 06:50:46 +0100
Subject: [PATCH 6/6] rubocop
---
app/views/users/sessions/totp.html.erb | 2 +-
features/users/authenticate_users_with_totp.feature | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/app/views/users/sessions/totp.html.erb b/app/views/users/sessions/totp.html.erb
index b20334bdfa4..50e86734ee4 100644
--- a/app/views/users/sessions/totp.html.erb
+++ b/app/views/users/sessions/totp.html.erb
@@ -14,4 +14,4 @@
-<% end %>
\ No newline at end of file
+<% end %>
diff --git a/features/users/authenticate_users_with_totp.feature b/features/users/authenticate_users_with_totp.feature
index 1b6fc4975ff..dc634d22414 100644
--- a/features/users/authenticate_users_with_totp.feature
+++ b/features/users/authenticate_users_with_totp.feature
@@ -1,4 +1,4 @@
-@admin
+@users
Feature: Authenticate Users With TOTP 2FA
Scenario: Users can enable TOTP 2FA
Given the following activated users exist
@@ -65,7 +65,7 @@ Feature: Authenticate Users With TOTP 2FA
And I press "Disable"
Then I should see "Successfully disabled two-step verification."
- Scenario: Admins cannot disable TOTP 2FA if they provide a wrong password
+ Scenario: Users cannot disable TOTP 2FA if they provide a wrong password
Given the following activated users exist
| login | password |
| user | testpassword |
@@ -110,7 +110,7 @@ Feature: Authenticate Users With TOTP 2FA
| login | password |
| user | testpassword |
And user "user" has TOTP 2FA enabled
- When I go to the admin login page
+ When I go to the login page
And I fill in "Username or email" with "user"
And I fill in "Password" with "testpassword"
And I press "Log In"
@@ -133,7 +133,7 @@ Feature: Authenticate Users With TOTP 2FA
And I press "Log In" within "div#main"
Then I should see "Successfully logged in"
When I log out
- And I go to the admin login page
+ And I go to the login page
And I fill in "Username or email" with "user"
And I fill in "Password" with "testpassword"
And I press "Log In"