diff --git a/app/controllers/api/attachments_controller.rb b/app/controllers/api/attachments_controller.rb index 9d86b923..f00552cd 100644 --- a/app/controllers/api/attachments_controller.rb +++ b/app/controllers/api/attachments_controller.rb @@ -37,7 +37,7 @@ module Api rescue Submitters::MaliciousFileExtension => e Rollbar.error(e) if defined?(Rollbar) - render json: { error: e.message }, status: :unprocessable_entity + render json: { error: e.message }, status: :unprocessable_content end def build_new_cookie_signatures_json(submitter, attachment) diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index 98e5e0c3..ad5d6455 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -16,7 +16,9 @@ class StartFormController < ApplicationController COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze def show - raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa'] + if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa'] + raise ActionController::RoutingError, I18n.t('not_found') + end if @template.shared_link? @submitter = @template.submissions.new(account_id: @template.account_id) diff --git a/app/controllers/submission_events_controller.rb b/app/controllers/submission_events_controller.rb index 14cf5321..28011799 100644 --- a/app/controllers/submission_events_controller.rb +++ b/app/controllers/submission_events_controller.rb @@ -10,6 +10,7 @@ class SubmissionEventsController < ApplicationController 'api_complete_form' => 'check', 'send_reminder_email' => 'mail_forward', 'send_2fa_sms' => '2fa', + 'send_2fa_email' => '2fa', 'send_sms' => 'send', 'phone_verified' => 'phone_check', 'email_verified' => 'email_check', diff --git a/app/controllers/submissions_preview_controller.rb b/app/controllers/submissions_preview_controller.rb index 2ac30745..6163c36b 100644 --- a/app/controllers/submissions_preview_controller.rb +++ b/app/controllers/submissions_preview_controller.rb @@ -53,8 +53,11 @@ class SubmissionsPreviewController < ApplicationController def use_signature?(submission) return false if current_user && can?(:read, submission) - return true if submission.submitters.any? { |e| e.preferences['require_phone_2fa'] } + return true if submission.submitters.any? do |e| + e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa'] + end return true if submission.template&.preferences&.dig('require_phone_2fa') + return true if submission.template&.preferences&.dig('require_email_2fa') !submission_valid_ttl?(submission) end diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 963594fa..f723aa32 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -17,6 +17,7 @@ class SubmitFormController < ApplicationController submission = @submitter.submission return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? + return render :email_2fa if require_email_2fa?(@submitter) @form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) @@ -47,6 +48,11 @@ class SubmitFormController < ApplicationController end def update + if require_email_2fa?(@submitter) + return render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') }, + status: :unprocessable_content + end + if @submitter.completed_at? return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_content end @@ -77,6 +83,8 @@ class SubmitFormController < ApplicationController def completed raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at? + + redirect_to submit_form_path(params[:submit_form_slug]) if require_email_2fa?(@submitter) end def success; end @@ -109,4 +117,12 @@ class SubmitFormController < ApplicationController ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments) .preload(:blob).index_by(&:uuid) end + + def require_email_2fa?(submitter) + return false if submitter.submission.template&.preferences&.dig('require_email_2fa') != true && + submitter.preferences['require_email_2fa'] != true + return false if cookies.encrypted[:email_2fa_slug] == submitter.slug + + true + end end diff --git a/app/controllers/submit_form_email_2fas_controller.rb b/app/controllers/submit_form_email_2fas_controller.rb new file mode 100644 index 00000000..ec627b5d --- /dev/null +++ b/app/controllers/submit_form_email_2fas_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class SubmitFormEmail2fasController < ApplicationController + around_action :with_browser_locale + + skip_before_action :authenticate_user! + skip_authorization_check + + before_action :load_submitter + + COOKIES_TTL = 12.hours + COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze + + def create + RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) + + value = [@submitter.email.downcase.strip, @submitter.slug].join(':') + + if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value) + SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email }) + + cookies.encrypted[:email_2fa_slug] = + { value: @submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS } + + redirect_to submit_form_path(@submitter.slug) + else + redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:invalid_code) + end + rescue RateLimit::LimitApproached + redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts) + end + + def update + if @submitter.submission_events.where(event_type: 'send_2fa_email').exists?(created_at: 15.seconds.ago..) + return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:rate_limit_exceeded) + end + + RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) + + SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s) + + redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {} + + redirect_to submit_form_path(@submitter.slug, status: :sent), **redir_params + rescue RateLimit::LimitApproached + redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts) + end + + def load_submitter + @submitter = Submitter.find_by!(slug: params[:submitter_slug]) + end +end diff --git a/app/controllers/templates_preferences_controller.rb b/app/controllers/templates_preferences_controller.rb index 391b6714..49041cfc 100644 --- a/app/controllers/templates_preferences_controller.rb +++ b/app/controllers/templates_preferences_controller.rb @@ -54,7 +54,7 @@ class TemplatesPreferencesController < ApplicationController documents_copy_email_attach_documents documents_copy_email_reply_to completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters - require_all_submitters submitters_order require_phone_2fa + require_all_submitters submitters_order require_phone_2fa require_email_2fa default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled completed_notification_email_subject completed_notification_email_body completed_notification_email_enabled completed_notification_email_attach_audit] + diff --git a/app/jobs/send_submitter_verification_email_job.rb b/app/jobs/send_submitter_verification_email_job.rb new file mode 100644 index 00000000..542d4ae7 --- /dev/null +++ b/app/jobs/send_submitter_verification_email_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class SendSubmitterVerificationEmailJob + include Sidekiq::Job + + def perform(params = {}) + submitter = Submitter.find(params['submitter_id']) + + SubmitterMailer.otp_verification_email(submitter).deliver_now! + + SubmissionEvent.create!(submitter_id: params['submitter_id'], + event_type: 'send_2fa_email', + data: { email: submitter.email }) + end +end diff --git a/app/mailers/submitter_mailer.rb b/app/mailers/submitter_mailer.rb index 145d3756..a6db0770 100644 --- a/app/mailers/submitter_mailer.rb +++ b/app/mailers/submitter_mailer.rb @@ -144,6 +144,17 @@ class SubmitterMailer < ApplicationMailer end end + def otp_verification_email(submitter) + @submitter = submitter + @otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':')) + + assign_message_metadata('otp_verification_email', submitter) + + I18n.with_locale(submitter.account.locale) do + mail(to: submitter.email, subject: I18n.t('email_verification')) + end + end + private def build_submitter_reply_to(submitter, email_config: nil, documents_copy_email: nil) diff --git a/app/models/submission_event.rb b/app/models/submission_event.rb index 11a6e995..8dc44547 100644 --- a/app/models/submission_event.rb +++ b/app/models/submission_event.rb @@ -48,6 +48,7 @@ class SubmissionEvent < ApplicationRecord send_reminder_email: 'send_reminder_email', send_sms: 'send_sms', send_2fa_sms: 'send_2fa_sms', + send_2fa_email: 'send_2fa_email', open_email: 'open_email', click_email: 'click_email', click_sms: 'click_sms', diff --git a/app/views/submissions/_detailed_form.html.erb b/app/views/submissions/_detailed_form.html.erb index b2c79997..f6f832ff 100644 --- a/app/views/submissions/_detailed_form.html.erb +++ b/app/views/submissions/_detailed_form.html.erb @@ -29,7 +29,7 @@
"> - " value="<%= item['email'].presence || ((params[:selfsign] && index.zero?) || item['is_requester'] ? current_user.email : '') %>" id="detailed_email_<%= item['uuid'] %>"> + <%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: (local_assigns[:require_email_2fa] == true ? t(:email) : "#{t('email')} (#{t('optional')})"), value: item['email'].presence || ((params[:selfsign] && index.zero?) || item['is_requester'] ? current_user.email : ''), id: "detailed_email_#{item['uuid']}", required: local_assigns[:require_email_2fa] == true %> <% has_phone_field = true %> diff --git a/app/views/submissions/new.html.erb b/app/views/submissions/new.html.erb index ccea6b4b..e70b181f 100644 --- a/app/views/submissions/new.html.erb +++ b/app/views/submissions/new.html.erb @@ -1,6 +1,7 @@ <% require_phone_2fa = @template.preferences['require_phone_2fa'] == true %> +<% require_email_2fa = @template.preferences['require_email_2fa'] == true %> <% prefillable_fields = @template.fields.select { |f| f['prefillable'] } %> -<% only_detailed = require_phone_2fa || prefillable_fields.present? %> +<% only_detailed = require_phone_2fa || require_email_2fa || prefillable_fields.present? %> <%= render 'shared/turbo_modal_large', title: params[:selfsign] ? t('add_recipients') : t('add_new_recipients') do %> <% options = [only_detailed ? nil : [t('via_email'), 'email'], only_detailed ? nil : [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], [t('upload_list'), 'list']].compact %> @@ -25,7 +26,7 @@
<% end %>
- <%= render 'detailed_form', template: @template, require_phone_2fa:, prefillable_fields: %> + <%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields: %>