diff --git a/app/controllers/submit_form_email_2fas_controller.rb b/app/controllers/submit_form_email_2fas_controller.rb index 511daaf65..6f95e7544 100644 --- a/app/controllers/submit_form_email_2fas_controller.rb +++ b/app/controllers/submit_form_email_2fas_controller.rb @@ -10,12 +10,10 @@ class SubmitFormEmail2fasController < ApplicationController def create 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 }) cookies.encrypted[:email_2fa_slug] = diff --git a/app/mailers/submitter_mailer.rb b/app/mailers/submitter_mailer.rb index f8e59714c..3a3c1e990 100644 --- a/app/mailers/submitter_mailer.rb +++ b/app/mailers/submitter_mailer.rb @@ -181,7 +181,8 @@ class SubmitterMailer < ApplicationMailer def otp_verification_email(submitter, locale: nil) @current_account = submitter.account @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) diff --git a/app/mailers/template_mailer.rb b/app/mailers/template_mailer.rb index c92fb62f6..881d0fb4f 100644 --- a/app/mailers/template_mailer.rb +++ b/app/mailers/template_mailer.rb @@ -5,7 +5,7 @@ class TemplateMailer < ApplicationMailer @current_account = template.account @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) diff --git a/app/models/otp_challenge.rb b/app/models/otp_challenge.rb new file mode 100644 index 000000000..f4e35dbdf --- /dev/null +++ b/app/models/otp_challenge.rb @@ -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 diff --git a/db/migrate/20260930061927_create_otp_challenges.rb b/db/migrate/20260930061927_create_otp_challenges.rb new file mode 100644 index 000000000..7876486a3 --- /dev/null +++ b/db/migrate/20260930061927_create_otp_challenges.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index f159c4a97..58fd3dc2d 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[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 enable_extension "btree_gin" 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 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| t.bigint "account_id", null: false t.datetime "created_at", null: false diff --git a/lib/email_verification_codes.rb b/lib/email_verification_codes.rb deleted file mode 100644 index 8fdfaacca..000000000 --- a/lib/email_verification_codes.rb +++ /dev/null @@ -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 diff --git a/lib/otp_challenges.rb b/lib/otp_challenges.rb new file mode 100644 index 000000000..078199519 --- /dev/null +++ b/lib/otp_challenges.rb @@ -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 diff --git a/lib/submitters.rb b/lib/submitters.rb index b7a7a7dd1..4dcd1ebd0 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -280,12 +280,12 @@ module Submitters 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-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(':') - 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 end