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
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
|