pull/112/head
Alex Turchyn 2 years ago
parent b3662ddea8
commit ef618f8f49

@ -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'

@ -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

@ -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

@ -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

@ -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

@ -0,0 +1,19 @@
<div class="max-w-xl mx-auto px-2">
<h1 class="text-4xl font-bold text-center my-8">Log In</h1>
<%= 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 %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :otp_attempt, 'Two-Factor Code', class: 'label' %>
<%= f.text_field :otp_attempt, autofocus: true, placeholder: 'XXX-XXX', required: true, class: 'base-input' %>
</div>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Log In', disabled_with: 'Logging In'), class: 'base-button' %>
</div>
<% end %>
</div>

@ -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| %>
<div class="form-control my-6 space-y-2">
<%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
<span>
<%= @error_message %>
</span>
</div>
<div class="form-control mt-4">
<%= f.button button_title(title: 'Remove 2FA'), class: 'base-button' %>
</div>
<% end %>
<% end %>

@ -0,0 +1,19 @@
<%= render 'shared/turbo_modal', title: 'Setup 2FA' do %>
<%= form_for '', url: mfa_setup_path, data: { turbo_frame: :_top } do |f| %>
<p class="text-center">
Use an authenticator mobile app like Google Authenticator or 1Password to scan the QR code below.
</p>
<div>
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'w-80 h-80 my-4 mx-auto' }) %>
</div>
<div class="form-control my-6 space-y-2">
<%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
<span>
<%= @error_message %>
</span>
</div>
<div class="form-control mt-4">
<%= f.button button_title(title: 'Save'), class: 'base-button' %>
</div>
<% end %>
<% end %>

@ -35,6 +35,26 @@
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
</div>
<% end %>
<p class="text-2xl font-bold mt-8 mb-4">Two-Factor Authentication</p>
<div class="space-y-4">
<% if current_user.otp_required_for_login %>
<p class="flex items-center space-x-1">
<%= svg_icon('circle_check', class: 'stroke-success inline flex-none w-5 h-5') %>
<span>
2FA has been configured.
</span>
</p>
<a href="<%= edit_mfa_setup_path %>" data-turbo-frame="modal" class="white-button w-full !px-8">🔓 Remove 2FA</a>
<% else %>
<p class="flex items-center space-x-1">
<%= svg_icon('info_circle', class: 'stroke-warning inline flex-none w-5 h-5') %>
<span>
2FA is not configured.
</span>
</p>
<a href="<%= new_mfa_setup_path %>" data-turbo-frame="modal" class="base-button w-full !px-8">🔒 Set up 2FA</a>
<% end %>
</div>
</div>
<div class="w-0 md:w-52"></div>
</div>

@ -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.

@ -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]

@ -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]

@ -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

@ -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

Loading…
Cancel
Save