Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 79 additions & 1 deletion app/controllers/users/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -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|
Expand All @@ -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
90 changes: 90 additions & 0 deletions app/controllers/users/totp_controller.rb
Original file line number Diff line number Diff line change
@@ -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
43 changes: 42 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions app/views/preferences/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,33 @@
<li><%= link_to t(".navigation.blocked_users"), user_blocked_users_path %></li>
<li><%= link_to t(".navigation.muted_users"), user_muted_users_path %></li>
<li><%= link_to t(".navigation.change_username"), change_username_user_path(@user) %></li>
<li>
<% 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 %>
</li>
<li><%= link_to t(".navigation.change_password"), change_password_user_path(@user) %></li>
<li><%= link_to t(".navigation.change_email"), change_email_user_path(@user) %></li>
</ul>
<!--/subnav-->

<!--main content-->
<%= form_for(@preference, url: user_preference_path(@user, @preference), autocomplete: "off") do |f| %>
<fieldset>
<legend><%= t(".account_security.legend") %></legend>
<h4 class="heading"><%= t(".account_security.heading") %></h4>
<ul>
<% if @user.totp_enabled? %>
<li><%= link_to t(".account_security.disable_two_step_verification"), confirm_disable_user_totp_path %></li>
<li><%= link_to t(".account_security.regenerate_backup_codes"), show_backup_codes_user_totp_path %></li>
<% else %>
<li><%= link_to t(".account_security.enable_two_step_verification"), new_user_totp_path %></li>
<% end %>
<li><%= link_to t(".account_security.change_password"), change_password_user_path(@user) %></li>
</ul>
</fieldset>
<fieldset>
<legend><%= t(".privacy.legend") %></legend>
<h4 class="heading"><%= t(".privacy.heading") %> <%= link_to_help_modal(help_preferences_privacy_path, t(".privacy.help_title")) %></h4>
Expand Down
17 changes: 17 additions & 0 deletions app/views/users/sessions/totp.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<%= form_with(url: session_path(resource), method: :post, class: "totp simple confirm") do |form| %>
<fieldset>

<!--Descriptive page name, messages and instructions-->
<legend><h2 class="heading"><%= t(".page_heading") %></h2></legend>
<p class="note"><%= t(".enter_totp") %></p>
<!--/descriptions-->

<!--main content-->
<p>
<%= form.label :totp_attempt, t(".totp_label") %>
<%= form.text_field :totp_attempt %>
<%= submit_tag t(".submit") %>
</p>
<!--/content-->
</fieldset>
<% end %>
17 changes: 17 additions & 0 deletions app/views/users/totp/confirm_disable.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<%= form_with(url: disable_user_totp_path, method: :post, class: "password simple confirm") do |form| %>
<fieldset>

<!--Descriptive page name, messages and instructions-->
<legend><h2 class="heading"><%= t(".page_heading") %></h2></legend>
<p class="note"><%= t(".required_before_disabling") %></p>
<!--/descriptions-->

<!--main content-->
<p>
<%= form.label :password_check, t(".password_label") %>
<%= form.password_field :password_check %>
<%= form.submit t(".submit") %>
</p>
<!--/content-->
</fieldset>
<% end %>
17 changes: 17 additions & 0 deletions app/views/users/totp/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<%= form_with(url: reauthenticate_create_user_totp_path, method: :post, class: "password simple confirm") do |form| %>
<fieldset>

<!--Descriptive page name, messages and instructions-->
<legend><h2 class="heading"><%= t(".page_heading") %></h2></legend>
<p class="note"><%= t(".required_before_enabling") %></p>
<!--/descriptions-->

<!--main content-->
<p>
<%= form.label :password_check, t(".password_label") %>
<%= form.password_field :password_check %>
<%= form.submit t(".submit") %>
</p>
<!--/content-->
</fieldset>
<% end %>
Loading
Loading