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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c5e32e336c0ed4d23a3013880c3a74cad6df54df215c3e61881dabb5dec8273c
4
+ data.tar.gz: 4ba101c6a0618bc9de716c76db88beaad0e92d88b21967fd9c40faac421e718e
5
+ SHA512:
6
+ metadata.gz: b55e6c57e0989510bd774c79d69abf6a7a8ab360b9d0a2f2b04dcc3a707b267de0df26eac228dc72b3a0a3458ec5a51b8100d5ba2ff6ebbf93960757a00ec81a
7
+ data.tar.gz: c22097b312dcd88ca7b07bfdf32c0b591040f82bbeb8321fde9b02230491498ab18e1882dee917d4dcf73d1e44aabb88a3c5d4654fece372ff781cba8b6306b8
data/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the gem uses
5
+ [Semantic Versioning](https://semver.org/). While the version is 0.x, a minor
6
+ bump may change the public API; such changes are listed under **Breaking**.
7
+
8
+ ## 0.2.0 — 2026-10-19
9
+
10
+ ### Breaking
11
+
12
+ - Codes are delivered through `spree_notification_providers` (`~> 0.1`, new
13
+ dependency). Configure providers, credentials and the **Login code**
14
+ template in Admin → Settings → Notification providers.
15
+ - Removed `Spree::Otp::Provider::*` (Email, Msg91Sms, Msg91Whatsapp,
16
+ GupshupWhatsapp), `Spree::Otp::WhatsappSettings` and
17
+ `Spree::Otp::Delivery.provider_for`; use `Spree::Otp::Delivery.deliver`.
18
+ - Removed the MSG91 fields from the OTP admin page and the `effective_msg91_*`
19
+ readers on `Spree::OtpConfiguration`.
20
+ - The Gupshup webhook (`POST /webhooks/gupshup`) moved to
21
+ `spree_notification_providers` and checks each store's own webhook secret;
22
+ `GUPSHUP_API_KEY` and `GUPSHUP_WEBHOOK_SECRET` are no longer read.
23
+ - The synthetic email domain is configurable
24
+ (`SpreeOtpAuth.synthetic_email_domain`, default `@guest.example.invalid`).
25
+
26
+ ### Added
27
+
28
+ - SMS as a primary login channel (any SMS provider).
29
+ - `Spree::Otp::DeliveryFailedSubscriber`: a provider's later failure report
30
+ sends the email backup.
31
+ - `spree_otp_attempts.delivery_message_ref` links an attempt to its Delivery
32
+ log rows.
33
+
34
+ ### Changed
35
+
36
+ - A send whose outcome is unknown (for example a timeout after the request
37
+ went out) is not resent or switched to email.
38
+
39
+ ## 0.1.0
40
+
41
+ - Initial in-repo version: mobile-first OTP login over WhatsApp (Gupshup) with
42
+ email backup, marketing consent at login, store-scoped admin settings.
data/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, ONTODI
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # spree_otp_auth
2
+
3
+ Passwordless One-Time Password (OTP) authentication engine for Spree Commerce and headless storefronts (Next.js / Store API v3).
4
+
5
+ Customers sign in with their mobile number and receive the code on WhatsApp or SMS; email is the backup channel. Codes are delivered through [spree_notification_providers](https://github.com/kalanjiyaVishnu/spree_notification_providers), so the store chooses the providers (Gupshup, MSG91, or its own) and their priority there. News-and-offers consent is captured during login, not at checkout.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - **Mobile-first, passwordless login**: the customer enters a mobile number; the code arrives on the store's chosen channel (WhatsApp, SMS or email).
12
+ - **Any provider, in priority order**: delivery goes through spree_notification_providers. A provider that definitely fails moves the code to the next one; a send that may have gone out is never repeated.
13
+ - **Email collected once**: new numbers, or accounts without a real email (synthetic `…<SpreeOtpAuth.synthetic_email_domain>` addresses count as none), are asked for an email inline before the code is sent.
14
+ - **Safe account linking**: a new number plus the email of an existing email-only account needs both the phone code and a second code sent to that email. An email already linked to a different mobile is rejected (409).
15
+ - **Email backup** (on by default, admin can turn it off):
16
+ - automatic when no provider can send the code, or when a provider later reports it `failed` (for example, number not on WhatsApp);
17
+ - on demand via "Send code by email instead" after the resend wait.
18
+ - Not used when linking a number to an email-only account (the phone code is the proof of the phone).
19
+ - **Only the newest code works**: resends, email-instead and automatic fallback expire the previous code.
20
+ - **News-and-offers consent at login**: ticked by default beside the email field for new customers; shown on the code screen for returning customers who aren't subscribed (unticked if they opted out before); hidden for subscribers.
21
+ - **Store-scoped configuration** in Spree Admin.
22
+ - **Rate limiting**: resend cooldown and 5 codes per number per hour, applied to every channel.
23
+ - **No codes in the database or logs**: codes are stored as salted SHA-256 digests; provider secrets are stored encrypted by spree_notification_providers.
24
+
25
+ ---
26
+
27
+ ## Architecture Overview
28
+
29
+ ```
30
+ Storefront (Next.js) spree_otp_auth spree_notification_providers
31
+ -------------------- -------------------------------- ----------------------------
32
+ UnifiedAuthForm ── POST /otp/request ───▶ Spree::Otp::Generate
33
+ OtpVerificationInput └─ Spree::Otp::Delivery ─────────▶ deliver(type: :otp_code)
34
+ ├─ POST /otp/verify ───────────────────▶ Spree::Otp::Verify ├─ Gupshup / MSG91 / …
35
+ ├─ POST /otp/resend └─ Delivery log
36
+ ├─ POST /otp/fallback ─────────────────▶ Spree::Otp::Fallback ─▶ Spree::OtpMailer (deliver_later)
37
+ └─ POST /otp/status (polls ≤60s)
38
+ Provider callback ─────────────────────────────────────────────────────────────▶ notification.delivery_failed
39
+ Spree::Otp::DeliveryFailedSubscriber ◀┘ (email backup)
40
+ Admin ──── /admin/otp_configuration ─────▶ Spree::Admin::OtpConfigurationsController
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Installation
46
+
47
+ Install spree_notification_providers first (this gem depends on it), then:
48
+
49
+ ```ruby
50
+ gem 'spree_notification_providers', '~> 0.1'
51
+ gem 'spree_otp_auth', '~> 0.2'
52
+ ```
53
+
54
+ ```bash
55
+ bundle install
56
+ bin/rails g spree_notification_providers:install
57
+ bin/rails g spree_otp_auth:install
58
+ bin/rails db:migrate
59
+ ```
60
+
61
+ Migrations add `spree_otp_configurations` (channels and timings), `spree_otp_attempts` (code digests, staged mobile/email, purpose, delivery status, provider message id and delivery reference), and `mobile_number`, `mobile_confirmed_at` and `email_marketing_decided_at` on `spree_users`. The consent migration back-fills customers with a verified newsletter subscription as subscribed.
62
+
63
+ Set the placeholder email domain for accounts created from a mobile number alone (default `@guest.example.invalid`):
64
+
65
+ ```ruby
66
+ # config/initializers/spree_otp_auth.rb
67
+ SpreeOtpAuth.synthetic_email_domain = '@guest.example.local'
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Delivery providers
73
+
74
+ This gem registers the `otp_code` message type ("Login code"). Configure the providers in **Spree Admin → Settings → Notification providers**:
75
+
76
+ 1. **WhatsApp** tab: add Gupshup (or MSG91), fill in its inputs and, under *Templates*, the **Login code** template (for Gupshup, an approved authentication template whose only variable is the code, by name or UUID). Use **Send test**, then enable it.
77
+ 2. **SMS** tab (optional): add MSG91 with the DLT-approved OTP template as **Login code**.
78
+ 3. Order providers per channel; the next one is tried only when a provider definitely did not take the code.
79
+ 4. For Gupshup delivery callbacks, set the provider's **Webhook secret** and point the Gupshup app at `https://<api-host>/webhooks/gupshup?secret=<webhook secret>`. Without callbacks, only immediate send failures trigger the automatic email backup.
80
+
81
+ Then **Spree Admin → OTP Authentication**: each channel shows **Ready** or what is missing, with a link to its providers. Switching the primary channel to WhatsApp or SMS is refused until a provider can send login codes.
82
+
83
+ Rollback: set the channel back to **Email**. Codes go to the account's email from the next request, with no deploy.
84
+
85
+ ---
86
+
87
+ ## Store Configuration
88
+
89
+ **Spree Admin → OTP Authentication** (per store):
90
+
91
+ | Setting | Notes |
92
+ |---|---|
93
+ | Primary channel | WhatsApp, SMS or Email. |
94
+ | Email backup | `fallback_channel` `email` (default) or `none`. |
95
+ | Channel status | Ready, or Not set up with what's missing; links to Notification providers. |
96
+ | Email template | Store email template with `{{otp_code}}`, `{{store_name}}`, `{{expiry_minutes}}`, used for email codes; defaults to the built-in mailer. |
97
+ | Timings | Code length (4/6), expiry (≥60s), resend cooldown (≥10s; also when "email instead" unlocks), fallback option delay. |
98
+
99
+ Provider credentials and templates are not stored here. The old `msg91_*` / `gupshup_*` columns are read only by the 0.2 upgrade import and are dropped in a later version.
100
+
101
+ ---
102
+
103
+ ## REST API Reference
104
+
105
+ All customer endpoints are under `/api/v3/store/otp` and need the publishable key header. Full contract: `specs/017-whatsapp-otp-marketing-consent/contracts/otp-api-v3.yaml`.
106
+
107
+ ### Request a code — `POST /request`
108
+
109
+ ```json
110
+ { "identifier": "9876543210" }
111
+ ```
112
+
113
+ New number or no real email on the account:
114
+
115
+ ```json
116
+ { "data": { "requires_email": true, "show_marketing_consent": true, "marketing_consent_default": true } }
117
+ ```
118
+
119
+ Send again with `"email": "new@example.com"`. Code sent:
120
+
121
+ ```json
122
+ {
123
+ "data": {
124
+ "requires_email": false,
125
+ "requires_email_code": false,
126
+ "channel": "whatsapp",
127
+ "masked_target": "+91 98*** **210",
128
+ "masked_email": null,
129
+ "auto_fallback": false,
130
+ "otp_length": 6,
131
+ "cooldown_seconds": 30,
132
+ "email_fallback_available": true,
133
+ "show_marketing_consent": false,
134
+ "marketing_consent_default": true
135
+ }
136
+ }
137
+ ```
138
+
139
+ Errors: `409 email_linked_to_other_mobile` / `phone_already_registered`, `422 invalid_phone` / `invalid_email` / `delivery_failed`, `429 cooldown_active` / `hourly_limit_exceeded` (with `retry_after_seconds`).
140
+
141
+ Identifiers containing `@` keep the older email-first flow (`requires_mobile`).
142
+
143
+ ### Verify — `POST /verify`
144
+
145
+ ```json
146
+ { "identifier": "9876543210", "code": "491823", "email_code": "120934", "accept_marketing": true }
147
+ ```
148
+
149
+ `email_code` only when `requires_email_code` was true. `accept_marketing` only when the consent box was shown; omit it to leave consent unchanged. Returns `token`, `refresh_token` and `user` (including `accepts_email_marketing`). Errors: `401 invalid_otp` / `invalid_email_code`, `429 max_attempts_exceeded`.
150
+
151
+ ### Resend — `POST /resend`
152
+
153
+ `{ "identifier": "9876543210" }`. Resends on the latest attempt's channel.
154
+
155
+ ### Email instead — `POST /fallback`
156
+
157
+ `{ "identifier": "9876543210" }`. Errors: `404 email_fallback_disabled`, `422 no_email_on_account`, `429 cooldown_active`.
158
+
159
+ ### Where did the code go — `POST /status`
160
+
161
+ `{ "identifier": "9876543210" }` → `{ "data": { "channel": "email", "masked_target": "a***a@example.com", "delivery_status": "fallback_sent" } }`. POST keeps the phone number out of URLs and access logs.
162
+
163
+ ### Link / update mobile — `PATCH /customer/mobile`
164
+
165
+ Authenticated; unchanged.
166
+
167
+ ### Delivery failure reports
168
+
169
+ Provider callbacks (for example Gupshup's `POST /webhooks/gupshup?secret=…`) are handled by spree_notification_providers, which publishes `notification.delivery_failed`. This gem subscribes to it and sends the email backup.
170
+
171
+ ---
172
+
173
+ ## Running the tests
174
+
175
+ ```bash
176
+ # Inside the host app's web container
177
+ docker exec -e RAILS_ENV=test ecom-web-1 bash -lc 'cd /rails && bundle exec rspec engines/spree_otp_auth/spec'
178
+
179
+ # Standalone (generates spec/dummy with spree_dev_tools)
180
+ bundle exec rake test_app
181
+ bundle exec rspec
182
+ ```
183
+
184
+ Specs stub provider HTTP through `SpreeNotificationProviders::Http` (`spec/support/gupshup_helpers.rb`), so they never send real messages.
185
+
186
+ ---
187
+
188
+ ## License
189
+
190
+ Released under the [BSD 3-Clause License](LICENSE).
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Admin
5
+ class OtpConfigurationsController < Spree::Admin::BaseController
6
+ before_action :load_otp_configuration
7
+
8
+ def edit
9
+ authorize! :manage, Spree::OtpConfiguration
10
+ end
11
+
12
+ def update
13
+ authorize! :manage, Spree::OtpConfiguration
14
+
15
+ attrs = otp_configuration_params
16
+ attrs[:email_template_id] = nil if attrs[:email_template_id].blank?
17
+
18
+ if @otp_configuration.update(attrs)
19
+ flash[:success] = Spree.t('otp_auth.admin.update_success', default: 'OTP authentication settings updated successfully.')
20
+ redirect_to spree.edit_admin_otp_configuration_path, status: :see_other
21
+ else
22
+ flash.now[:error] = @otp_configuration.errors.full_messages.join(', ')
23
+ render :edit, status: :unprocessable_content
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def model_class
30
+ Spree::OtpConfiguration
31
+ end
32
+
33
+ def load_otp_configuration
34
+ @otp_configuration = Spree::OtpConfiguration.default_for(current_store)
35
+ end
36
+
37
+ def otp_configuration_params
38
+ params.require(:otp_configuration).permit(
39
+ :active_channel,
40
+ :fallback_channel,
41
+ :email_template_id,
42
+ :otp_length,
43
+ :otp_expiry_seconds,
44
+ :resend_cooldown_seconds,
45
+ :fallback_delay_seconds
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Api
5
+ module V3
6
+ module Store
7
+ class OtpController < Store::BaseController
8
+ skip_before_action :authenticate_user, only: [:request_otp, :verify, :resend, :fallback, :delivery_status]
9
+ before_action :require_authentication!, only: [:update_mobile]
10
+
11
+ # POST /api/v3/store/otp/verify
12
+ def verify
13
+ identifier = params[:identifier]
14
+ code = params[:code]
15
+
16
+ if identifier.blank? || code.blank?
17
+ return render_error(
18
+ code: 'missing_parameter',
19
+ message: 'Identifier and code are required.',
20
+ status: :unprocessable_entity
21
+ )
22
+ end
23
+
24
+ result = Spree::Otp::Verify.call(
25
+ store: current_store,
26
+ identifier: identifier,
27
+ code: code,
28
+ email_code: params[:email_code],
29
+ accept_marketing: params.key?(:accept_marketing) ? ActiveModel::Type::Boolean.new.cast(params[:accept_marketing]) : nil,
30
+ request_env: request_env_for_token
31
+ )
32
+
33
+ if result.success?
34
+ render json: {
35
+ token: result.value[:token],
36
+ refresh_token: result.value[:refresh_token],
37
+ user: serialize_user(result.value[:user])
38
+ }, status: :ok
39
+ else
40
+ status_code = case result.value
41
+ when :max_attempts_exceeded then :too_many_requests
42
+ when :invalid_identifier then :unprocessable_entity
43
+ else :unauthorized
44
+ end
45
+
46
+ render_error(
47
+ code: result.value.to_s,
48
+ message: result.error.to_s,
49
+ status: status_code
50
+ )
51
+ end
52
+ end
53
+
54
+ # POST /api/v3/store/otp/request
55
+ def request_otp
56
+ identifier = params[:identifier]
57
+ if identifier.blank?
58
+ return render_error(
59
+ code: 'missing_parameter',
60
+ message: 'Identifier is required.',
61
+ status: :unprocessable_entity
62
+ )
63
+ end
64
+
65
+ result = Spree::Otp::Generate.call(
66
+ store: current_store,
67
+ identifier: identifier,
68
+ mobile_number: params[:mobile_number],
69
+ email: params[:email],
70
+ request_env: request_env_for_token
71
+ )
72
+
73
+ if result.success?
74
+ render json: { data: result.value }, status: :ok
75
+ else
76
+ status_code = case result.value
77
+ when :cooldown_active, :hourly_limit_exceeded then :too_many_requests
78
+ when :phone_already_registered, :email_linked_to_other_mobile then :conflict
79
+ else :unprocessable_entity
80
+ end
81
+
82
+ message = result.error.value.is_a?(Hash) ? result.error.value[:message] : result.error.to_s
83
+ retry_after = result.error.value.is_a?(Hash) ? result.error.value[:retry_after_seconds] : nil
84
+
85
+ render_error(
86
+ code: result.value.to_s,
87
+ message: message,
88
+ retry_after_seconds: retry_after,
89
+ status: status_code
90
+ )
91
+ end
92
+ end
93
+
94
+ # POST /api/v3/store/otp/resend
95
+ def resend
96
+ identifier = params[:identifier]
97
+ if identifier.blank?
98
+ return render_error(
99
+ code: 'missing_parameter',
100
+ message: 'Identifier is required.',
101
+ status: :unprocessable_entity
102
+ )
103
+ end
104
+
105
+ result = Spree::Otp::Resend.call(
106
+ store: current_store,
107
+ identifier: identifier,
108
+ request_env: request_env_for_token
109
+ )
110
+
111
+ if result.success?
112
+ render json: { data: result.value }, status: :ok
113
+ else
114
+ status_code = case result.value
115
+ when :cooldown_active, :hourly_limit_exceeded then :too_many_requests
116
+ else :unprocessable_entity
117
+ end
118
+
119
+ message = result.error.value.is_a?(Hash) ? result.error.value[:message] : result.error.to_s
120
+ retry_after = result.error.value.is_a?(Hash) ? result.error.value[:retry_after_seconds] : nil
121
+
122
+ render_error(
123
+ code: result.value.to_s,
124
+ message: message,
125
+ retry_after_seconds: retry_after,
126
+ status: status_code
127
+ )
128
+ end
129
+ end
130
+
131
+ # POST /api/v3/store/otp/fallback
132
+ # "Send code by email instead" for the latest login attempt.
133
+ def fallback
134
+ identifier, = Spree::Otp::Identity.normalize_identifier(params[:identifier])
135
+ if identifier.blank?
136
+ return render_error(
137
+ code: 'missing_parameter',
138
+ message: 'Identifier is required.',
139
+ status: :unprocessable_entity
140
+ )
141
+ end
142
+
143
+ attempt = latest_login_attempt(identifier)
144
+ unless attempt
145
+ return render_error(
146
+ code: 'invalid_identifier',
147
+ message: 'Please request a new code.',
148
+ status: :unprocessable_entity
149
+ )
150
+ end
151
+
152
+ result = Spree::Otp::Fallback.call(store: current_store, attempt: attempt, reason: 'customer_requested', enforce_cooldown: true)
153
+
154
+ if result.success?
155
+ render json: {
156
+ data: Spree::Otp::CodeSentPayload.build(attempt: result.value[:attempt], config: current_store.effective_otp_configuration)
157
+ }, status: :ok
158
+ else
159
+ status_code = case result.value
160
+ when :email_fallback_disabled then :not_found
161
+ when :cooldown_active, :hourly_limit_exceeded then :too_many_requests
162
+ else :unprocessable_entity
163
+ end
164
+
165
+ message = result.error.value.is_a?(Hash) ? result.error.value[:message] : result.error.to_s
166
+ retry_after = result.error.value.is_a?(Hash) ? result.error.value[:retry_after_seconds] : nil
167
+
168
+ render_error(code: result.value.to_s, message: message, retry_after_seconds: retry_after, status: status_code)
169
+ end
170
+ end
171
+
172
+ # POST /api/v3/store/otp/status (body: identifier) — POST keeps the phone out of URLs/logs.
173
+ # Lets the code screen notice an automatic switch to email (webhook path).
174
+ def delivery_status
175
+ identifier, = Spree::Otp::Identity.normalize_identifier(params[:identifier])
176
+ attempt = identifier.present? ? latest_login_attempt(identifier) : nil
177
+ unless attempt
178
+ return render_error(code: 'not_found', message: 'No code in progress.', status: :not_found)
179
+ end
180
+
181
+ previous = Spree::OtpAttempt.where(store: current_store, identifier: identifier).login
182
+ .where('created_at < ?', attempt.created_at)
183
+ .order(created_at: :desc).first
184
+ fell_back = attempt.channel == 'email' && previous&.delivery_status == 'fallback_sent'
185
+ destination = Spree::Otp::Delivery.destination_for(attempt)
186
+
187
+ render json: {
188
+ data: {
189
+ channel: attempt.channel,
190
+ masked_target: Spree::Otp::Identity.mask(destination.to_s),
191
+ delivery_status: fell_back ? 'fallback_sent' : attempt.delivery_status
192
+ }
193
+ }, status: :ok
194
+ end
195
+
196
+ # PATCH /api/v3/store/otp/customer/mobile
197
+ def update_mobile
198
+ if current_user.nil?
199
+ return render_error(
200
+ code: 'unauthorized',
201
+ message: 'Authentication required.',
202
+ status: :unauthorized
203
+ )
204
+ end
205
+
206
+ mobile_number = params[:mobile_number]
207
+ if mobile_number.blank?
208
+ return render_error(
209
+ code: 'missing_parameter',
210
+ message: 'mobile_number is required.',
211
+ status: :unprocessable_entity
212
+ )
213
+ end
214
+
215
+ normalized = normalize_phone(mobile_number)
216
+ unless normalized =~ /\A\+[1-9]\d{9,14}\z/
217
+ return render_error(
218
+ code: 'invalid_phone_format',
219
+ message: 'Invalid phone number format.',
220
+ status: :unprocessable_entity
221
+ )
222
+ end
223
+
224
+ existing_user = Spree.user_class.where.not(id: current_user.id).find_by(mobile_number: normalized)
225
+ if existing_user
226
+ return render_error(
227
+ code: 'mobile_already_registered',
228
+ message: 'This mobile number is already linked to another account. Please sign in with that account.',
229
+ status: :conflict
230
+ )
231
+ end
232
+
233
+ current_user.confirm_mobile!(normalized)
234
+ render json: { user: serialize_user(current_user) }, status: :ok
235
+ end
236
+
237
+ protected
238
+
239
+ def serializer_params
240
+ {
241
+ store: current_store,
242
+ locale: current_locale,
243
+ currency: current_currency,
244
+ user: current_user,
245
+ includes: []
246
+ }
247
+ end
248
+
249
+ private
250
+
251
+ def render_error(code:, message:, status:, details: nil, retry_after_seconds: nil)
252
+ error_response = {
253
+ error: {
254
+ code: code,
255
+ message: message
256
+ }
257
+ }
258
+ error_response[:error][:details] = details if details.present?
259
+ error_response[:error][:retry_after_seconds] = retry_after_seconds if retry_after_seconds.present?
260
+
261
+ render json: error_response, status: status
262
+ end
263
+
264
+ def serialize_user(user)
265
+ # Serializers return string or symbol keys depending on the Spree
266
+ # version; normalize so the extra fields replace rather than duplicate.
267
+ serialized = user_serializer.new(user, params: serializer_params).to_h.stringify_keys
268
+ serialized.merge(
269
+ 'id' => user.prefixed_id,
270
+ 'mobile_number' => user.mobile_number,
271
+ 'mobile_confirmed_at' => user.mobile_confirmed_at,
272
+ 'accepts_email_marketing' => user.accepts_email_marketing
273
+ )
274
+ end
275
+
276
+ def user_serializer
277
+ Spree.api.customer_serializer
278
+ end
279
+
280
+ def request_env_for_token
281
+ {
282
+ ip_address: request.remote_ip,
283
+ user_agent: request.user_agent&.truncate(255)
284
+ }
285
+ end
286
+
287
+ def normalize_phone(raw)
288
+ Spree::Otp::Identity.normalize_phone(raw).to_s
289
+ end
290
+
291
+ # Newest unverified login attempt from the last 15 minutes.
292
+ def latest_login_attempt(identifier)
293
+ Spree::OtpAttempt.where(store: current_store, identifier: identifier, verified_at: nil).login
294
+ .where('created_at > ?', 15.minutes.ago)
295
+ .order(created_at: :desc).first
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class OtpMailer < ApplicationMailer
5
+ def send_otp(store:, to:, code:, expiry_minutes: 10)
6
+ @store = store
7
+ @code = code
8
+ @expiry_minutes = expiry_minutes
9
+
10
+ # Routed through the store's email providers (spree_notification_providers).
11
+ # The sender is left to the provider: SMTP uses its own from address, so
12
+ # the code never goes out from an address the SMTP account can't send as.
13
+ headers['X-Spree-Store-Id'] = store.id.to_s
14
+ headers['X-Spree-Message-Type'] = 'otp_code'
15
+
16
+ from_address = nil
17
+ reply_to_address = store.customer_support_email.presence || store.mail_from_address.presence
18
+
19
+ config = store.otp_configuration || (store.respond_to?(:effective_otp_configuration) ? store.effective_otp_configuration : nil)
20
+ template = config&.email_template
21
+
22
+ if template
23
+ begin
24
+ variables = {
25
+ 'otp_code' => code.to_s,
26
+ 'store_name' => store.name.to_s,
27
+ 'expiry_minutes' => expiry_minutes.to_s,
28
+ 'OTP_CODE' => code.to_s,
29
+ 'STORE_NAME' => store.name.to_s,
30
+ 'EXPIRY_MINUTES' => expiry_minutes.to_s
31
+ }
32
+ rendered_subject = template.render_subject(variables)
33
+ rendered_body = template.render_body(variables)
34
+
35
+ return mail(
36
+ to: to,
37
+ from: from_address,
38
+ reply_to: reply_to_address,
39
+ subject: rendered_subject.presence || "#{code} is your #{store.name} verification code"
40
+ ) do |format|
41
+ format.html { render html: rendered_body.html_safe }
42
+ format.text { render plain: ActionView::Base.full_sanitizer.sanitize(rendered_body) }
43
+ end
44
+ rescue StandardError => e
45
+ Rails.logger.warn("[SpreeOtpAuth] Failed to render email template #{template.id}: #{e.message}. Falling back to default mailer.")
46
+ end
47
+ end
48
+
49
+ mail(
50
+ to: to,
51
+ from: from_address,
52
+ reply_to: reply_to_address,
53
+ subject: "#{code} is your #{store.name} verification code"
54
+ )
55
+ end
56
+ end
57
+ end