spree_otp_auth 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +42 -0
  3. data/LICENSE +29 -0
  4. data/README.md +190 -0
  5. data/app/controllers/spree/admin/otp_configurations_controller.rb +50 -0
  6. data/app/controllers/spree/api/v3/store/otp_controller.rb +301 -0
  7. data/app/mailers/spree/otp_mailer.rb +57 -0
  8. data/app/models/spree/otp_attempt.rb +82 -0
  9. data/app/models/spree/otp_configuration.rb +90 -0
  10. data/app/models/spree/otp_store_decorator.rb +15 -0
  11. data/app/models/spree/otp_user_decorator.rb +59 -0
  12. data/app/services/spree/otp/code_sent_payload.rb +55 -0
  13. data/app/services/spree/otp/delivery.rb +77 -0
  14. data/app/services/spree/otp/fallback.rb +86 -0
  15. data/app/services/spree/otp/generate.rb +198 -0
  16. data/app/services/spree/otp/identity.rb +81 -0
  17. data/app/services/spree/otp/marketing_consent.rb +35 -0
  18. data/app/services/spree/otp/rate_limiter.rb +82 -0
  19. data/app/services/spree/otp/resend.rb +27 -0
  20. data/app/services/spree/otp/verify.rb +173 -0
  21. data/app/subscribers/spree/otp/delivery_failed_subscriber.rb +43 -0
  22. data/app/views/spree/admin/otp_configurations/edit.html.erb +141 -0
  23. data/app/views/spree/otp_mailer/send_otp.html.erb +100 -0
  24. data/app/views/spree/otp_mailer/send_otp.text.erb +8 -0
  25. data/config/locales/en.yml +39 -0
  26. data/config/routes.rb +22 -0
  27. data/db/migrate/20260927000001_create_spree_otp_configurations.spree_otp_auth.rb +20 -0
  28. data/db/migrate/20260927000002_create_spree_otp_attempts.spree_otp_auth.rb +26 -0
  29. data/db/migrate/20260927000003_add_otp_fields_to_spree_users.spree_otp_auth.rb +12 -0
  30. data/db/migrate/20260927000004_update_otp_schema_for_mandatory_mobile_and_templates.spree_otp_auth.rb +28 -0
  31. data/db/migrate/20261018000001_add_gupshup_fields_to_spree_otp_configurations.spree_otp_auth.rb +18 -0
  32. data/db/migrate/20261018000002_add_delivery_fields_to_spree_otp_attempts.spree_otp_auth.rb +18 -0
  33. data/db/migrate/20261018000003_add_email_marketing_decided_at_to_spree_users.spree_otp_auth.rb +30 -0
  34. data/db/migrate/20261019000004_add_delivery_message_ref_to_spree_otp_attempts.spree_otp_auth.rb +8 -0
  35. data/lib/generators/spree_otp_auth/install/install_generator.rb +27 -0
  36. data/lib/spree_otp_auth/engine.rb +44 -0
  37. data/lib/spree_otp_auth/version.rb +5 -0
  38. data/lib/spree_otp_auth.rb +28 -0
  39. data/spree_otp_auth.gemspec +39 -0
  40. metadata +167 -0
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'securerandom'
5
+
6
+ module Spree
7
+ class OtpAttempt < Spree::Base
8
+ self.table_name = 'spree_otp_attempts'
9
+ has_prefix_id :otp_att
10
+
11
+ belongs_to :store, class_name: 'Spree::Store'
12
+ belongs_to :user, class_name: Spree.user_class.to_s, optional: true
13
+ belongs_to :parent_attempt, class_name: 'Spree::OtpAttempt', optional: true
14
+ has_one :link_email_attempt, class_name: 'Spree::OtpAttempt', foreign_key: :parent_attempt_id,
15
+ inverse_of: :parent_attempt, dependent: :destroy
16
+
17
+ CHANNELS = %w[email mobile whatsapp].freeze
18
+ PURPOSES = %w[login link_email].freeze
19
+ DELIVERY_STATUSES = %w[pending submitted failed fallback_sent].freeze
20
+
21
+ validates :store, presence: true
22
+ validates :identifier, presence: true
23
+ validates :channel, inclusion: { in: CHANNELS }
24
+ validates :purpose, inclusion: { in: PURPOSES }
25
+ validates :delivery_status, inclusion: { in: DELIVERY_STATUSES }
26
+ validates :code_digest, presence: true
27
+ validates :salt, presence: true
28
+ validates :expires_at, presence: true
29
+ validates :attempts_count, numericality: { greater_than_or_equal_to: 0 }
30
+ validates :max_attempts, numericality: { greater_than: 0 }
31
+ validates :staged_mobile_number, format: { with: /\A\+[1-9]\d{9,14}\z/, allow_blank: true }
32
+ validates :staged_email, format: { with: URI::MailTo::EMAIL_REGEXP, allow_blank: true }
33
+
34
+ scope :active, -> { where(verified_at: nil).where('expires_at > ?', Time.current) }
35
+ scope :for_identifier, ->(id) { where(identifier: id) }
36
+ scope :login, -> { where(purpose: 'login') }
37
+
38
+ def expired?
39
+ Time.current >= expires_at
40
+ end
41
+
42
+ def verified?
43
+ verified_at.present?
44
+ end
45
+
46
+ def locked?
47
+ attempts_count >= max_attempts
48
+ end
49
+
50
+ def valid_code?(submitted_code)
51
+ return false if expired? || verified? || locked?
52
+ return false if submitted_code.blank?
53
+
54
+ candidate_digest = self.class.digest(submitted_code, salt)
55
+ ActiveSupport::SecurityUtils.secure_compare(code_digest, candidate_digest)
56
+ end
57
+
58
+ def record_failed_attempt!
59
+ increment!(:attempts_count)
60
+ end
61
+
62
+ def mark_verified!
63
+ update!(verified_at: Time.current)
64
+ end
65
+
66
+ # Invalidates this code (and its link_email child) without deleting the
67
+ # record, so the hourly limiter and admin diagnostics still see it.
68
+ def expire!
69
+ now = Time.current
70
+ update_columns(expires_at: now, updated_at: now)
71
+ link_email_attempt&.update_columns(expires_at: now, updated_at: now)
72
+ end
73
+
74
+ def self.generate_salt
75
+ SecureRandom.hex(16)
76
+ end
77
+
78
+ def self.digest(code, salt)
79
+ Digest::SHA256.hexdigest("#{salt}#{code.to_s.strip}")
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class OtpConfiguration < Spree::Base
5
+ self.table_name = 'spree_otp_configurations'
6
+ has_prefix_id :otp_cfg
7
+
8
+ belongs_to :store, class_name: 'Spree::Store'
9
+
10
+ CHANNELS = %w[email mobile whatsapp].freeze
11
+ FALLBACK_CHANNELS = %w[none whatsapp sms email].freeze
12
+
13
+ alias_attribute :auth_channel, :active_channel
14
+
15
+ # Legacy provider columns (msg91_*, gupshup_*) are read only by the
16
+ # one-time import into spree_notification_providers and are dropped in a
17
+ # later release. Providers, credentials and templates now live in
18
+ # Admin -> Settings -> Notification providers.
19
+ encrypts :msg91_auth_key, deterministic: false
20
+
21
+ validates :store_id, uniqueness: true, presence: true
22
+ validates :active_channel, inclusion: { in: CHANNELS }
23
+ validates :fallback_channel, inclusion: { in: FALLBACK_CHANNELS }
24
+ validates :otp_length, inclusion: { in: [4, 6] }
25
+ validates :otp_expiry_seconds, numericality: { greater_than_or_equal_to: 60 }
26
+ validates :resend_cooldown_seconds, numericality: { greater_than_or_equal_to: 10 }
27
+ validates :fallback_delay_seconds, numericality: { greater_than_or_equal_to: 10 }
28
+ validate :active_channel_ready, if: -> { %w[whatsapp mobile].include?(active_channel) }
29
+
30
+ # What still stops the store sending login codes on `channel`, as labels.
31
+ def missing_requirements(channel = active_channel)
32
+ return [] if channel.to_s == 'email'
33
+
34
+ provider_channel = Spree::Otp::Delivery.provider_channel(channel)
35
+ statuses = SpreeNotificationProviders.channel_status(store, provider_channel)
36
+ return ["#{channel.to_s == 'mobile' ? 'an' : 'a'} #{channel_name(channel)} provider"] if statuses.empty?
37
+ return [] if Spree::Otp::Delivery.ready?(store, channel)
38
+
39
+ enabled = statuses.reject { |s| s[:status] == :disabled }
40
+ return ["an enabled #{channel_name(channel)} provider"] if enabled.empty?
41
+
42
+ missing = enabled.flat_map { |s| s[:missing] }.uniq
43
+ missing.presence || ['a "Login code" template']
44
+ end
45
+
46
+ def channel_ready?(channel = active_channel)
47
+ missing_requirements(channel).empty?
48
+ end
49
+
50
+ # The admin-chosen email template, when the app provides a template model
51
+ # (SpreeOtpAuth.email_template_class).
52
+ def email_template
53
+ model = SpreeOtpAuth.email_template_model
54
+ model && email_template_id.present? ? model.find_by(id: email_template_id) : nil
55
+ end
56
+
57
+ def email_template=(template)
58
+ self.email_template_id = template&.id
59
+ end
60
+
61
+ def email_fallback_enabled?
62
+ fallback_channel == 'email'
63
+ end
64
+
65
+ def self.default_for(store)
66
+ find_or_initialize_by(store: store) do |cfg|
67
+ cfg.active_channel = 'whatsapp'
68
+ cfg.fallback_channel = 'email'
69
+ cfg.otp_length = 6
70
+ cfg.otp_expiry_seconds = 600
71
+ cfg.resend_cooldown_seconds = 30
72
+ cfg.fallback_delay_seconds = 20
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def active_channel_ready
79
+ missing = missing_requirements
80
+ return if missing.empty?
81
+
82
+ errors.add(:active_channel, "can't be #{channel_name(active_channel)} yet. Missing: #{missing.join(', ')}. " \
83
+ 'Set this up in Settings → Notification providers.')
84
+ end
85
+
86
+ def channel_name(channel)
87
+ channel.to_s == 'mobile' ? 'SMS' : channel.to_s.titleize.sub('Whatsapp', 'WhatsApp')
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module OtpStoreDecorator
5
+ def self.prepended(base)
6
+ base.has_one :otp_configuration, class_name: 'Spree::OtpConfiguration', dependent: :destroy
7
+ end
8
+
9
+ def effective_otp_configuration
10
+ otp_configuration || Spree::OtpConfiguration.default_for(self)
11
+ end
12
+ end
13
+
14
+ Spree::Store.prepend(OtpStoreDecorator) if defined?(Spree::Store)
15
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module OtpUserDecorator
5
+ def self.prepended(base)
6
+ base.has_many :otp_attempts, class_name: 'Spree::OtpAttempt', dependent: :destroy
7
+
8
+ base.validates :mobile_number,
9
+ uniqueness: { case_sensitive: false, allow_blank: true },
10
+ format: { with: /\A\+[1-9]\d{9,14}\z/, message: :invalid_phone_format, allow_blank: true }
11
+
12
+ base.before_validation :normalize_auth_identifiers
13
+ end
14
+
15
+ # :subscribed, :unsubscribed (opted out before) or :undecided (never asked,
16
+ # or no real email yet). Drives the news-and-offers checkbox at login.
17
+ def marketing_consent_state
18
+ return :undecided unless Spree::Otp::Identity.real_email?(email)
19
+ return :subscribed if accepts_email_marketing
20
+ return :unsubscribed if respond_to?(:email_marketing_decided_at) && email_marketing_decided_at.present?
21
+
22
+ :undecided
23
+ end
24
+
25
+ def mobile_confirmed?
26
+ respond_to?(:mobile_confirmed_at) && mobile_confirmed_at.present?
27
+ end
28
+
29
+ def confirm_mobile!(phone_or_timestamp = nil, timestamp = Time.current)
30
+ if phone_or_timestamp.is_a?(String)
31
+ normalized = normalize_phone(phone_or_timestamp)
32
+ updates = { mobile_number: normalized }
33
+ updates[:phone] = normalized if respond_to?(:phone=)
34
+ updates[:mobile_confirmed_at] = timestamp if respond_to?(:mobile_confirmed_at)
35
+ update!(updates)
36
+ else
37
+ ts = phone_or_timestamp || timestamp
38
+ update_columns(mobile_confirmed_at: ts) if respond_to?(:mobile_confirmed_at)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def normalize_auth_identifiers
45
+ self.email = email.to_s.strip.downcase.presence if respond_to?(:email)
46
+ self.mobile_number = normalize_phone(mobile_number) if mobile_number.present?
47
+ self.phone = mobile_number if respond_to?(:phone=) && mobile_number.present?
48
+ self.auth_identifier = (auth_channel == 'mobile' ? mobile_number : email).presence if respond_to?(:auth_identifier=)
49
+ end
50
+
51
+ def normalize_phone(raw)
52
+ digits = raw.to_s.gsub(/\D/, '')
53
+ digits = "91#{digits}" if digits.length == 10
54
+ digits.present? ? "+#{digits}" : nil
55
+ end
56
+ end
57
+
58
+ Spree.user_class.prepend(OtpUserDecorator) if Spree.user_class
59
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Otp
5
+ # Builds the "code sent" response shared by request, resend and fallback
6
+ # (CodeSent in specs/017-whatsapp-otp-marketing-consent/contracts/otp-api-v3.yaml).
7
+ module CodeSentPayload
8
+ module_function
9
+
10
+ def build(attempt:, config:, auto_fallback: false)
11
+ email_destination = Spree::Otp::Delivery.email_destination(attempt)
12
+ sent_by_email = attempt.channel == 'email'
13
+ target = sent_by_email ? email_destination : attempt.identifier
14
+ child = attempt.link_email_attempt
15
+ consent = consent_flags(attempt, child, email_destination)
16
+
17
+ {
18
+ requires_mobile: false,
19
+ requires_email: false,
20
+ requires_email_code: child.present?,
21
+ channel: attempt.channel,
22
+ masked_target: Spree::Otp::Identity.mask(target.to_s),
23
+ masked_email: (child || sent_by_email) ? Spree::Otp::Identity.mask_email((child&.identifier || email_destination).to_s) : nil,
24
+ auto_fallback: auto_fallback,
25
+ otp_length: config.otp_length || 6,
26
+ cooldown_seconds: config.resend_cooldown_seconds,
27
+ fallback_available_after_seconds: config.fallback_delay_seconds,
28
+ email_fallback_available: !sent_by_email && child.nil? && config.email_fallback_enabled? && email_destination.present?
29
+ }.merge(consent)
30
+ end
31
+
32
+ # The news-and-offers box is shown once: beside the email field when one
33
+ # was collected, otherwise on the code screen for returning customers who
34
+ # aren't subscribed (ticked unless they opted out before).
35
+ def consent_flags(attempt, child, email_destination)
36
+ state = attempt.user ? attempt.user.marketing_consent_state : :undecided
37
+ collected_email_now = attempt.staged_email.present? || child.present?
38
+ show = !collected_email_now && state != :subscribed && Spree::Otp::Identity.real_email?(email_destination)
39
+
40
+ { show_marketing_consent: show, marketing_consent_default: state != :unsubscribed }
41
+ end
42
+
43
+ def requires_email(user)
44
+ state = user ? user.marketing_consent_state : :undecided
45
+ {
46
+ requires_email: true,
47
+ requires_mobile: false,
48
+ show_marketing_consent: true,
49
+ marketing_consent_default: state != :unsubscribed,
50
+ message: 'Please enter your email address to continue.'
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Otp
5
+ # Sends login codes and picks where an attempt's code goes.
6
+ #
7
+ # SMS and WhatsApp codes go through spree_notification_providers, so the
8
+ # store's providers for that channel are tried in priority order. Email
9
+ # codes are rendered by Spree::OtpMailer and delivered later by the
10
+ # app's mailer (which itself can route through the providers).
11
+ module Delivery
12
+ # :sent - a provider accepted the code
13
+ # :unknown - it may have gone out; never resend or switch channel for it
14
+ # :failed - no provider can have sent it; safe to fall back
15
+ Outcome = Struct.new(:status, :message_id, :message_ref, :error_message, keyword_init: true) do
16
+ def sent? = status == :sent
17
+ def unknown? = status == :unknown
18
+ def failed? = status == :failed
19
+ end
20
+
21
+ module_function
22
+
23
+ # OTP channel names predate the provider gem: 'mobile' means SMS.
24
+ def provider_channel(channel)
25
+ channel.to_s == 'mobile' ? :sms : channel.to_sym
26
+ end
27
+
28
+ def deliver(channel:, store:, destination:, code:, expiry_minutes:, attempt: nil)
29
+ return send_email(store: store, to: destination, code: code, expiry_minutes: expiry_minutes) if channel.to_s == 'email'
30
+
31
+ message = SpreeNotificationProviders::Message.new(
32
+ channel: provider_channel(channel), type: :otp_code, kind: :otp, to: destination,
33
+ code: code, expiry_minutes: expiry_minutes, source: attempt
34
+ )
35
+ result = SpreeNotificationProviders.deliver(store: store, message: message)
36
+ status = if result.success? then :sent
37
+ elsif result.retryable? then :failed
38
+ else :unknown
39
+ end
40
+ Outcome.new(
41
+ status: status, message_id: result.provider_message_id, message_ref: result.message_ref,
42
+ error_message: (result.reasons.join('; ').presence || result.outcome.to_s if status == :failed)
43
+ )
44
+ end
45
+
46
+ def send_email(store:, to:, code:, expiry_minutes:)
47
+ mail = Spree::OtpMailer.send_otp(store: store, to: to, code: code, expiry_minutes: expiry_minutes)
48
+ mail.deliver_later
49
+ Outcome.new(status: :sent, message_id: mail.message_id)
50
+ rescue StandardError => e
51
+ Rails.logger.error("[SpreeOtpAuth] email code not queued: #{e.class}: #{e.message}")
52
+ Outcome.new(status: :failed, error_message: e.class.name)
53
+ end
54
+
55
+ # Whether the store can send login codes on this OTP channel.
56
+ def ready?(store, channel)
57
+ return true if channel.to_s == 'email'
58
+
59
+ SpreeNotificationProviders.ready_for?(store, provider_channel(channel), :otp_code)
60
+ end
61
+
62
+ # Where an email code for this attempt would go: the identifier itself for
63
+ # email-first logins, else the staged email, else the account's real email.
64
+ def email_destination(attempt)
65
+ return attempt.identifier if Spree::Otp::Identity.email_identifier?(attempt.identifier)
66
+ return attempt.staged_email if attempt.staged_email.present?
67
+
68
+ email = attempt.user&.email
69
+ Spree::Otp::Identity.real_email?(email) ? email : nil
70
+ end
71
+
72
+ def destination_for(attempt)
73
+ attempt.channel == 'email' ? email_destination(attempt) : attempt.identifier
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Otp
5
+ # Sends a fresh login code by email in place of an attempt's WhatsApp code.
6
+ #
7
+ # Used for the automatic backup (immediate send failure in Generate, or a
8
+ # provider's later failure report) and for the customer's "Send code by email
9
+ # instead" link. Only the new code is valid afterwards.
10
+ class Fallback
11
+ prepend Spree::ServiceModule::Base
12
+
13
+ # discard_original: destroy the original attempt instead of expiring it
14
+ # (its WhatsApp send never went out, so it shouldn't count toward limits).
15
+ def call(store:, attempt:, reason: nil, enforce_cooldown: true, discard_original: false)
16
+ config = store.effective_otp_configuration
17
+ return failure(:email_fallback_disabled, 'Email codes are not available right now.') unless config.email_fallback_enabled?
18
+
19
+ # Linking a number to an email-only account needs the WhatsApp code as
20
+ # proof of the phone; an email code can't replace it.
21
+ if attempt.link_email_attempt.present?
22
+ return failure(:email_fallback_disabled, 'Please use the code sent on WhatsApp to link this number.')
23
+ end
24
+
25
+ email = Spree::Otp::Delivery.email_destination(attempt)
26
+ unless Spree::Otp::Identity.real_email?(email)
27
+ return failure(:no_email_on_account, 'There is no email on this account to send a code to.')
28
+ end
29
+
30
+ limiter = Spree::Otp::RateLimiter.new(store: store, identifier: attempt.identifier)
31
+ if enforce_cooldown
32
+ allowed, reason_code, retry_after = limiter.check_dispatch_allowed(config.resend_cooldown_seconds)
33
+ unless allowed
34
+ if reason_code == :hourly_limit_exceeded
35
+ return failure(:hourly_limit_exceeded, 'Hourly limit of 5 codes exceeded. Please try again later.')
36
+ end
37
+
38
+ return failure(:cooldown_active, { message: "Please wait #{retry_after} seconds before requesting another code.", retry_after_seconds: retry_after })
39
+ end
40
+ elsif limiter.hourly_limit_exceeded?
41
+ return failure(:hourly_limit_exceeded, 'Hourly limit of 5 codes exceeded. Please try again later.')
42
+ end
43
+
44
+ code = Spree::Otp::Generate.generate_code(config.otp_length || 6)
45
+ salt = Spree::OtpAttempt.generate_salt
46
+ new_attempt = nil
47
+
48
+ ApplicationRecord.transaction do
49
+ new_attempt = Spree::OtpAttempt.create!(
50
+ store: store,
51
+ user: attempt.user,
52
+ identifier: attempt.identifier,
53
+ channel: 'email',
54
+ purpose: 'login',
55
+ code_digest: Spree::OtpAttempt.digest(code, salt),
56
+ salt: salt,
57
+ staged_mobile_number: attempt.staged_mobile_number,
58
+ staged_email: attempt.staged_email,
59
+ delivery_status: 'pending',
60
+ expires_at: (config.otp_expiry_seconds || 600).seconds.from_now,
61
+ ip_address: attempt.ip_address,
62
+ user_agent: attempt.user_agent
63
+ )
64
+
65
+ if discard_original
66
+ attempt.destroy!
67
+ else
68
+ attempt.expire!
69
+ attempt.update_columns(delivery_status: 'fallback_sent', fallback_reason: reason.to_s.truncate(120).presence)
70
+ end
71
+ end
72
+
73
+ result = Spree::Otp::Delivery.send_email(store: store, to: email, code: code,
74
+ expiry_minutes: ((config.otp_expiry_seconds || 600) / 60))
75
+
76
+ if result.failed?
77
+ new_attempt.update_columns(delivery_status: 'failed', fallback_reason: result.error_message.to_s.truncate(120))
78
+ return failure(:delivery_failed, 'Failed to send the code by email.')
79
+ end
80
+
81
+ new_attempt.update_columns(delivery_status: 'submitted', provider_message_id: result.message_id)
82
+ success(attempt: new_attempt.reload)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Spree
6
+ module Otp
7
+ # Starts a login: finds the account, decides what still has to be collected,
8
+ # and sends the code.
9
+ #
10
+ # Mobile-first (identifier is a phone number, the default storefront flow):
11
+ # * account with a real email -> code sent on the active channel
12
+ # * new number / no real email -> requires_email until an email is given
13
+ # * email owned by another mobile -> :email_linked_to_other_mobile
14
+ # * email owned by an account with no mobile -> code on WhatsApp plus a
15
+ # second code to that email (link_email child attempt); both are needed
16
+ #
17
+ # Email-first (identifier contains "@") keeps the feature-010 behaviour.
18
+ class Generate
19
+ prepend Spree::ServiceModule::Base
20
+
21
+ def self.generate_code(length)
22
+ min = 10**(length - 1)
23
+ max = (10**length) - 1
24
+ SecureRandom.random_number(min..max).to_s
25
+ end
26
+
27
+ def call(store:, identifier:, mobile_number: nil, email: nil, channel: nil, request_env: {})
28
+ normalized_identifier, kind = Spree::Otp::Identity.normalize_identifier(identifier)
29
+ return failure(:invalid_identifier, 'Invalid email or phone number format.') if normalized_identifier.blank?
30
+
31
+ config = store.effective_otp_configuration
32
+ context = kind == 'email' ? email_first(normalized_identifier, mobile_number) : mobile_first(normalized_identifier, email)
33
+ return context if context.is_a?(Spree::ServiceModule::Result)
34
+
35
+ rate_limiter = Spree::Otp::RateLimiter.new(store: store, identifier: normalized_identifier)
36
+ allowed, reason, retry_after = rate_limiter.check_dispatch_allowed(config.resend_cooldown_seconds)
37
+ unless allowed
38
+ if reason == :hourly_limit_exceeded
39
+ return failure(:hourly_limit_exceeded, 'Hourly limit of 5 codes exceeded. Please try again later.')
40
+ end
41
+
42
+ return failure(:cooldown_active, { message: "Please wait #{retry_after} seconds before requesting another code.", retry_after_seconds: retry_after })
43
+ end
44
+
45
+ active_channel = channel.presence || (kind == 'email' ? 'email' : config.active_channel)
46
+ expire_previous_attempts(store, normalized_identifier)
47
+
48
+ code = self.class.generate_code(config.otp_length || 6)
49
+ attempt = create_attempt(store, config, normalized_identifier, active_channel, code, context, request_env)
50
+
51
+ destination = Spree::Otp::Delivery.destination_for(attempt)
52
+ if destination.blank?
53
+ attempt.destroy
54
+ return failure(:no_email_on_account, 'There is no email on this account to send a code to.')
55
+ end
56
+
57
+ outcome = Spree::Otp::Delivery.deliver(
58
+ channel: active_channel, store: store, attempt: attempt, destination: destination, code: code,
59
+ expiry_minutes: ((config.otp_expiry_seconds || 600) / 60)
60
+ )
61
+
62
+ auto_fallback = false
63
+ if outcome.failed?
64
+ attempt.update_columns(delivery_status: 'failed', fallback_reason: outcome.error_message.to_s.truncate(120),
65
+ delivery_message_ref: outcome.message_ref)
66
+
67
+ fallback = try_email_fallback(store, config, attempt, outcome.error_message, linking: context[:link_user].present?)
68
+ if fallback&.success?
69
+ attempt = fallback.value[:attempt]
70
+ auto_fallback = true
71
+ else
72
+ attempt.destroy # Clean up unsent attempt so cooldown isn't unfairly triggered
73
+ return failure(:delivery_failed, outcome.error_message || 'Failed to deliver passcode.')
74
+ end
75
+ else
76
+ # :unknown may still have gone out, so it is not resent or switched to
77
+ # email here; a later provider failure report triggers the backup.
78
+ attempt.update_columns(delivery_status: outcome.sent? ? 'submitted' : 'pending',
79
+ provider_message_id: outcome.message_id, delivery_message_ref: outcome.message_ref)
80
+ end
81
+
82
+ if context[:link_user]
83
+ link_result = send_link_email_code(store, config, attempt, context[:link_user], request_env)
84
+ return link_result if link_result
85
+ end
86
+
87
+ success(Spree::Otp::CodeSentPayload.build(attempt: attempt.reload, config: config, auto_fallback: auto_fallback))
88
+ end
89
+
90
+ private
91
+
92
+ # Returns a context hash, or a Result to return straight away.
93
+ def mobile_first(mobile, email)
94
+ user = Spree.user_class.find_by(mobile_number: mobile)
95
+ return { user: user } if user && Spree::Otp::Identity.real_email?(user.email)
96
+
97
+ normalized_email = Spree::Otp::Identity.normalize_email(email)
98
+ if normalized_email.blank?
99
+ return success(Spree::Otp::CodeSentPayload.requires_email(user))
100
+ end
101
+ return failure(:invalid_email, 'Please enter a valid email address.') unless Spree::Otp::Identity.valid_email?(normalized_email)
102
+
103
+ email_owner = Spree.user_class.find_by('LOWER(email) = ?', normalized_email)
104
+ if email_owner && email_owner != user
105
+ if email_owner.mobile_number.present? || user.present?
106
+ return failure(:email_linked_to_other_mobile,
107
+ 'This email is already linked to another mobile number. Please sign in with the mobile number linked to that email.')
108
+ end
109
+
110
+ # Email-only account: the customer must also prove they own the email.
111
+ return { user: email_owner, staged_mobile: mobile, link_user: email_owner }
112
+ end
113
+
114
+ { user: user, staged_email: normalized_email, staged_mobile: user ? nil : mobile }
115
+ end
116
+
117
+ def email_first(email, mobile_number)
118
+ user = Spree.user_class.find_by('LOWER(email) = ?', email)
119
+ return { user: user } if user&.mobile_number.present?
120
+
121
+ if mobile_number.blank?
122
+ return success(requires_mobile: true, message: 'Mobile number is required to continue.')
123
+ end
124
+
125
+ normalized_mobile = Spree::Otp::Identity.normalize_phone(mobile_number)
126
+ return failure(:invalid_phone, 'Invalid mobile phone number format.') unless Spree::Otp::Identity.valid_phone?(normalized_mobile)
127
+
128
+ existing_mobile_user = Spree.user_class.find_by(mobile_number: normalized_mobile)
129
+ if existing_mobile_user && existing_mobile_user.id != user&.id
130
+ return failure(:phone_already_registered, 'This mobile number is already linked to another account.')
131
+ end
132
+
133
+ { user: user, staged_mobile: normalized_mobile }
134
+ end
135
+
136
+ # Only the newest login code for an identifier is valid.
137
+ def expire_previous_attempts(store, identifier)
138
+ Spree::OtpAttempt.where(store: store, identifier: identifier).login.active.find_each(&:expire!)
139
+ end
140
+
141
+ def create_attempt(store, config, identifier, channel, code, context, request_env)
142
+ salt = Spree::OtpAttempt.generate_salt
143
+ Spree::OtpAttempt.create!(
144
+ store: store,
145
+ user: context[:user],
146
+ identifier: identifier,
147
+ channel: channel,
148
+ purpose: 'login',
149
+ code_digest: Spree::OtpAttempt.digest(code, salt),
150
+ salt: salt,
151
+ staged_mobile_number: context[:staged_mobile],
152
+ staged_email: context[:staged_email],
153
+ expires_at: (config.otp_expiry_seconds || 600).seconds.from_now,
154
+ ip_address: request_env[:ip_address],
155
+ user_agent: request_env[:user_agent]&.truncate(255)
156
+ )
157
+ end
158
+
159
+ # Linking a number to an email-only account needs proof of the phone, so
160
+ # the email backup can't stand in for the WhatsApp code there.
161
+ def try_email_fallback(store, config, attempt, reason, linking:)
162
+ return nil if linking || attempt.channel == 'email' || !config.email_fallback_enabled?
163
+
164
+ Spree::Otp::Fallback.call(store: store, attempt: attempt, reason: reason, enforce_cooldown: false, discard_original: true)
165
+ end
166
+
167
+ # Second code for the "link this number to an email-only account" case.
168
+ # Returns a failure Result when it can't be sent, else nil.
169
+ def send_link_email_code(store, config, attempt, link_user, request_env)
170
+ code = self.class.generate_code(config.otp_length || 6)
171
+ salt = Spree::OtpAttempt.generate_salt
172
+ child = Spree::OtpAttempt.create!(
173
+ store: store,
174
+ user: link_user,
175
+ parent_attempt: attempt,
176
+ identifier: link_user.email.downcase,
177
+ channel: 'email',
178
+ purpose: 'link_email',
179
+ code_digest: Spree::OtpAttempt.digest(code, salt),
180
+ salt: salt,
181
+ expires_at: attempt.expires_at,
182
+ ip_address: request_env[:ip_address],
183
+ user_agent: request_env[:user_agent]&.truncate(255)
184
+ )
185
+
186
+ result = Spree::Otp::Delivery.send_email(store: store, to: child.identifier, code: code,
187
+ expiry_minutes: ((config.otp_expiry_seconds || 600) / 60))
188
+ if result.failed?
189
+ attempt.destroy
190
+ return failure(:delivery_failed, 'Failed to send the code to your email.')
191
+ end
192
+
193
+ child.update_columns(delivery_status: 'submitted', provider_message_id: result.message_id)
194
+ nil
195
+ end
196
+ end
197
+ end
198
+ end