standard_id 0.54.1 → 0.55.1
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 +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +123 -0
- data/app/controllers/standard_id/api/email_password_controller.rb +131 -0
- data/app/forms/standard_id/web/reset_password_confirm_form.rb +8 -8
- data/app/jobs/standard_id/purge_unverified_email_signups_job.rb +141 -0
- data/app/mailers/standard_id/account_notice_mailer.rb +33 -0
- data/app/models/standard_id/identifier.rb +22 -2
- data/app/models/standard_id/session.rb +4 -4
- data/app/views/standard_id/account_notice_mailer/existing_account.html.erb +22 -0
- data/app/views/standard_id/account_notice_mailer/existing_account.text.erb +10 -0
- data/config/locales/en.yml +8 -0
- data/config/routes/api.rb +14 -0
- data/db/migrate/20261022000000_add_signup_source_to_standard_id_identifiers.rb +13 -0
- data/lib/standard_id/access_revocation.rb +40 -0
- data/lib/standard_id/config/callable_validator.rb +49 -0
- data/lib/standard_id/config/schema.rb +108 -0
- data/lib/standard_id/email_password/authentication.rb +71 -0
- data/lib/standard_id/email_password/daily_limit.rb +67 -0
- data/lib/standard_id/email_password/password_reset.rb +160 -0
- data/lib/standard_id/email_password/registration.rb +141 -0
- data/lib/standard_id/email_password/token_flow.rb +36 -0
- data/lib/standard_id/email_password/verification.rb +155 -0
- data/lib/standard_id/email_password.rb +209 -0
- data/lib/standard_id/errors.rb +39 -0
- data/lib/standard_id/login_method_policy.rb +2 -1
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +8 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79b115262b448690a7d1429716da8b36e6422be79e9406868684a3744e0cf3e8
|
|
4
|
+
data.tar.gz: a43dc5ed9ea2ff5bd5317809f79c9326d1df14a2115f63e631090a7fe15042cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a184a92b4a8f865db6e4fd4f05cbaa9e44d1156407ed61d30530ee6379269cc9cc3da0d9f64b800c7fc44461abc28d2df44ebe2d4562a6dbde96d0f69a106ec7
|
|
7
|
+
data.tar.gz: abc3fe5d187c94408406cfeba9e27a699f5542b30a1979c268c7b5fe7d2986ea29b56dcb6d4b8399b8e0df3a7ed56271951a49eec9a25076d5cd568a1a30b9c5
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.55.1] - 2026-10-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`credential.password.reset_completed` from `POST /auth/password/reset` carries a
|
|
15
|
+
loaded credential again.** The reset's row lock dropped the preloaded
|
|
16
|
+
`credential → identifier → account`, so under strict loading a subscriber that walked
|
|
17
|
+
`event[:credential].credential.identifier` raised, silently, since the publish is
|
|
18
|
+
rescued. The chain is now preloaded again before the event is published.
|
|
19
|
+
- **A `reset_completed` subscriber that raises is now reported via `Rails.error`**
|
|
20
|
+
(`handled: true, source: "standard_id"`) as well as logged, on both the API reset and
|
|
21
|
+
the web reset form, so it reaches the host's error tracker. The reset still succeeds.
|
|
22
|
+
|
|
23
|
+
## [0.55.0] - 2026-10-22
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **Email + password API, opt-in (`c.email_password.enabled`).** `POST /auth/register`,
|
|
28
|
+
`/auth/verify-email`, `/auth/verify-email/resend`, `/auth/login`,
|
|
29
|
+
`/auth/password/forgot` and `/auth/password/reset` under the API mount, for JSON
|
|
30
|
+
clients. Sign-up creates an unverified account and emails a 6-digit code;
|
|
31
|
+
verification needs the code and the sign-up password and returns the usual token
|
|
32
|
+
response, refreshable through `/oauth/token`. Login answers a distinct `unverified`
|
|
33
|
+
only to the right password. Resend and forgot always answer `202`. A reset verifies
|
|
34
|
+
the address and revokes every session and refresh token. Re-submitting a pending
|
|
35
|
+
sign-up never changes its password. Errors are stable `StandardId::EmailPasswordError`
|
|
36
|
+
codes, with an `error_renderer` hook for hosts that keep their own envelope. Short
|
|
37
|
+
limits reuse `c.rate_limits`, plus new daily per-address caps
|
|
38
|
+
(`rate_limits.email_password_*`). The operations behind it
|
|
39
|
+
(`StandardId::EmailPassword::*`) are public. Migration
|
|
40
|
+
**`20261022000000_add_signup_source_to_standard_id_identifiers`** adds the
|
|
41
|
+
nullable `signup_source` column that marks these sign-ups; run it before enabling
|
|
42
|
+
the API. See the README and `docs/MIGRATION_GUIDE.md`.
|
|
43
|
+
- **`c.email_password.reveal_existing_accounts`** (default `true`: `409
|
|
44
|
+
account_exists` / `social_account`). `false` answers sign-up for a registered address
|
|
45
|
+
like a new one and emails the owner (`AccountNoticeMailer#existing_account`).
|
|
46
|
+
- **`StandardId::PurgeUnverifiedEmailSignupsJob`** removes sign-ups made through the
|
|
47
|
+
email + password API (`signup_source` `"email_password"`; never the web sign-up form's) left unverified for `c.email_password.unverified_purge_after` (3 days), skipping any
|
|
48
|
+
account showing use. Schedule it yourself (`docs/OPERATIONS.md`).
|
|
49
|
+
- **`StandardId::EmailPassword.discard_pending_credentials!`**, for a
|
|
50
|
+
`social.account.linked` subscriber: drops a stranger's pending password from an
|
|
51
|
+
account its owner has just proven with a provider.
|
|
52
|
+
- **`StandardId::AccessRevocation.revoke_all!(account, reason:, issued_before: nil)`**
|
|
53
|
+
revokes every session and every refresh token, including sessionless ones; the web
|
|
54
|
+
password reset now uses it.
|
|
55
|
+
- **`login_method_policy` flow `:api_email_password`.**
|
|
56
|
+
- **`Identifier#verify!(defer_event: true)`** publishes
|
|
57
|
+
`IDENTIFIER_VERIFICATION_SUCCEEDED` after the enclosing transaction commits (and not
|
|
58
|
+
at all if it rolls back). The default is unchanged.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **`SESSION_REVOKED` timing on a web password reset** (from 0.54.1, now documented):
|
|
63
|
+
the events publish inside the reset's transaction, before it commits. Each is
|
|
64
|
+
rescued, so a subscriber cannot roll the reset back, but one that reads the rows
|
|
65
|
+
must do so after commit (from a job, say).
|
|
66
|
+
- **`passwordless.bypass_code` applies to the web email/phone verification pages**
|
|
67
|
+
(from 0.54.1, which routed them through `Otp.verify`). It is never accepted in
|
|
68
|
+
production, which raises.
|
|
69
|
+
|
|
10
70
|
## [0.54.1] - 2026-10-22
|
|
11
71
|
|
|
12
72
|
### Security
|
data/README.md
CHANGED
|
@@ -1614,6 +1614,7 @@ usual "invalid" answer and never reaches the policy.
|
|
|
1614
1614
|
| `:oauth_social_callback` (`/api/oauth/callback/:provider`) | `:social` + `provider` | `403 access_denied`; a just-created account is removed |
|
|
1615
1615
|
| `:oauth_refresh_token` | the **original** sign-in's method (and provider), else `:unspecified` | `400 invalid_grant` ("Refresh token is no longer valid"), and the token family is revoked |
|
|
1616
1616
|
| `:api_device_session` / `:api_service_session` (`Api::TokenManager#create_*_session` from host code) | what you pass, else `:unspecified` | raises `LoginMethodDenied` |
|
|
1617
|
+
| `:api_email_password` (`/api/auth/login`, `/api/auth/verify-email`) | `:password` | `403 {"error":"login_method_denied"}` (through `error_renderer` when set) |
|
|
1617
1618
|
|
|
1618
1619
|
**Refresh tokens remember how their authentication was made.** The method
|
|
1619
1620
|
(and provider) is recorded at sign-in and carried along: web sign-in → the
|
|
@@ -1674,6 +1675,127 @@ POST /api/passwordless/verify
|
|
|
1674
1675
|
}
|
|
1675
1676
|
```
|
|
1676
1677
|
|
|
1678
|
+
### Email + password API (0.55+)
|
|
1679
|
+
|
|
1680
|
+
Opt-in sign-up and sign-in with an email and a password for JSON clients
|
|
1681
|
+
(mobile apps, SPAs), alongside the OAuth grants. Off by default; the routes are
|
|
1682
|
+
not drawn until you enable it. It needs migration
|
|
1683
|
+
`20261022000000_add_signup_source_to_standard_id_identifiers`, so run
|
|
1684
|
+
`bin/rails standard_id:install:migrations && bin/rails db:migrate` before
|
|
1685
|
+
enabling it.
|
|
1686
|
+
|
|
1687
|
+
```ruby
|
|
1688
|
+
StandardId.configure do |c|
|
|
1689
|
+
c.email_password.enabled = true
|
|
1690
|
+
c.email_password.reset_url = "https://app.example.com/password-reset?token={token}"
|
|
1691
|
+
|
|
1692
|
+
# Optional hooks (each may declare any subset of its keywords):
|
|
1693
|
+
c.email_password.require_name = true
|
|
1694
|
+
c.email_password.client_key = ->(request:) { MaskedNetwork.for(request) } # default: remote IP
|
|
1695
|
+
c.email_password.password_validator = ->(password:) { PwnedPasswords.check(password) } # => Array of messages
|
|
1696
|
+
c.email_password.after_email_verified = ->(account:, name:) { Profile.provision!(account, name) }
|
|
1697
|
+
c.email_password.account_in_use = ->(account:) { Profile.exists?(account: account) }
|
|
1698
|
+
end
|
|
1699
|
+
```
|
|
1700
|
+
|
|
1701
|
+
| Request (under the API mount) | Body | Success |
|
|
1702
|
+
|---|---|---|
|
|
1703
|
+
| `POST /auth/register` | `email`, `password`, `name?` | `202 {"status":"verification_required","email":…}` |
|
|
1704
|
+
| `POST /auth/verify-email` | `email`, `code`, `password` | `200` token response |
|
|
1705
|
+
| `POST /auth/verify-email/resend` | `email`, `password` | `202 {"status":"verification_sent"}`, always |
|
|
1706
|
+
| `POST /auth/login` | `email`, `password` | `200` token response |
|
|
1707
|
+
| `POST /auth/password/forgot` | `email` | `202 {"status":"reset_requested"}`, always |
|
|
1708
|
+
| `POST /auth/password/reset` | `token`, `password`, `password_confirmation?` | `200 {"status":"password_reset"}` |
|
|
1709
|
+
|
|
1710
|
+
Requests must be JSON. Token responses are the usual `TokenResponse` and
|
|
1711
|
+
refresh through `POST /oauth/token` with `client_id` =
|
|
1712
|
+
`c.email_password.token_client_id` (default `"email"`).
|
|
1713
|
+
|
|
1714
|
+
The rules:
|
|
1715
|
+
|
|
1716
|
+
- **Sign-up creates an unverified account** and emails a 6-digit code
|
|
1717
|
+
(`PasswordlessMailer#verification_email`, or your own
|
|
1718
|
+
`PASSWORDLESS_CODE_GENERATED` subscriber with `code_delivery = :custom`). No
|
|
1719
|
+
tokens until the address is verified.
|
|
1720
|
+
- **Verifying needs the code and the sign-up password**, so a stranger who
|
|
1721
|
+
started a sign-up for someone else's address cannot finish it with the
|
|
1722
|
+
owner's code. A wrong password answers exactly like a wrong code. Outside
|
|
1723
|
+
production, `passwordless.bypass_code` is accepted as the code (as everywhere
|
|
1724
|
+
`Otp.verify` runs); `after_email_verified` then receives no `name`.
|
|
1725
|
+
- **Re-submitting sign-up for a pending address never changes its password.**
|
|
1726
|
+
With the same password it sends a fresh code; with a different one it answers
|
|
1727
|
+
the same `202` and sends nothing.
|
|
1728
|
+
- **Login answers `unverified` (403) only when the password is right**;
|
|
1729
|
+
everything else (unknown address, wrong password, deactivated or locked
|
|
1730
|
+
account) is one `invalid_credentials` (401).
|
|
1731
|
+
- **Resend and forgot-password answer the same whatever the address.** The
|
|
1732
|
+
reset lookup happens in `PasswordResetDeliveryJob`.
|
|
1733
|
+
- **A reset link** sets the password, verifies the address (the link proved the
|
|
1734
|
+
inbox; a pending sign-up's `after_email_verified` runs then) and revokes every
|
|
1735
|
+
session and refresh token. It is single-use and
|
|
1736
|
+
expires with `PasswordCredential`'s `password_reset` token.
|
|
1737
|
+
- **Existing accounts:** sign-up for an address with a verified password
|
|
1738
|
+
account answers `409 account_exists`, and for a social-only account `409
|
|
1739
|
+
social_account`. Set `c.email_password.reveal_existing_accounts = false` to
|
|
1740
|
+
answer those like a new address instead and email the owner
|
|
1741
|
+
(`AccountNoticeMailer#existing_account`).
|
|
1742
|
+
- **`login_method_policy`** is consulted with `flow: :api_email_password`.
|
|
1743
|
+
- **Hooks and events.** `ACCOUNT_CREATED` and `CREDENTIAL_PASSWORD_CREATED`
|
|
1744
|
+
fire at sign-up, for an address that is not verified yet; provision anything
|
|
1745
|
+
that should wait for a proven address from `after_email_verified`. That hook
|
|
1746
|
+
runs once, when the address is first verified (by code or reset link), in the
|
|
1747
|
+
verification's transaction: if it raises, the address stays unverified and the
|
|
1748
|
+
registrant retries with a new code or link. It runs only for a sign-up made
|
|
1749
|
+
through this API (its identifier's `signup_source` is `"email_password"`): a
|
|
1750
|
+
web sign-up verified here, by code or reset link, is verified without it. It runs with the address's rows
|
|
1751
|
+
locked, so keep slow work (an external call) in a job it enqueues.
|
|
1752
|
+
|
|
1753
|
+
`IDENTIFIER_VERIFICATION_SUCCEEDED` is published only after that transaction
|
|
1754
|
+
commits. A failing subscriber to it is logged, not raised, since the
|
|
1755
|
+
verification has already committed. Tokens are minted under the password
|
|
1756
|
+
credential's row lock, so a reset racing a login or a verification either
|
|
1757
|
+
lands first (that request then fails) or revokes what it minted.
|
|
1758
|
+
|
|
1759
|
+
**Errors** are `StandardId::EmailPasswordError` codes, rendered as
|
|
1760
|
+
`{"error": code, "error_description": message, "details"?: {field: [messages]}}`:
|
|
1761
|
+
|
|
1762
|
+
| Code | Status |
|
|
1763
|
+
|---|---|
|
|
1764
|
+
| `invalid_credentials` | 401 |
|
|
1765
|
+
| `unverified`, `login_method_denied` | 403 |
|
|
1766
|
+
| `account_exists`, `social_account` | 409 |
|
|
1767
|
+
| `invalid_code`, `invalid_reset_token`, `weak_password`, `invalid_request` | 422 |
|
|
1768
|
+
| `rate_limited` (with `Retry-After`) | 429 |
|
|
1769
|
+
|
|
1770
|
+
To keep an existing envelope, return your own body from
|
|
1771
|
+
`c.email_password.error_renderer = ->(error:, request:) { … }`; the status stays
|
|
1772
|
+
`error.http_status`.
|
|
1773
|
+
|
|
1774
|
+
**Limits.** Each endpoint has short per-client-key and per-address limits that
|
|
1775
|
+
reuse `c.rate_limits` (`signup_per_ip`, `login_per_*`, `verification_start_*`,
|
|
1776
|
+
`password_reset_start_*`, `otp_verify_per_ip`) plus
|
|
1777
|
+
`email_password_verify_per_email` and `email_password_reset_per_ip`; like the
|
|
1778
|
+
engine's other `rate_limit` declarations they are read when the controller
|
|
1779
|
+
loads, so set them in an initializer. Daily per-address caps sit on top:
|
|
1780
|
+
`email_password_failed_verifications_per_day` (then verify is refused for the
|
|
1781
|
+
day, even with the right code), `email_password_codes_per_day`,
|
|
1782
|
+
`email_password_reset_requests_per_network_per_day` and
|
|
1783
|
+
`email_password_reset_requests_per_day`. All of them count in
|
|
1784
|
+
`c.cache_store`, so a `NullStore` disables them. Only a wrong code sent with the
|
|
1785
|
+
right password counts toward the daily verify lockout, but the short
|
|
1786
|
+
`email_password_verify_per_email` limit counts every request, so anyone can
|
|
1787
|
+
hold an address's verification off for that window.
|
|
1788
|
+
|
|
1789
|
+
**Housekeeping.** Schedule `StandardId::PurgeUnverifiedEmailSignupsJob` (see
|
|
1790
|
+
[docs/OPERATIONS.md](docs/OPERATIONS.md)). If you link social sign-ins by
|
|
1791
|
+
email, call `StandardId::EmailPassword.discard_pending_credentials!(account,
|
|
1792
|
+
issued_before:)` from a `social.account.linked` subscriber, so an owner who
|
|
1793
|
+
signs in with a provider does not inherit a stranger's pending password.
|
|
1794
|
+
|
|
1795
|
+
The operations behind the controller (`EmailPassword::Registration`,
|
|
1796
|
+
`Authentication`, `Verification`, `PasswordReset`, `TokenFlow`) are public, so
|
|
1797
|
+
a host can drive them from its own controllers instead.
|
|
1798
|
+
|
|
1677
1799
|
### API Authentication
|
|
1678
1800
|
|
|
1679
1801
|
```ruby
|
|
@@ -1770,6 +1892,7 @@ StandardId creates the following tables:
|
|
|
1770
1892
|
- `GET /userinfo` - OpenID Connect userinfo
|
|
1771
1893
|
- `POST /passwordless/start` - Start passwordless flow
|
|
1772
1894
|
- `POST /passwordless/verify` - Verify OTP code
|
|
1895
|
+
- `POST /auth/register`, `/auth/login`, `/auth/verify-email`, `/auth/verify-email/resend`, `/auth/password/forgot`, `/auth/password/reset` - Email + password, when `c.email_password.enabled`
|
|
1773
1896
|
- `GET /oauth/callback/google` - Google OAuth callback
|
|
1774
1897
|
- `POST /oauth/callback/apple` - Apple Sign In callback
|
|
1775
1898
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Api
|
|
3
|
+
# Email + password sign-up and sign-in for JSON clients. Drawn only while
|
|
4
|
+
# `c.email_password.enabled` is true; the rules are in StandardId::EmailPassword.
|
|
5
|
+
#
|
|
6
|
+
# POST /auth/register { email, password, name? }
|
|
7
|
+
# 202 { status: "verification_required", email }
|
|
8
|
+
# POST /auth/login { email, password }
|
|
9
|
+
# 200 token response
|
|
10
|
+
# POST /auth/verify-email { email, code, password }
|
|
11
|
+
# 200 token response
|
|
12
|
+
# POST /auth/verify-email/resend { email, password }
|
|
13
|
+
# 202 { status: "verification_sent" }, ALWAYS
|
|
14
|
+
# POST /auth/password/forgot { email }
|
|
15
|
+
# 202 { status: "reset_requested" }, ALWAYS
|
|
16
|
+
# POST /auth/password/reset { token, password, password_confirmation? }
|
|
17
|
+
# 200 { status: "password_reset" }
|
|
18
|
+
#
|
|
19
|
+
# Token responses are the TokenResponse every other grant returns, and
|
|
20
|
+
# refresh through /oauth/token with `c.email_password.token_client_id`.
|
|
21
|
+
#
|
|
22
|
+
# Failures are StandardId::EmailPasswordError codes, rendered as
|
|
23
|
+
# `{ error, error_description, details? }` with the code's status, or by
|
|
24
|
+
# `c.email_password.error_renderer` when set.
|
|
25
|
+
#
|
|
26
|
+
# Every action is limited per client key (`c.email_password.client_key`,
|
|
27
|
+
# default the remote IP) and, where there is one, per target email.
|
|
28
|
+
class EmailPasswordController < BaseController
|
|
29
|
+
public_controller
|
|
30
|
+
|
|
31
|
+
RATE_LIMITS = {
|
|
32
|
+
register: { ip: [:signup_per_ip, 1.hour], email: [:verification_start_per_target, 15.minutes] },
|
|
33
|
+
login: { ip: [:login_per_ip, 15.minutes], email: [:login_per_email, 15.minutes] },
|
|
34
|
+
verify: { ip: [:otp_verify_per_ip, 15.minutes], email: [:email_password_verify_per_email, 15.minutes] },
|
|
35
|
+
resend: { ip: [:verification_start_per_ip, 1.hour], email: [:verification_start_per_target, 15.minutes] },
|
|
36
|
+
forgot_password: { ip: [:password_reset_start_per_ip, 1.hour], email: [:password_reset_start_per_target, 15.minutes] },
|
|
37
|
+
reset_password: { ip: [:email_password_reset_per_ip, 15.minutes] }
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
RATE_LIMITS.each do |action, limits|
|
|
41
|
+
limits.each do |kind, (field, within)|
|
|
42
|
+
rate_limit to: StandardId.config.rate_limits.public_send(field),
|
|
43
|
+
within: within,
|
|
44
|
+
name: "email-password-#{action}-#{kind}",
|
|
45
|
+
only: action,
|
|
46
|
+
by: kind == :ip ? -> { rate_limit_client_key } : -> { "email:#{rate_limit_email.presence || rate_limit_client_key}" },
|
|
47
|
+
with: -> { raise StandardId::EmailPasswordError.new(:rate_limited, retry_after: within.to_i) },
|
|
48
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
rescue_from StandardId::EmailPasswordError, with: :render_email_password_error
|
|
53
|
+
|
|
54
|
+
def register
|
|
55
|
+
email = StandardId::EmailPassword::Registration.call(
|
|
56
|
+
email: params[:email], password: params[:password], name: params[:name], request: request
|
|
57
|
+
)
|
|
58
|
+
render json: { status: "verification_required", email: email }, status: :accepted
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def login
|
|
62
|
+
StandardId::EmailPassword::Authentication.call(email: params[:email], password: params[:password]) do |account|
|
|
63
|
+
render_tokens_for(account)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def verify
|
|
68
|
+
StandardId::EmailPassword::Verification.verify!(
|
|
69
|
+
email: params[:email], code: params[:code], password: params[:password], request: request
|
|
70
|
+
) do |account|
|
|
71
|
+
render_tokens_for(account)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resend
|
|
76
|
+
StandardId::EmailPassword::Verification.send_code(
|
|
77
|
+
email: params[:email], password: params[:password], request: request
|
|
78
|
+
)
|
|
79
|
+
render json: { status: "verification_sent" }, status: :accepted
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def forgot_password
|
|
83
|
+
StandardId::EmailPassword::PasswordReset.request(email: params[:email], request: request)
|
|
84
|
+
render json: { status: "reset_requested" }, status: :accepted
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def reset_password
|
|
88
|
+
StandardId::EmailPassword::PasswordReset.complete!(
|
|
89
|
+
token: params[:token],
|
|
90
|
+
password: params[:password],
|
|
91
|
+
password_confirmation: params.key?(:password_confirmation) ? params[:password_confirmation] : nil,
|
|
92
|
+
request: request
|
|
93
|
+
)
|
|
94
|
+
render json: { status: "password_reset" }, status: :ok
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def render_tokens_for(account)
|
|
100
|
+
render json: StandardId::EmailPassword::TokenFlow.new(account: account, request: request).execute, status: :ok
|
|
101
|
+
rescue StandardId::AccountDeactivatedError, StandardId::AccountLockedError
|
|
102
|
+
# The same answer as a wrong password: this endpoint must not reveal
|
|
103
|
+
# the account's state.
|
|
104
|
+
raise StandardId::EmailPasswordError.new(:invalid_credentials)
|
|
105
|
+
rescue StandardId::LoginMethodDenied => e
|
|
106
|
+
# The password was right, so the policy's reason may be shown.
|
|
107
|
+
raise StandardId::EmailPasswordError.new(:login_method_denied, e.message)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def render_email_password_error(error)
|
|
111
|
+
response.set_header("Retry-After", error.retry_after.to_i.to_s) if error.retry_after.to_i.positive?
|
|
112
|
+
|
|
113
|
+
renderer = StandardId::EmailPassword.config.error_renderer
|
|
114
|
+
body = if renderer.respond_to?(:call)
|
|
115
|
+
StandardId::EmailPassword.call_hook(renderer, error: error, request: request)
|
|
116
|
+
else
|
|
117
|
+
{ error: error.code.to_s, error_description: error.message, details: error.details.presence }.compact
|
|
118
|
+
end
|
|
119
|
+
render json: body, status: error.http_status
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def rate_limit_client_key
|
|
123
|
+
StandardId::EmailPassword.client_key(request).to_s
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def rate_limit_email
|
|
127
|
+
StandardId::EmailPassword.normalize_email(params[:email])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -46,7 +46,7 @@ module StandardId
|
|
|
46
46
|
private
|
|
47
47
|
|
|
48
48
|
# The reset has already happened, so a failing subscriber must not turn
|
|
49
|
-
# it into an error page;
|
|
49
|
+
# it into an error page; report and carry on, as Session does for
|
|
50
50
|
# SESSION_REVOKED.
|
|
51
51
|
def publish_reset_completed(account)
|
|
52
52
|
StandardId::Events.publish(
|
|
@@ -55,6 +55,7 @@ module StandardId
|
|
|
55
55
|
account: account
|
|
56
56
|
)
|
|
57
57
|
rescue StandardError => e
|
|
58
|
+
Rails.error.report(e, handled: true, source: "standard_id")
|
|
58
59
|
logger = StandardId.logger
|
|
59
60
|
return unless logger.respond_to?(:error)
|
|
60
61
|
|
|
@@ -64,14 +65,13 @@ module StandardId
|
|
|
64
65
|
)
|
|
65
66
|
end
|
|
66
67
|
|
|
67
|
-
# Revoke every session
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
68
|
+
# Revoke every session and refresh token (StandardId::AccessRevocation).
|
|
69
|
+
# Revoking rather than destroying keeps the audit trail and fires
|
|
70
|
+
# SESSION_REVOKED; sessions.destroy_all used to nullify
|
|
71
|
+
# refresh_tokens.session_id (dependent: :nullify), leaving those tokens
|
|
72
|
+
# active after the reset.
|
|
72
73
|
def revoke_access!(account)
|
|
73
|
-
StandardId::
|
|
74
|
-
StandardId::RefreshToken.where(account_id: account.id, revoked_at: nil).update_all(revoked_at: Time.current)
|
|
74
|
+
StandardId::AccessRevocation.revoke_all!(account, reason: "password_reset")
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Deletes email + password sign-ups (StandardId::EmailPassword) that were
|
|
3
|
+
# never verified. A no-op unless `c.email_password.enabled`; not part of
|
|
4
|
+
# CleanupAllJob, so schedule it yourself (daily is plenty).
|
|
5
|
+
#
|
|
6
|
+
# A sign-up that sits unverified is dead weight at best and, at worst, a
|
|
7
|
+
# stranger squatting someone else's address (see
|
|
8
|
+
# EmailPassword::Registration). After `c.email_password.unverified_purge_after`
|
|
9
|
+
# seconds (3 days by default, far beyond the code TTL and the daily code cap)
|
|
10
|
+
# the pending password credential and its email identifier are removed, and
|
|
11
|
+
# the account too when nothing else is left on it.
|
|
12
|
+
#
|
|
13
|
+
# Only an email identifier that is unverified, has no provider, carries a
|
|
14
|
+
# password credential, is older than the cutoff AND was created by
|
|
15
|
+
# EmailPassword::Registration (`signup_source`, EmailPassword.api_signup?)
|
|
16
|
+
# is considered. That last condition keeps the job away from other
|
|
17
|
+
# unverified password sign-ups, such as Web::SignupForm's.
|
|
18
|
+
# Even then the whole account is skipped, unchanged, at any sign of real use:
|
|
19
|
+
# * any verified identifier, or any with a provider (a social sign-in
|
|
20
|
+
# linked to it does not verify the identifier, so the provider is
|
|
21
|
+
# checked separately);
|
|
22
|
+
# * any session or refresh token;
|
|
23
|
+
# * `c.email_password.account_in_use` returning true (e.g. the host's
|
|
24
|
+
# profile rows);
|
|
25
|
+
# * any other row still referencing the account: the delete is guarded by
|
|
26
|
+
# restrict / foreign keys, and a guard failure rolls the unit back.
|
|
27
|
+
# A sign-up whose owner asked for a code within
|
|
28
|
+
# `c.email_password.purge_recent_code_window` seconds is kept, so someone
|
|
29
|
+
# who comes back on day 4 is not deleted minutes before typing the code in.
|
|
30
|
+
# So is one whose address asked for a password reset in the last 24 hours
|
|
31
|
+
# (EmailPassword::DailyLimit's counter; a reset leaves no row), so the
|
|
32
|
+
# link in flight still works. That check is only as good as the rate-limit
|
|
33
|
+
# store: with a null store it never fires.
|
|
34
|
+
# Each sign-up is re-checked under a row lock in its own transaction, so a
|
|
35
|
+
# verification that lands mid-run wins. The locks are taken in the order
|
|
36
|
+
# verify and reset take them (password credential, then identifier); a
|
|
37
|
+
# deadlock or lock wait timeout anyway leaves the sign-up for the next run.
|
|
38
|
+
class PurgeUnverifiedEmailSignupsJob < ApplicationJob
|
|
39
|
+
queue_as :default
|
|
40
|
+
|
|
41
|
+
# @param older_than_seconds [Integer, nil] defaults to
|
|
42
|
+
# c.email_password.unverified_purge_after
|
|
43
|
+
# @return [Hash] `{ purged_count:, skipped_count: }`
|
|
44
|
+
def perform(older_than_seconds: nil)
|
|
45
|
+
return { purged_count: 0, skipped_count: 0 } unless StandardId::EmailPassword.enabled?
|
|
46
|
+
|
|
47
|
+
cutoff = (older_than_seconds || config.unverified_purge_after).to_i.seconds.ago
|
|
48
|
+
purged = 0
|
|
49
|
+
skipped = 0
|
|
50
|
+
|
|
51
|
+
candidates(cutoff).find_each do |identifier|
|
|
52
|
+
if purge(identifier, cutoff)
|
|
53
|
+
purged += 1
|
|
54
|
+
else
|
|
55
|
+
skipped += 1
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Rails.logger.info("[StandardId] Purged #{purged} unverified email sign-ups older than #{cutoff} (skipped #{skipped})")
|
|
60
|
+
{ purged_count: purged, skipped_count: skipped }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def config
|
|
66
|
+
StandardId::EmailPassword.config
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def candidates(cutoff)
|
|
70
|
+
StandardId::EmailIdentifier
|
|
71
|
+
.where(verified_at: nil, provider: nil, signup_source: StandardId::EmailPassword::SIGNUP_SOURCE)
|
|
72
|
+
.where(created_at: ...cutoff)
|
|
73
|
+
.where(id: StandardId::Credential.where(credentialable_type: StandardId::PasswordCredential.name).select(:identifier_id))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# true when the sign-up was removed, false when it was left alone.
|
|
77
|
+
def purge(identifier, cutoff)
|
|
78
|
+
ActiveRecord::Base.transaction do
|
|
79
|
+
lock_password_credentials(identifier)
|
|
80
|
+
identifier.lock!
|
|
81
|
+
return false unless identifier.verified_at.nil? && identifier.provider.nil? && identifier.created_at < cutoff &&
|
|
82
|
+
StandardId::EmailPassword.api_signup?(identifier)
|
|
83
|
+
|
|
84
|
+
account = StandardId.account_class.find_by(id: identifier.account_id)
|
|
85
|
+
return false if account.nil? || account_in_use?(account)
|
|
86
|
+
return false if recent_code_requested?(identifier.value) || reset_requested?(identifier.value)
|
|
87
|
+
|
|
88
|
+
credentials = StandardId::Credential.where(identifier_id: identifier.id).to_a
|
|
89
|
+
return false unless credentials.all? { |c| c.credentialable_type == StandardId::PasswordCredential.name }
|
|
90
|
+
|
|
91
|
+
password_credentials = StandardId::PasswordCredential.where(id: credentials.map(&:credentialable_id)).to_a
|
|
92
|
+
credentials.each(&:destroy!)
|
|
93
|
+
password_credentials.each(&:destroy!)
|
|
94
|
+
identifier.destroy!
|
|
95
|
+
|
|
96
|
+
unless StandardId::Identifier.exists?(account_id: account.id)
|
|
97
|
+
# The dependent-destroy cascade lazy-loads associations, which a host
|
|
98
|
+
# running strict_loading_by_default would refuse.
|
|
99
|
+
account.strict_loading!(false)
|
|
100
|
+
account.destroy!
|
|
101
|
+
end
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
rescue ActiveRecord::DeleteRestrictionError, ActiveRecord::InvalidForeignKey, ActiveRecord::RecordNotDestroyed,
|
|
105
|
+
ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout => e
|
|
106
|
+
Rails.logger.warn("[StandardId] Left unverified email sign-up #{identifier.id} alone: #{e.class}")
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def lock_password_credentials(identifier)
|
|
111
|
+
StandardId::PasswordCredential
|
|
112
|
+
.where(id: StandardId::Credential
|
|
113
|
+
.where(identifier_id: identifier.id, credentialable_type: StandardId::PasswordCredential.name)
|
|
114
|
+
.select(:credentialable_id))
|
|
115
|
+
.order(:id)
|
|
116
|
+
.lock
|
|
117
|
+
.to_a
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def account_in_use?(account)
|
|
121
|
+
return true if StandardId::Identifier.where(account_id: account.id).where("verified_at IS NOT NULL OR provider IS NOT NULL").exists?
|
|
122
|
+
return true if StandardId::Session.where(account_id: account.id).exists?
|
|
123
|
+
return true if StandardId::RefreshToken.where(account_id: account.id).exists?
|
|
124
|
+
|
|
125
|
+
hook = config.account_in_use
|
|
126
|
+
# Any truthy answer keeps the account: when in doubt, keep.
|
|
127
|
+
hook.respond_to?(:call) && StandardId::EmailPassword.call_hook(hook, account: account) ? true : false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def reset_requested?(email)
|
|
131
|
+
StandardId::EmailPassword::DailyLimit.count(:reset_requested, email).positive?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def recent_code_requested?(email)
|
|
135
|
+
StandardId::CodeChallenge
|
|
136
|
+
.where(realm: config.verification_realm, target: StandardId::EmailPassword.normalize_email(email))
|
|
137
|
+
.where(created_at: config.purge_recent_code_window.to_i.seconds.ago..)
|
|
138
|
+
.exists?
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Notices about an account, sent to its address rather than to whoever made
|
|
3
|
+
# the request.
|
|
4
|
+
#
|
|
5
|
+
# * #existing_account: sign-up was attempted for an address that already has
|
|
6
|
+
# an account, while `c.email_password.reveal_existing_accounts` is false
|
|
7
|
+
# (the API then answers as if the sign-up went ahead). The owner learns
|
|
8
|
+
# about it and how to get in; the requester learns nothing.
|
|
9
|
+
#
|
|
10
|
+
# Copy lives under `standard_id.account_notice_mailer.<action>.*` in
|
|
11
|
+
# config/locales/en.yml; override any key in the host's locale files, or the
|
|
12
|
+
# templates in app/views/standard_id/account_notice_mailer/.
|
|
13
|
+
class AccountNoticeMailer < ApplicationMailer
|
|
14
|
+
layout false
|
|
15
|
+
|
|
16
|
+
helper_method :notice_copy
|
|
17
|
+
|
|
18
|
+
# @param email [String]
|
|
19
|
+
def existing_account
|
|
20
|
+
@email = params[:email]
|
|
21
|
+
|
|
22
|
+
mail(to: @email, from: StandardId.config.passwordless.mailer_from, subject: notice_copy(:subject))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Copy for the current action, falling back to the gem's English (see
|
|
26
|
+
# PasswordlessMailer#otp_copy).
|
|
27
|
+
def notice_copy(key, default: nil, **options)
|
|
28
|
+
scope = "standard_id.account_notice_mailer.#{action_name}"
|
|
29
|
+
english = I18n.t(key, scope: scope, locale: :en, default: default, **options)
|
|
30
|
+
I18n.t(key, scope: scope, default: english, **options)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -20,9 +20,17 @@ module StandardId
|
|
|
20
20
|
verified_at.present?
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
# +defer_event+ holds IDENTIFIER_VERIFICATION_SUCCEEDED until the
|
|
24
|
+
# enclosing transaction commits, and drops it if that rolls back. The
|
|
25
|
+
# verification has committed by the time it fires, so a failing
|
|
26
|
+
# subscriber is logged rather than raised.
|
|
27
|
+
def verify!(defer_event: false)
|
|
24
28
|
update!(verified_at: Time.current)
|
|
25
|
-
|
|
29
|
+
if defer_event
|
|
30
|
+
ActiveRecord.after_all_transactions_commit { emit_verification_succeeded_after_commit }
|
|
31
|
+
else
|
|
32
|
+
emit_verification_succeeded
|
|
33
|
+
end
|
|
26
34
|
end
|
|
27
35
|
|
|
28
36
|
def unverify!
|
|
@@ -63,6 +71,18 @@ module StandardId
|
|
|
63
71
|
)
|
|
64
72
|
end
|
|
65
73
|
|
|
74
|
+
def emit_verification_succeeded_after_commit
|
|
75
|
+
emit_verification_succeeded
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
logger = StandardId.logger
|
|
78
|
+
return unless logger.respond_to?(:error)
|
|
79
|
+
|
|
80
|
+
logger.error(
|
|
81
|
+
"[StandardId::Identifier] Failed to publish IDENTIFIER_VERIFICATION_SUCCEEDED " \
|
|
82
|
+
"for identifier #{id}: #{e.class}: #{e.message}"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
66
86
|
def emit_account_verified
|
|
67
87
|
StandardId::Events.publish(
|
|
68
88
|
StandardId::Events::ACCOUNT_VERIFIED,
|
|
@@ -176,10 +176,10 @@ module StandardId
|
|
|
176
176
|
)
|
|
177
177
|
end
|
|
178
178
|
|
|
179
|
-
# DB state is already committed by the time this runs
|
|
180
|
-
# wraps revoke_all_for! in its own transaction, as
|
|
181
|
-
# does
|
|
182
|
-
# best-effort audit emission. A failing subscriber must not short-circuit the
|
|
179
|
+
# DB state is already committed by the time this runs, unless the caller
|
|
180
|
+
# wraps revoke_all_for! in its own transaction, as StandardId::AccessRevocation
|
|
181
|
+
# does for the password-reset paths (Web::ResetPasswordConfirmForm and
|
|
182
|
+
# EmailPassword::PasswordReset). Event publishing is best-effort audit emission. A failing subscriber must not short-circuit the
|
|
183
183
|
# loop and leave later sessions without their SESSION_REVOKED event, which
|
|
184
184
|
# would permanently desync audit-trail consumers from the DB.
|
|
185
185
|
def self.publish_session_revocations(sessions, account:, reason:, now:)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<style>
|
|
6
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; }
|
|
7
|
+
.container { max-width: 480px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; padding: 40px; }
|
|
8
|
+
.footer { margin-top: 32px; font-size: 13px; color: #888888; text-align: center; }
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div class="container">
|
|
13
|
+
<p><%= notice_copy(:greeting) %></p>
|
|
14
|
+
<p><%= notice_copy(:intro) %></p>
|
|
15
|
+
<p><%= notice_copy(:action) %></p>
|
|
16
|
+
<p><%= notice_copy(:ignore) %></p>
|
|
17
|
+
<div class="footer">
|
|
18
|
+
<p><%= notice_copy(:footer) %></p>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|