mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 06:42:32 -05:00
refactor otp
This commit is contained in:
1 parent
a847e7aa9c
commit
e187644716
9 files changed
+121
-37
No files matched your search
@@ -10,12 +10,10 @@ class SubmitFormEmail2fasController < ApplicationController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||||
RateLimit.call("verify-2fa-code-1h-#{@submitter.id}", limit: 10, ttl: 1.hour, enabled: true)
|
|
||||||
RateLimit.call("verify-2fa-code-1d-#{@submitter.id}", limit: 20, ttl: 1.day, enabled: true)
|
|
||||||
|
|
||||||
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
|
value = [@submitter.email.downcase.squish, @submitter.slug].join(':')
|
||||||
|
|
||||||
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value)
|
if OtpChallenges.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value, purpose: 'form_email_2fa')
|
||||||
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
|
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
|
||||||
|
|
||||||
cookies.encrypted[:email_2fa_slug] =
|
cookies.encrypted[:email_2fa_slug] =
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ class SubmitterMailer < ApplicationMailer
|
|||||||
def otp_verification_email(submitter, locale: nil)
|
def otp_verification_email(submitter, locale: nil)
|
||||||
@current_account = submitter.account
|
@current_account = submitter.account
|
||||||
@submitter = submitter
|
@submitter = submitter
|
||||||
@otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':'))
|
@otp_code = OtpChallenges.generate([submitter.email.downcase.squish, submitter.slug].join(':'),
|
||||||
|
purpose: 'form_email_2fa')
|
||||||
|
|
||||||
assign_message_metadata('otp_verification_email', submitter)
|
assign_message_metadata('otp_verification_email', submitter)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class TemplateMailer < ApplicationMailer
|
|||||||
@current_account = template.account
|
@current_account = template.account
|
||||||
@template = template
|
@template = template
|
||||||
|
|
||||||
@otp_code = EmailVerificationCodes.generate([email.downcase.strip, template.slug].join(':'))
|
@otp_code = OtpChallenges.generate([email.downcase.squish, template.slug].join(':'), purpose: 'form_email_2fa')
|
||||||
|
|
||||||
assign_message_metadata('otp_verification_email', template)
|
assign_message_metadata('otp_verification_email', template)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: otp_challenges
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# attempts :integer default(0), not null
|
||||||
|
# expire_at :datetime not null
|
||||||
|
# key :string not null
|
||||||
|
# nonce :string not null
|
||||||
|
# verified_at :datetime
|
||||||
|
# version :integer not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_otp_challenges_on_key_and_version (key,version) UNIQUE
|
||||||
|
#
|
||||||
|
class OtpChallenge < ApplicationRecord
|
||||||
|
end
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateOtpChallenges < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :otp_challenges do |t|
|
||||||
|
t.string :key, null: false
|
||||||
|
t.integer :version, null: false
|
||||||
|
t.string :nonce, null: false
|
||||||
|
t.integer :attempts, null: false, default: 0
|
||||||
|
t.datetime :expire_at, null: false
|
||||||
|
t.datetime :verified_at
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
|
||||||
|
t.index %i[key version], unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+12
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2026_08_19_091500) do
|
ActiveRecord::Schema[8.1].define(version: 2026_09_30_061927) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "btree_gin"
|
enable_extension "btree_gin"
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
@@ -322,6 +322,17 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_19_091500) do
|
|||||||
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
|
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "otp_challenges", force: :cascade do |t|
|
||||||
|
t.integer "attempts", default: 0, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "expire_at", null: false
|
||||||
|
t.string "key", null: false
|
||||||
|
t.string "nonce", null: false
|
||||||
|
t.datetime "verified_at"
|
||||||
|
t.integer "version", null: false
|
||||||
|
t.index ["key", "version"], name: "index_otp_challenges_on_key_and_version", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "search_entries", force: :cascade do |t|
|
create_table "search_entries", force: :cascade do |t|
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module EmailVerificationCodes
|
|
||||||
DRIFT_BEHIND = 5.minutes
|
|
||||||
|
|
||||||
module_function
|
|
||||||
|
|
||||||
def generate(value)
|
|
||||||
totp = ROTP::TOTP.new(build_totp_secret(value))
|
|
||||||
|
|
||||||
totp.at(Time.current)
|
|
||||||
end
|
|
||||||
|
|
||||||
def verify(code, value)
|
|
||||||
totp = ROTP::TOTP.new(build_totp_secret(value))
|
|
||||||
|
|
||||||
totp.verify(code, drift_behind: DRIFT_BEHIND)
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_totp_secret(value)
|
|
||||||
ROTP::Base32.encode(
|
|
||||||
Digest::SHA1.digest(
|
|
||||||
[Rails.application.secret_key_base, 'form_email_2fa', value].join(':')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module OtpChallenges
|
||||||
|
CODE_TTL = 10.minutes
|
||||||
|
REUSE_MIN_TTL = 5.minutes
|
||||||
|
MAX_ATTEMPTS = 20
|
||||||
|
MAX_TOTAL_ATTEMPTS = 100
|
||||||
|
ATTEMPTS_WINDOW = 23.hours
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def generate(value, purpose:)
|
||||||
|
key = build_key(value, purpose)
|
||||||
|
latest_challenge = OtpChallenge.where(key:).order(version: :desc).first
|
||||||
|
|
||||||
|
otp_challenge =
|
||||||
|
if latest_challenge && !latest_challenge.verified_at? && latest_challenge.expire_at > REUSE_MIN_TTL.from_now
|
||||||
|
latest_challenge
|
||||||
|
else
|
||||||
|
version = latest_challenge ? latest_challenge.version + 1 : 1
|
||||||
|
|
||||||
|
OtpChallenge.create_with(nonce: SecureRandom.base58(16), expire_at: CODE_TTL.from_now)
|
||||||
|
.create_or_find_by!(key:, version:)
|
||||||
|
end
|
||||||
|
|
||||||
|
build_hotp(value, purpose, otp_challenge.nonce).at(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify(code, value, purpose:)
|
||||||
|
challenges = OtpChallenge.where(key: build_key(value, purpose))
|
||||||
|
|
||||||
|
recent_challenges = challenges.where(expire_at: ATTEMPTS_WINDOW.ago..).order(version: :desc).to_a
|
||||||
|
|
||||||
|
latest_challenge = recent_challenges.first
|
||||||
|
|
||||||
|
return false if latest_challenge.nil? || latest_challenge.verified_at? || latest_challenge.expire_at.past?
|
||||||
|
|
||||||
|
raise RateLimit::LimitApproached if recent_challenges.sum(&:attempts) >= MAX_ATTEMPTS
|
||||||
|
raise RateLimit::LimitApproached if challenges.sum(:attempts) >= MAX_TOTAL_ATTEMPTS
|
||||||
|
|
||||||
|
if build_hotp(value, purpose, latest_challenge.nonce).verify(code.to_s, 0)
|
||||||
|
OtpChallenge.where(id: latest_challenge.id, verified_at: nil).update_all(verified_at: Time.current) == 1
|
||||||
|
else
|
||||||
|
latest_challenge.increment!(:attempts)
|
||||||
|
|
||||||
|
OtpChallenge.current_transaction.after_rollback { OtpChallenge.update_counters(latest_challenge.id, attempts: 1) }
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_key(value, purpose)
|
||||||
|
Digest::SHA256.hexdigest([purpose, value].join(':'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_hotp(value, purpose, nonce)
|
||||||
|
ROTP::HOTP.new(
|
||||||
|
ROTP::Base32.encode(
|
||||||
|
Digest::SHA1.digest([Rails.application.secret_key_base, purpose, value, nonce].join(':'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
+3
-3
@@ -280,12 +280,12 @@ module Submitters
|
|||||||
email_digest = Digest::MD5.base64digest(submitter.email.downcase.squish)
|
email_digest = Digest::MD5.base64digest(submitter.email.downcase.squish)
|
||||||
|
|
||||||
RateLimit.call("verify-2fa-code-#{email_digest}", limit: 2, ttl: 45.seconds, enabled: true)
|
RateLimit.call("verify-2fa-code-#{email_digest}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||||
RateLimit.call("verify-2fa-code-1h-#{email_digest}", limit: 10, ttl: 1.hour, enabled: true)
|
|
||||||
RateLimit.call("verify-2fa-code-1d-#{email_digest}", limit: 20, ttl: 1.day, enabled: true)
|
|
||||||
|
|
||||||
link_2fa_key = [submitter.email.downcase.squish, submitter.submission.template.slug].join(':')
|
link_2fa_key = [submitter.email.downcase.squish, submitter.submission.template.slug].join(':')
|
||||||
|
|
||||||
raise InvalidOtp, I18n.t(:invalid_code) unless EmailVerificationCodes.verify(otp, link_2fa_key)
|
unless OtpChallenges.verify(otp.to_s.gsub(/\D/, ''), link_2fa_key, purpose: 'form_email_2fa')
|
||||||
|
raise InvalidOtp, I18n.t(:invalid_code)
|
||||||
|
end
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in new issue
Block a user