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