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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +42 -0
- data/LICENSE +29 -0
- data/README.md +190 -0
- data/app/controllers/spree/admin/otp_configurations_controller.rb +50 -0
- data/app/controllers/spree/api/v3/store/otp_controller.rb +301 -0
- data/app/mailers/spree/otp_mailer.rb +57 -0
- data/app/models/spree/otp_attempt.rb +82 -0
- data/app/models/spree/otp_configuration.rb +90 -0
- data/app/models/spree/otp_store_decorator.rb +15 -0
- data/app/models/spree/otp_user_decorator.rb +59 -0
- data/app/services/spree/otp/code_sent_payload.rb +55 -0
- data/app/services/spree/otp/delivery.rb +77 -0
- data/app/services/spree/otp/fallback.rb +86 -0
- data/app/services/spree/otp/generate.rb +198 -0
- data/app/services/spree/otp/identity.rb +81 -0
- data/app/services/spree/otp/marketing_consent.rb +35 -0
- data/app/services/spree/otp/rate_limiter.rb +82 -0
- data/app/services/spree/otp/resend.rb +27 -0
- data/app/services/spree/otp/verify.rb +173 -0
- data/app/subscribers/spree/otp/delivery_failed_subscriber.rb +43 -0
- data/app/views/spree/admin/otp_configurations/edit.html.erb +141 -0
- data/app/views/spree/otp_mailer/send_otp.html.erb +100 -0
- data/app/views/spree/otp_mailer/send_otp.text.erb +8 -0
- data/config/locales/en.yml +39 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260927000001_create_spree_otp_configurations.spree_otp_auth.rb +20 -0
- data/db/migrate/20260927000002_create_spree_otp_attempts.spree_otp_auth.rb +26 -0
- data/db/migrate/20260927000003_add_otp_fields_to_spree_users.spree_otp_auth.rb +12 -0
- data/db/migrate/20260927000004_update_otp_schema_for_mandatory_mobile_and_templates.spree_otp_auth.rb +28 -0
- data/db/migrate/20261018000001_add_gupshup_fields_to_spree_otp_configurations.spree_otp_auth.rb +18 -0
- data/db/migrate/20261018000002_add_delivery_fields_to_spree_otp_attempts.spree_otp_auth.rb +18 -0
- data/db/migrate/20261018000003_add_email_marketing_decided_at_to_spree_users.spree_otp_auth.rb +30 -0
- data/db/migrate/20261019000004_add_delivery_message_ref_to_spree_otp_attempts.spree_otp_auth.rb +8 -0
- data/lib/generators/spree_otp_auth/install/install_generator.rb +27 -0
- data/lib/spree_otp_auth/engine.rb +44 -0
- data/lib/spree_otp_auth/version.rb +5 -0
- data/lib/spree_otp_auth.rb +28 -0
- data/spree_otp_auth.gemspec +39 -0
- 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
|