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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +123 -0
  4. data/app/controllers/standard_id/api/email_password_controller.rb +131 -0
  5. data/app/controllers/standard_id/web/verify_email/confirm_controller.rb +18 -22
  6. data/app/controllers/standard_id/web/verify_phone/confirm_controller.rb +18 -22
  7. data/app/forms/standard_id/web/reset_password_confirm_form.rb +41 -1
  8. data/app/jobs/standard_id/purge_unverified_email_signups_job.rb +141 -0
  9. data/app/mailers/standard_id/account_notice_mailer.rb +33 -0
  10. data/app/models/standard_id/identifier.rb +22 -2
  11. data/app/models/standard_id/session.rb +4 -2
  12. data/app/views/standard_id/account_notice_mailer/existing_account.html.erb +22 -0
  13. data/app/views/standard_id/account_notice_mailer/existing_account.text.erb +10 -0
  14. data/config/locales/en.yml +8 -0
  15. data/config/routes/api.rb +14 -0
  16. data/db/migrate/20261022000000_add_signup_source_to_standard_id_identifiers.rb +13 -0
  17. data/lib/standard_id/access_revocation.rb +40 -0
  18. data/lib/standard_id/config/callable_validator.rb +49 -0
  19. data/lib/standard_id/config/schema.rb +108 -0
  20. data/lib/standard_id/email_password/authentication.rb +71 -0
  21. data/lib/standard_id/email_password/daily_limit.rb +67 -0
  22. data/lib/standard_id/email_password/password_reset.rb +148 -0
  23. data/lib/standard_id/email_password/registration.rb +141 -0
  24. data/lib/standard_id/email_password/token_flow.rb +36 -0
  25. data/lib/standard_id/email_password/verification.rb +155 -0
  26. data/lib/standard_id/email_password.rb +209 -0
  27. data/lib/standard_id/errors.rb +39 -0
  28. data/lib/standard_id/login_method_policy.rb +2 -1
  29. data/lib/standard_id/version.rb +1 -1
  30. data/lib/standard_id.rb +8 -0
  31. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de8ced4a8e48a3b14437ece4082079510d1f7466737be90f3dffe3ac3959d56c
4
- data.tar.gz: af90eba1873dd457c207ce3151d75215cf5de2e8a5cb37d42ab32a1227fb5017
3
+ metadata.gz: 1b6e3c72dcbb1a915c4927a4f0607b13d6f66c791240e20825698b490b0f13a1
4
+ data.tar.gz: 9606c37fbf368616167772d7a32fdadc7b4abb16758c9041b7041694b697ffb4
5
5
  SHA512:
6
- metadata.gz: d5bb7b2e920c26ae28e3b8ddd09ea4c475bb8b0562b89f057adf9573737fc225c43470faf94e202545d3840a4ab2b41e6abc8b17836fd8272a6b17711e47d5f6
7
- data.tar.gz: b64139dbd6641e978e75d4dec5f89a14891b603755edf6e67e2a566c5ace604d678aaeb4f75a6e924f1da2d04fdd5af3de8b0f005c05c344486aa2796d45b096
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). The per-challenge attempt cap alone doesn't stop distributed
7
- # guessing across many challenges; both show and update probe a code.
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 @challenge.nil?
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
- return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
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
- identifier = StandardId::EmailIdentifier.find_by(value: @challenge.target)
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 prepare_code_challenge
36
- email = params[:email].to_s.strip.downcase
37
- code = params[:code].to_s
38
- return @challenge = nil if email.blank? || code.blank?
39
-
40
- @challenge = StandardId::CodeChallenge.active.find_by(
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). The per-challenge attempt cap alone doesn't stop distributed
7
- # guessing across many challenges; both show and update probe a code.
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 @challenge.nil?
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
- return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
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
- identifier = StandardId::PhoneNumberIdentifier.find_by(value: @challenge.target)
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 prepare_code_challenge
36
- phone = params[:phone_number].to_s.strip
37
- code = params[:code].to_s
38
- return @challenge = nil if phone.blank? || code.blank?
39
-
40
- @challenge = StandardId::CodeChallenge.active.find_by(
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
- @password_credential.account.sessions.destroy_all
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