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..dae22c0b9f8 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.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)
+ 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 ApplicationController#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..04b9c0ed14d
--- /dev/null
+++ b/app/controllers/users/totp_controller.rb
@@ -0,0 +1,90 @@
+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
+
+ 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..16a8ccfd569 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 @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| %>
+