standard_id 0.54.0 → 0.55.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 +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +123 -0
- data/app/controllers/standard_id/api/email_password_controller.rb +131 -0
- data/app/controllers/standard_id/web/verify_email/confirm_controller.rb +18 -22
- data/app/controllers/standard_id/web/verify_phone/confirm_controller.rb +18 -22
- data/app/forms/standard_id/web/reset_password_confirm_form.rb +41 -1
- 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 -2
- 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 +148 -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: 1b6e3c72dcbb1a915c4927a4f0607b13d6f66c791240e20825698b490b0f13a1
|
|
4
|
+
data.tar.gz: 9606c37fbf368616167772d7a32fdadc7b4abb16758c9041b7041694b697ffb4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b31012a34cc04b983d754b506f13d227a046dd5a33a53774b071d2822e4d51aba4745d685a25f0d84c7c55b7c37e79378351a4efbc556b91831368095282d7d4
|
|
7
|
+
data.tar.gz: e0ad4c58dfeb705e5824da097c12b6a00b6d60450f0c248a3f0a1874fe912f5549ecebfdbd1dd199aa6810af6668fea7f8562c6ee7dad0b076f29635e36ffd44
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.55.0] - 2026-10-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Email + password API, opt-in (`c.email_password.enabled`).** `POST /auth/register`,
|
|
15
|
+
`/auth/verify-email`, `/auth/verify-email/resend`, `/auth/login`,
|
|
16
|
+
`/auth/password/forgot` and `/auth/password/reset` under the API mount, for JSON
|
|
17
|
+
clients. Sign-up creates an unverified account and emails a 6-digit code;
|
|
18
|
+
verification needs the code and the sign-up password and returns the usual token
|
|
19
|
+
response, refreshable through `/oauth/token`. Login answers a distinct `unverified`
|
|
20
|
+
only to the right password. Resend and forgot always answer `202`. A reset verifies
|
|
21
|
+
the address and revokes every session and refresh token. Re-submitting a pending
|
|
22
|
+
sign-up never changes its password. Errors are stable `StandardId::EmailPasswordError`
|
|
23
|
+
codes, with an `error_renderer` hook for hosts that keep their own envelope. Short
|
|
24
|
+
limits reuse `c.rate_limits`, plus new daily per-address caps
|
|
25
|
+
(`rate_limits.email_password_*`). The operations behind it
|
|
26
|
+
(`StandardId::EmailPassword::*`) are public. Migration
|
|
27
|
+
**`20261022000000_add_signup_source_to_standard_id_identifiers`** adds the
|
|
28
|
+
nullable `signup_source` column that marks these sign-ups; run it before enabling
|
|
29
|
+
the API. See the README and `docs/MIGRATION_GUIDE.md`.
|
|
30
|
+
- **`c.email_password.reveal_existing_accounts`** (default `true`: `409
|
|
31
|
+
account_exists` / `social_account`). `false` answers sign-up for a registered address
|
|
32
|
+
like a new one and emails the owner (`AccountNoticeMailer#existing_account`).
|
|
33
|
+
- **`StandardId::PurgeUnverifiedEmailSignupsJob`** removes sign-ups made through the
|
|
34
|
+
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
|
|
35
|
+
account showing use. Schedule it yourself (`docs/OPERATIONS.md`).
|
|
36
|
+
- **`StandardId::EmailPassword.discard_pending_credentials!`**, for a
|
|
37
|
+
`social.account.linked` subscriber: drops a stranger's pending password from an
|
|
38
|
+
account its owner has just proven with a provider.
|
|
39
|
+
- **`StandardId::AccessRevocation.revoke_all!(account, reason:, issued_before: nil)`**
|
|
40
|
+
revokes every session and every refresh token, including sessionless ones; the web
|
|
41
|
+
password reset now uses it.
|
|
42
|
+
- **`login_method_policy` flow `:api_email_password`.**
|
|
43
|
+
- **`Identifier#verify!(defer_event: true)`** publishes
|
|
44
|
+
`IDENTIFIER_VERIFICATION_SUCCEEDED` after the enclosing transaction commits (and not
|
|
45
|
+
at all if it rolls back). The default is unchanged.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **`SESSION_REVOKED` timing on a web password reset** (from 0.54.1, now documented):
|
|
50
|
+
the events publish inside the reset's transaction, before it commits. Each is
|
|
51
|
+
rescued, so a subscriber cannot roll the reset back, but one that reads the rows
|
|
52
|
+
must do so after commit (from a job, say).
|
|
53
|
+
- **`passwordless.bypass_code` applies to the web email/phone verification pages**
|
|
54
|
+
(from 0.54.1, which routed them through `Otp.verify`). It is never accepted in
|
|
55
|
+
production, which raises.
|
|
56
|
+
|
|
57
|
+
## [0.54.1] - 2026-10-22
|
|
58
|
+
|
|
59
|
+
### Security
|
|
60
|
+
|
|
61
|
+
- **Web email/phone verification now counts attempts.** `Web::VerifyEmail::ConfirmController`
|
|
62
|
+
and `Web::VerifyPhone::ConfirmController` looked the code up with
|
|
63
|
+
`CodeChallenge.active.find_by(code:)`, so the only brake on guessing was the per-IP
|
|
64
|
+
rate limit. `PATCH` now goes through `StandardId::Otp.verify`, which counts failed
|
|
65
|
+
attempts and burns the challenge at `passwordless.max_attempts_per_challenge`.
|
|
66
|
+
`GET` no longer checks the code at all (it answered valid/invalid without counting an
|
|
67
|
+
attempt); a wrong code is reported on submit. Because `Otp.verify` honours
|
|
68
|
+
`passwordless.bypass_code`, that code now also verifies an email or phone through these
|
|
69
|
+
pages where it is allowed (never in production, which raises).
|
|
70
|
+
- **Web password reset revokes refresh tokens.** `Web::ResetPasswordConfirmForm` ran
|
|
71
|
+
`account.sessions.destroy_all`, and `Session has_many :refresh_tokens, dependent:
|
|
72
|
+
:nullify` left every refresh token active and detached. It now revokes all sessions
|
|
73
|
+
(`Session.revoke_all_for!`, reason `"password_reset"`, one `SESSION_REVOKED` each)
|
|
74
|
+
and every remaining refresh token for the account, and publishes the documented
|
|
75
|
+
`credential.password.reset_completed` event, which the gem never emitted before (a
|
|
76
|
+
raising subscriber is logged, not surfaced, since the reset has already happened).
|
|
77
|
+
Sessions are revoked, no longer deleted.
|
|
78
|
+
|
|
10
79
|
## [0.54.0] - 2026-10-22
|
|
11
80
|
|
|
12
81
|
### Added
|
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
|
|
@@ -3,46 +3,42 @@ module StandardId
|
|
|
3
3
|
module VerifyEmail
|
|
4
4
|
class ConfirmController < BaseController
|
|
5
5
|
# Per-IP limit on code confirmation (20 per 15 minutes, shared with OTP
|
|
6
|
-
# verify).
|
|
7
|
-
# guessing across many challenges
|
|
6
|
+
# verify). Otp.verify's per-challenge attempt cap alone doesn't stop
|
|
7
|
+
# distributed guessing across many challenges.
|
|
8
8
|
rate_limit to: StandardId.config.rate_limits.otp_verify_per_ip,
|
|
9
9
|
within: 15.minutes,
|
|
10
10
|
name: "verify-email-confirm-ip",
|
|
11
11
|
only: [:show, :update],
|
|
12
12
|
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
13
13
|
|
|
14
|
-
before_action :prepare_code_challenge
|
|
15
|
-
|
|
16
14
|
def show
|
|
17
|
-
return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if
|
|
15
|
+
return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if target.blank? || code.blank?
|
|
16
|
+
# Does not check the code: a GET that answered valid/invalid would be
|
|
17
|
+
# a guessing oracle that never counts an attempt. PATCH verifies.
|
|
18
18
|
render plain: "verify email confirm", status: :ok
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def update
|
|
22
|
-
|
|
22
|
+
# Otp.verify locks the challenge, compares in constant time, counts
|
|
23
|
+
# the failed attempt and burns the challenge at the per-challenge cap.
|
|
24
|
+
return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if code.blank?
|
|
25
|
+
|
|
26
|
+
result = StandardId::Otp.verify(realm: "verification", target: target, code: code, channel: :email, request: request)
|
|
27
|
+
return redirect_to(standard_id_web.login_path, alert: result.error) unless result.success?
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
if identifier.present?
|
|
26
|
-
identifier.verify!
|
|
27
|
-
end
|
|
28
|
-
@challenge.use!
|
|
29
|
+
StandardId::EmailIdentifier.find_by(value: target)&.verify!
|
|
29
30
|
|
|
30
31
|
redirect_to standard_id_web.login_path, notice: "Your email has been verified. Please sign in.", status: :see_other
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
private
|
|
34
35
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
realm: "verification",
|
|
42
|
-
channel: "email",
|
|
43
|
-
target: email,
|
|
44
|
-
code: code
|
|
45
|
-
)
|
|
36
|
+
def target
|
|
37
|
+
@target ||= params[:email].to_s.strip.downcase
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def code
|
|
41
|
+
params[:code].to_s
|
|
46
42
|
end
|
|
47
43
|
end
|
|
48
44
|
end
|
|
@@ -3,46 +3,42 @@ module StandardId
|
|
|
3
3
|
module VerifyPhone
|
|
4
4
|
class ConfirmController < BaseController
|
|
5
5
|
# Per-IP limit on code confirmation (20 per 15 minutes, shared with OTP
|
|
6
|
-
# verify).
|
|
7
|
-
# guessing across many challenges
|
|
6
|
+
# verify). Otp.verify's per-challenge attempt cap alone doesn't stop
|
|
7
|
+
# distributed guessing across many challenges.
|
|
8
8
|
rate_limit to: StandardId.config.rate_limits.otp_verify_per_ip,
|
|
9
9
|
within: 15.minutes,
|
|
10
10
|
name: "verify-phone-confirm-ip",
|
|
11
11
|
only: [:show, :update],
|
|
12
12
|
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
13
13
|
|
|
14
|
-
before_action :prepare_code_challenge
|
|
15
|
-
|
|
16
14
|
def show
|
|
17
|
-
return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if
|
|
15
|
+
return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if target.blank? || code.blank?
|
|
16
|
+
# Does not check the code: a GET that answered valid/invalid would be
|
|
17
|
+
# a guessing oracle that never counts an attempt. PATCH verifies.
|
|
18
18
|
render plain: "verify phone confirm", status: :ok
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def update
|
|
22
|
-
|
|
22
|
+
# Otp.verify locks the challenge, compares in constant time, counts
|
|
23
|
+
# the failed attempt and burns the challenge at the per-challenge cap.
|
|
24
|
+
return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if code.blank?
|
|
25
|
+
|
|
26
|
+
result = StandardId::Otp.verify(realm: "verification", target: target, code: code, channel: :sms, request: request)
|
|
27
|
+
return redirect_to(standard_id_web.login_path, alert: result.error) unless result.success?
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
if identifier.present?
|
|
26
|
-
identifier.verify!
|
|
27
|
-
end
|
|
28
|
-
@challenge.use!
|
|
29
|
+
StandardId::PhoneNumberIdentifier.find_by(value: target)&.verify!
|
|
29
30
|
|
|
30
31
|
redirect_to standard_id_web.login_path, notice: "Your phone number has been verified. Please sign in.", status: :see_other
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
private
|
|
34
35
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
realm: "verification",
|
|
42
|
-
channel: "sms",
|
|
43
|
-
target: phone,
|
|
44
|
-
code: code
|
|
45
|
-
)
|
|
36
|
+
def target
|
|
37
|
+
@target ||= params[:phone_number].to_s.strip
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def code
|
|
41
|
+
params[:code].to_s
|
|
46
42
|
end
|
|
47
43
|
end
|
|
48
44
|
end
|
|
@@ -22,16 +22,56 @@ module StandardId
|
|
|
22
22
|
def submit
|
|
23
23
|
return false unless valid?
|
|
24
24
|
|
|
25
|
+
account = @password_credential.account
|
|
26
|
+
|
|
27
|
+
# The password change and the revocation commit together, so a failure
|
|
28
|
+
# cannot leave the new password in place with old tokens still live.
|
|
29
|
+
# The SESSION_REVOKED events revoke_all_for! publishes therefore run
|
|
30
|
+
# before the commit; each is rescued, so a subscriber cannot roll the
|
|
31
|
+
# reset back, but one that reads the rows must do so after commit
|
|
32
|
+
# (e.g. from a job, which Rails enqueues after commit).
|
|
25
33
|
ActiveRecord::Base.transaction do
|
|
26
34
|
@password_credential.update!(password: password, password_confirmation: password_confirmation)
|
|
27
|
-
|
|
35
|
+
revoke_access!(account)
|
|
28
36
|
end
|
|
29
37
|
|
|
38
|
+
publish_reset_completed(account)
|
|
39
|
+
|
|
30
40
|
true
|
|
31
41
|
rescue ActiveRecord::RecordInvalid => e
|
|
32
42
|
errors.add(:base, e.record.errors.full_messages.join(", "))
|
|
33
43
|
false
|
|
34
44
|
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# The reset has already happened, so a failing subscriber must not turn
|
|
49
|
+
# it into an error page; log and carry on, as Session does for
|
|
50
|
+
# SESSION_REVOKED.
|
|
51
|
+
def publish_reset_completed(account)
|
|
52
|
+
StandardId::Events.publish(
|
|
53
|
+
StandardId::Events::CREDENTIAL_PASSWORD_RESET_COMPLETED,
|
|
54
|
+
credential: @password_credential,
|
|
55
|
+
account: account
|
|
56
|
+
)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
logger = StandardId.logger
|
|
59
|
+
return unless logger.respond_to?(:error)
|
|
60
|
+
|
|
61
|
+
logger.error(
|
|
62
|
+
"[StandardId::Web::ResetPasswordConfirmForm] Failed to publish " \
|
|
63
|
+
"CREDENTIAL_PASSWORD_RESET_COMPLETED for account #{account.id}: #{e.class}: #{e.message}"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Revoke every session and refresh token (StandardId::AccessRevocation).
|
|
68
|
+
# Revoking rather than destroying keeps the audit trail and fires
|
|
69
|
+
# SESSION_REVOKED; sessions.destroy_all used to nullify
|
|
70
|
+
# refresh_tokens.session_id (dependent: :nullify), leaving those tokens
|
|
71
|
+
# active after the reset.
|
|
72
|
+
def revoke_access!(account)
|
|
73
|
+
StandardId::AccessRevocation.revoke_all!(account, reason: "password_reset")
|
|
74
|
+
end
|
|
35
75
|
end
|
|
36
76
|
end
|
|
37
77
|
end
|