diff --git a/Gemfile b/Gemfile
index d2b338b1..0b5badcc 100644
--- a/Gemfile
+++ b/Gemfile
@@ -8,6 +8,7 @@ gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', require: false
gem 'bootsnap', require: false
gem 'devise'
+gem 'devise-two-factor'
gem 'dotenv', require: false
gem 'faraday'
gem 'google-cloud-storage', require: false
@@ -28,6 +29,8 @@ gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
+gem 'rotp'
+gem 'rqrcode'
gem 'ruby-vips'
gem 'rubyXL'
gem 'shakapacker'
diff --git a/Gemfile.lock b/Gemfile.lock
index e5da07a7..b102b054 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -123,6 +123,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
+ chunky_png (1.4.0)
cmdparse (3.0.7)
coderay (1.1.3)
concurrent-ruby (1.2.2)
@@ -146,6 +147,11 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
+ devise-two-factor (5.0.0)
+ activesupport (~> 7.0)
+ devise (~> 4.0)
+ railties (~> 7.0)
+ rotp (~> 6.0)
diff-lcs (1.5.0)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
@@ -423,6 +429,11 @@ GEM
retriable (3.1.2)
rexml (3.2.6)
rollbar (3.4.0)
+ rotp (6.3.0)
+ rqrcode (2.2.0)
+ chunky_png (~> 1.0)
+ rqrcode_core (~> 1.0)
+ rqrcode_core (1.2.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
@@ -559,6 +570,7 @@ DEPENDENCIES
cuprite
debug
devise
+ devise-two-factor
dotenv
erb_lint
factory_bot_rails
@@ -584,6 +596,8 @@ DEPENDENCIES
rails-i18n
rails_autolink
rollbar
+ rotp
+ rqrcode
rspec-rails
rubocop
rubocop-performance
diff --git a/app/controllers/mfa_setup_controller.rb b/app/controllers/mfa_setup_controller.rb
new file mode 100644
index 00000000..79b0e895
--- /dev/null
+++ b/app/controllers/mfa_setup_controller.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class MfaSetupController < ApplicationController
+ def new
+ current_user.otp_secret ||= User.generate_otp_secret
+
+ current_user.save!
+
+ @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME)
+ end
+
+ def edit; end
+
+ def create
+ if current_user.validate_and_consume_otp!(params[:otp_attempt])
+ current_user.otp_required_for_login = true
+ current_user.save!
+
+ redirect_to settings_profile_index_path, notice: '2FA has been configured'
+ else
+ @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME)
+
+ @error_message = 'Code is invalid'
+
+ render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/new'), status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ if current_user.validate_and_consume_otp!(params[:otp_attempt])
+ current_user.update!(otp_required_for_login: false, otp_secret: nil)
+
+ redirect_to settings_profile_index_path, notice: '2FA has been removed'
+ else
+ @error_message = 'Code is invalid'
+
+ render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
+ end
+ end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 5085d411..4cfa542e 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -1,12 +1,18 @@
# frozen_string_literal: true
class SessionsController < Devise::SessionsController
+ before_action :configure_permitted_parameters
+
def create
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: 'Create a new account'
end
+ if User.exists?(email: sign_in_params[:email], otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
+ return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
+ end
+
super
end
@@ -22,6 +28,10 @@ class SessionsController < Devise::SessionsController
super
end
+ def configure_permitted_parameters
+ devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
+ end
+
def require_no_authentication
super
diff --git a/app/models/user.rb b/app/models/user.rb
index 3d9bec43..9470107a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -5,6 +5,7 @@
# Table name: users
#
# id :bigint not null, primary key
+# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
# deleted_at :datetime
@@ -16,6 +17,8 @@
# last_sign_in_at :datetime
# last_sign_in_ip :string
# locked_at :datetime
+# otp_required_for_login :boolean default(FALSE), not null
+# otp_secret :string
# remember_created_at :datetime
# reset_password_sent_at :datetime
# reset_password_token :string
@@ -50,7 +53,7 @@ class User < ApplicationRecord
has_one :access_token, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
- devise :database_authenticatable, :recoverable, :rememberable, :validatable, :trackable
+ devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
attribute :role, :string, default: ADMIN_ROLE
diff --git a/app/views/devise/sessions/otp.html.erb b/app/views/devise/sessions/otp.html.erb
new file mode 100644
index 00000000..1eff15a9
--- /dev/null
+++ b/app/views/devise/sessions/otp.html.erb
@@ -0,0 +1,19 @@
+
+
Log In
+ <%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %>
+ <%= f.hidden_field :email %>
+ <%= f.hidden_field :password %>
+ <% if params[:redir].present? %>
+ <%= hidden_field_tag :redir, params[:redir] %>
+ <% end %>
+
+
+ <%= f.label :otp_attempt, 'Two-Factor Code', class: 'label' %>
+ <%= f.text_field :otp_attempt, autofocus: true, placeholder: 'XXX-XXX', required: true, class: 'base-input' %>
+
+
+
+ <%= f.button button_title(title: 'Log In', disabled_with: 'Logging In'), class: 'base-button' %>
+
+ <% end %>
+
diff --git a/app/views/mfa_setup/edit.html.erb b/app/views/mfa_setup/edit.html.erb
new file mode 100644
index 00000000..3df50c5a
--- /dev/null
+++ b/app/views/mfa_setup/edit.html.erb
@@ -0,0 +1,13 @@
+<%= render 'shared/turbo_modal', title: 'Remove 2FA' do %>
+ <%= form_for '', url: mfa_setup_path, method: :delete, data: { turbo_frame: :_top } do |f| %>
+
+ <%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
+
+ <%= @error_message %>
+
+
+
+ <%= f.button button_title(title: 'Remove 2FA'), class: 'base-button' %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/mfa_setup/new.html.erb b/app/views/mfa_setup/new.html.erb
new file mode 100644
index 00000000..14a20172
--- /dev/null
+++ b/app/views/mfa_setup/new.html.erb
@@ -0,0 +1,19 @@
+<%= render 'shared/turbo_modal', title: 'Setup 2FA' do %>
+ <%= form_for '', url: mfa_setup_path, data: { turbo_frame: :_top } do |f| %>
+
+ Use an authenticator mobile app like Google Authenticator or 1Password to scan the QR code below.
+
+
+ <%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'w-80 h-80 my-4 mx-auto' }) %>
+
+
+ <%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
+
+ <%= @error_message %>
+
+
+
+ <%= f.button button_title(title: 'Save'), class: 'base-button' %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/profile/index.html.erb b/app/views/profile/index.html.erb
index 3e463d85..dd398f8d 100644
--- a/app/views/profile/index.html.erb
+++ b/app/views/profile/index.html.erb
@@ -35,6 +35,26 @@
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
<% end %>
+ Two-Factor Authentication
+
+ <% if current_user.otp_required_for_login %>
+
+ <%= svg_icon('circle_check', class: 'stroke-success inline flex-none w-5 h-5') %>
+
+ 2FA has been configured.
+
+
+
🔓 Remove 2FA
+ <% else %>
+
+ <%= svg_icon('info_circle', class: 'stroke-warning inline flex-none w-5 h-5') %>
+
+ 2FA is not configured.
+
+
+
🔒 Set up 2FA
+ <% end %>
+
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index 85145bdc..d5ffebfb 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -4,6 +4,8 @@ require_relative '../../lib/auth_with_token_strategy'
Warden::Strategies.add(:auth_token, AuthWithTokenStrategy)
+Devise.otp_allowed_drift = 60.seconds
+
# Assuming you have not yet modified this file, each configuration option below
# is set to its default value. Note that some are commented out while others
# are not: uncommented lines are intended to protect your configuration from
@@ -13,6 +15,10 @@ Warden::Strategies.add(:auth_token, AuthWithTokenStrategy)
# Use this hook to configure devise mailer, warden hooks and so forth.
# Many of these configuration options can be set straight in your model.
Devise.setup do |config|
+ config.warden do |manager|
+ manager.default_strategies(scope: :user).unshift(:two_factor_authenticatable)
+ end
+
# The secret key used by Devise. Devise uses this key to generate
# random tokens. Changing this key will render invalid all existing
# confirmation, reset password and unlock tokens in the database.
diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb
index 81da078a..a87cbaa0 100644
--- a/config/initializers/filter_parameter_logging.rb
+++ b/config/initializers/filter_parameter_logging.rb
@@ -1,3 +1,3 @@
# frozen_string_literal: true
-Rails.application.config.filter_parameters += %i[password token]
+Rails.application.config.filter_parameters += %i[password token otp_attempt]
diff --git a/config/routes.rb b/config/routes.rb
index 8535d769..7380f432 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -41,6 +41,7 @@ Rails.application.routes.draw do
end
resources :verify_pdf_signature, only: %i[create]
+ resource :mfa_setup, only: %i[new edit create destroy], controller: 'mfa_setup'
resources :dashboard, only: %i[index]
resources :setup, only: %i[index create]
resource :newsletter, only: %i[show update]
diff --git a/db/migrate/20230915200635_add_devise_two_factor_to_users.rb b/db/migrate/20230915200635_add_devise_two_factor_to_users.rb
new file mode 100644
index 00000000..b041dc43
--- /dev/null
+++ b/db/migrate/20230915200635_add_devise_two_factor_to_users.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddDeviseTwoFactorToUsers < ActiveRecord::Migration[7.0]
+ def change
+ add_column :users, :otp_secret, :string
+ add_column :users, :consumed_timestep, :integer
+ add_column :users, :otp_required_for_login, :boolean, default: false, null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0ecb94c8..95db88d8 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.0].define(version: 2023_09_10_084410) do
+ActiveRecord::Schema[7.0].define(version: 2023_09_15_200635) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -177,6 +177,9 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_10_084410) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "uuid", null: false
+ t.string "otp_secret"
+ t.integer "consumed_timestep"
+ t.boolean "otp_required_for_login", default: false, null: false
t.index ["account_id"], name: "index_users_on_account_id"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true