strongroom-rails 0.2.0 → 0.3.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 +7 -0
- data/README.md +125 -13
- data/lib/strongroom/client.rb +56 -5
- data/lib/strongroom/errors.rb +8 -0
- data/lib/strongroom/rails/account.rb +12 -0
- data/lib/strongroom/rails/authentication.rb +16 -0
- data/lib/strongroom/rails/railtie.rb +3 -1
- data/lib/strongroom/rails/version.rb +1 -1
- data/lib/strongroom/session.rb +23 -6
- data/lib/strongroom/token.rb +6 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2313700e0514a62dbf3f58e5f541e0c4421f4c034a8220840cd9ca243d05b816
|
|
4
|
+
data.tar.gz: 5530d843f7630a021488e36bcc230bdfd9d1137f3aec6c5a1dd8d233c3a9b09f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b77a81722924260a13456a6416012c2670dafe0360cb8932f21c8fd3250301a50564fd7d86e7c26425e741b5fd781a08b694a812357afece92dec857570b881
|
|
7
|
+
data.tar.gz: fce5256ee8f35e3f44e70165c0615a0d5c3a0ddc61b05d5b976cf7918368f425d0b0ef026d7009fb1c7a44a733bb0b36f8849b56eda5ee73b5cb32c2b25b4127
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.2.0...strongroom-rails-v0.3.0) (2026-10-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add password recovery links and self-service password change ([b4f9160](https://github.com/seuros/strongroom/commit/b4f9160b352fb0122086c57a56c701ddb29b461a))
|
|
9
|
+
|
|
3
10
|
## [0.2.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.1.0...strongroom-rails/v0.2.0) (2026-10-25)
|
|
4
11
|
|
|
5
12
|
|
data/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Rails adapter for [Strongroom](https://github.com/seuros/strongroom), a Postgres-first authentication kernel
|
|
4
4
|
that acts as your application's security coprocessor: Rails never sees password
|
|
5
|
-
hashes or session secrets. Registration
|
|
6
|
-
from your server; MFA,
|
|
7
|
-
are deliberately absent.
|
|
5
|
+
hashes or session secrets. Registration and recovery links go through the
|
|
6
|
+
kernel's service API from your server; MFA, JWT minting, mail and automatic
|
|
7
|
+
routes are deliberately absent.
|
|
8
8
|
|
|
9
9
|
> **Status: pre-production.** Strongroom has not been audited and is not approved
|
|
10
10
|
> for production traffic or real credential imports.
|
|
@@ -34,6 +34,8 @@ Requires Ruby **4.0+** and Rails **8.1+** (currently Rails 8.x).
|
|
|
34
34
|
- [x] Registration, verification links and the account event feed through the
|
|
35
35
|
kernel's service listener, with the namespace's service key (see
|
|
36
36
|
[Registration](#registration)). Sessions report `identity_verified`.
|
|
37
|
+
- [x] Self-service password change and password recovery links (see
|
|
38
|
+
[Password change and recovery](#password-change-and-recovery)).
|
|
37
39
|
|
|
38
40
|
It never finds users by email, creates local accounts, dynamically constantizes
|
|
39
41
|
table names, overrides `current_user`, installs routes or falls back to legacy
|
|
@@ -105,7 +107,8 @@ At most 16 are supported; names, tables and same-issuer audiences must be distin
|
|
|
105
107
|
Pass actual model classes, never class names from parameters/configuration.
|
|
106
108
|
`register` defaults false; `register: true` needs a client configured with
|
|
107
109
|
`service_endpoint:` and `service_key:` and enables `register_account` and
|
|
108
|
-
`issue_verification` on the account
|
|
110
|
+
`issue_verification` on the account; `issue_recovery` and `events` need only
|
|
111
|
+
the service key. The gem still installs no route.
|
|
109
112
|
|
|
110
113
|
Controllers opt in explicitly and keep their own frontend response contract:
|
|
111
114
|
|
|
@@ -365,7 +368,8 @@ verifies the address.
|
|
|
365
368
|
- `events(after: nil, limit: 100)` returns a `Strongroom::EventPage` of
|
|
366
369
|
`Strongroom::Event`s (`Provision`, `Register`, `RegistrationCancel`,
|
|
367
370
|
`VerificationIssue`, `IdentityVerify`, `PasswordHashUpgrade`,
|
|
368
|
-
`PasswordReplace`, `Revoke
|
|
371
|
+
`PasswordReplace`, `Revoke`, `RecoveryIssue`, `RecoveryComplete`,
|
|
372
|
+
`PasswordChange`); keep `page.next` and pass it as `after:`.
|
|
369
373
|
`next` is `nil` for an empty page: ask again later with the cursor you have.
|
|
370
374
|
`page.oldest_at` is the oldest event the kernel still holds; if your last
|
|
371
375
|
event is older, retention pruned events you never read. `Strongroom::CursorAhead`
|
|
@@ -379,6 +383,105 @@ Service-key refusals raise `Strongroom::ProtocolError` (a deployment error, not
|
|
|
379
383
|
a user's), rate limits `Strongroom::RateLimited`/`Busy` with `retry_after`. The
|
|
380
384
|
Railtie maps `Conflict`s to 409, `InvalidPassword` to 422 and `NotFound` to 404.
|
|
381
385
|
|
|
386
|
+
## Password change and recovery
|
|
387
|
+
|
|
388
|
+
### Changing a password
|
|
389
|
+
|
|
390
|
+
A signed-in user changes their password through your controller; the helper
|
|
391
|
+
verifies mutation intent like the others, sends the request's session to the
|
|
392
|
+
kernel and replaces the encrypted cookie with the session the kernel rotated
|
|
393
|
+
onto the new password. The user stays signed in; every other session of theirs
|
|
394
|
+
in the org ends, in every app.
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
def update # POST; verified CSRF required.
|
|
398
|
+
user = strongroom_change_password(namespace: :users,
|
|
399
|
+
current_password: params.require(:current_password), new_password: params.require(:new_password))
|
|
400
|
+
render json: {id: user.id}
|
|
401
|
+
end
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
A wrong current password raises `Strongroom::Unauthorized` (401) and changes
|
|
405
|
+
nothing: the session keeps working. A refused new password raises
|
|
406
|
+
`Strongroom::InvalidPassword` with `reason` `too_short`, `too_long` or
|
|
407
|
+
`invalid` (422), before the current password is even checked. An unverified
|
|
408
|
+
identity raises `Strongroom::IdentityUnverified` (409): nobody has proven they
|
|
409
|
+
own the address, so its password proves nothing; have the user confirm it
|
|
410
|
+
first. A session issued with a password the identity no longer logs in with
|
|
411
|
+
raises `Strongroom::CredentialChanged` (409): sign in again. The kernel's
|
|
412
|
+
per-user budget raises `Strongroom::RateLimited` (429). Add your own per-IP
|
|
413
|
+
`rate_limit` too, as for logins. Recovery links the user asked for keep
|
|
414
|
+
working after a change. `client.change_password(token:, current_password:,
|
|
415
|
+
new_password:)` (or `account.change_password`) is the same call without the
|
|
416
|
+
cookie: it returns the rotated `Strongroom::IssuedSession`, confirmed through
|
|
417
|
+
`/me` like a rotation. If that confirmation fails after the kernel made the
|
|
418
|
+
change, the rotated session is logged out before `ProtocolError` is raised:
|
|
419
|
+
the new password is set, and the user signs in with it.
|
|
420
|
+
|
|
421
|
+
### Recovering a password
|
|
422
|
+
|
|
423
|
+
Any namespace whose client has `service_endpoint:` and `service_key:` can
|
|
424
|
+
issue recovery links, for imported and registered accounts alike; `register:`
|
|
425
|
+
is not needed. Ask for a link where the mail is built, never in the request
|
|
426
|
+
that asked for it, and never hand the token to a job:
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
class RecoveriesController < ApplicationController
|
|
430
|
+
rate_limit to: 5, within: 10.minutes, by: -> { request.remote_ip }, only: :create
|
|
431
|
+
|
|
432
|
+
def create # POST; the same answer whether or not the address has an account.
|
|
433
|
+
RecoveryMailer.with(email: params.require(:email)).reset.deliver_later # the address, never a token
|
|
434
|
+
head :accepted
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
class RecoveryMailer < ApplicationMailer
|
|
439
|
+
def reset
|
|
440
|
+
users = Rails.configuration.x.strongroom_accounts.fetch(:users)
|
|
441
|
+
link = users.issue_recovery(identity: params.fetch(:email))
|
|
442
|
+
# The fragment of a link to your own page: browsers never send it, so
|
|
443
|
+
# neither your logs nor a Referer header see the token.
|
|
444
|
+
@url = "https://app.example.com/password/reset##{link.token.reveal}"
|
|
445
|
+
mail(to: params.fetch(:email), subject: "Choose a new password")
|
|
446
|
+
rescue Strongroom::NotFound
|
|
447
|
+
# No usable account holds the address: without `mail`, nothing is sent.
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
`issue_recovery(identity:)` returns a `Strongroom::Recovery` (`token`, a
|
|
453
|
+
`Strongroom::RecoveryToken` redacted everywhere and never a session credential;
|
|
454
|
+
`expires_at`, by default 30 minutes away; `user_id`). Earlier links of that
|
|
455
|
+
address stop working. It raises `Strongroom::NotFound` when no usable account
|
|
456
|
+
holds the address in the namespace: answer your users the same way either way,
|
|
457
|
+
or the form tells anyone which addresses have accounts. If the token must
|
|
458
|
+
travel through a job after all, only in an argument your own
|
|
459
|
+
`ActiveJob::Serializers::ObjectSerializer` encrypts.
|
|
460
|
+
|
|
461
|
+
The page at `/password/reset` asks for the new password and posts it with the
|
|
462
|
+
fragment to the kernel's public listener (list the page's origin in its
|
|
463
|
+
`[browser] allowed_origins`; the gem does not proxy it):
|
|
464
|
+
|
|
465
|
+
```js
|
|
466
|
+
const reply = await fetch(`${STRONGROOM_ORIGIN}/accounts/users/recovery`, {
|
|
467
|
+
method: "POST",
|
|
468
|
+
headers: { "Content-Type": "application/json", "X-Strongroom-Request": "1" },
|
|
469
|
+
body: JSON.stringify({ token: location.hash.slice(1), password: form.password.value }),
|
|
470
|
+
}); // 200 {"status": "recovered"}; 422 {"error": "invalid_password", "reason": ...} keeps the link
|
|
471
|
+
// usable; 400 {"error": "invalid_token", "reason": "invalid" | "expired"}; 503: retry
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
A completed recovery sets the new password, ends every session of the user in
|
|
475
|
+
the org (in every app) and their other recovery links, and verifies an address
|
|
476
|
+
that was never verified: following the link proved the mailbox. Send the
|
|
477
|
+
person to your sign-in page afterwards. A second use of the link answers
|
|
478
|
+
`invalid_token`. Links belong to the address across every app of the org.
|
|
479
|
+
|
|
480
|
+
Do not offer recovery for an address whose registration is still unverified:
|
|
481
|
+
the owner would inherit the account whoever registered it prepared (your
|
|
482
|
+
records for that `external_id`), and `cancel_registration` refuses it once
|
|
483
|
+
verified. Cancel such a registration and let the owner register afresh.
|
|
484
|
+
|
|
382
485
|
## Running the daemon
|
|
383
486
|
|
|
384
487
|
This gem is only the client: it ships no binary and needs none to install. The
|
|
@@ -406,8 +509,9 @@ The two gems are versioned separately.
|
|
|
406
509
|
|
|
407
510
|
- Keep direct backend access restricted and TLS/service networking trusted.
|
|
408
511
|
Local eligibility checks do not synchronize kernel account state or revoke
|
|
409
|
-
all remote sessions.
|
|
410
|
-
|
|
512
|
+
all remote sessions. Disablement and MFA enablement still need authoritative
|
|
513
|
+
credential/state updates and invalidation; password changes and recoveries
|
|
514
|
+
through the kernel revoke its sessions themselves.
|
|
411
515
|
- Imports remain an existing, confirmed, password-only cohort; registration
|
|
412
516
|
creates unverified accounts the kernel refuses until they are verified (or
|
|
413
517
|
until a configured grace runs out). Real admins and MFA-required accounts
|
|
@@ -422,8 +526,8 @@ The two gems are versioned separately.
|
|
|
422
526
|
Ruby/parser/network/framework copies cannot be reliably erased. Do not log
|
|
423
527
|
headers, cookie jars, passwords, transport bodies or explicit token strings.
|
|
424
528
|
The Railtie adds parameter filters; separately audit proxy/middleware/APM logs.
|
|
425
|
-
- Mail delivery,
|
|
426
|
-
|
|
529
|
+
- Mail delivery, bulk mapping orchestration, remote admin operations,
|
|
530
|
+
deployment SLOs and production approval are not shipped.
|
|
427
531
|
|
|
428
532
|
## Local checks (no installs, network or database required)
|
|
429
533
|
|
|
@@ -495,7 +599,8 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
|
|
|
495
599
|
`__Host-` cookie and a free admin port, and waits for `/admin/ready`. If the
|
|
496
600
|
daemon exits, the failure carries its stderr (database URL redacted).
|
|
497
601
|
- Seeds only synthetic org/app bindings, then uses the actual admin CLI to
|
|
498
|
-
provision
|
|
602
|
+
provision three distinct, confirmed password-only principals, two of them with
|
|
603
|
+
the same email in different namespaces.
|
|
499
604
|
The `admin` fixture is a realm-isolation test, not real admin/MFA migration.
|
|
500
605
|
- Installs a fresh package in a temporary consumer bundle, then uses its real
|
|
501
606
|
Net::HTTP client for login, audience isolation, rotation, reuse detection,
|
|
@@ -511,9 +616,16 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
|
|
|
511
616
|
nothing, then confirms it by `POST` on the public listener and logs in with
|
|
512
617
|
`identity_verified` true, reads the event feed, and cancels a squatted
|
|
513
618
|
registration so the address can be registered again.
|
|
514
|
-
-
|
|
515
|
-
|
|
516
|
-
|
|
619
|
+
- Changes a registered account's password: a wrong current password and a weak
|
|
620
|
+
new one change nothing, the session that asked continues on the new
|
|
621
|
+
password, its other session and the old password stop working. Recovers a
|
|
622
|
+
registered account (refused weak password, one-time link, every session
|
|
623
|
+
ended, the new password logs in), an address that was never verified (the
|
|
624
|
+
recovery verifies it) and an imported bcrypt account.
|
|
625
|
+
- Checks the database for the imports, the verified registrations, the bcrypt
|
|
626
|
+
upgrades, the rotations and no live sessions, and the daemon's
|
|
627
|
+
`/admin/metrics` for the exact login, registration, verification, recovery
|
|
628
|
+
and password change outcomes.
|
|
517
629
|
- Bounds subprocesses and kills/reaps its owned process groups, drops its schema
|
|
518
630
|
and removes temporary files on completion or handled failure/interruption.
|
|
519
631
|
The caller remains responsible for stopping the supplied PostgreSQL server.
|
data/lib/strongroom/client.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Strongroom
|
|
|
11
11
|
CURSOR = /\A[A-Za-z0-9_-]{34}\z/
|
|
12
12
|
IDENTITY_STATES = %w[Unverified Verified Disabled].freeze
|
|
13
13
|
EVENT_TYPES = %w[Provision Register RegistrationCancel VerificationIssue IdentityVerify PasswordHashUpgrade
|
|
14
|
-
PasswordReplace Revoke].freeze
|
|
14
|
+
PasswordReplace Revoke RecoveryIssue RecoveryComplete PasswordChange].freeze
|
|
15
15
|
LOGIN = {"session_id" => :uuid, "user_id" => :uuid, "expires_at" => :timestamp,
|
|
16
16
|
"identity_verified" => :boolean}.freeze
|
|
17
17
|
METADATA = LOGIN.merge("org_id" => :uuid, "app_id" => :uuid).freeze
|
|
@@ -89,6 +89,32 @@ module Strongroom
|
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# Changes the password of the identity `token`'s session was issued for and
|
|
93
|
+
# returns that session rotated onto it, confirmed through `/me` as rotation
|
|
94
|
+
# is. Every other session of the user in the org ends. A wrong
|
|
95
|
+
# `current_password` raises `Unauthorized` and leaves the session as it was;
|
|
96
|
+
# an unverified identity raises `IdentityUnverified`, a session issued with
|
|
97
|
+
# a password the identity no longer logs in with `CredentialChanged`. If
|
|
98
|
+
# the change went through but `/me` does not confirm the rotated session,
|
|
99
|
+
# that session is logged out before `ProtocolError` is raised: the new
|
|
100
|
+
# password is set and the user signs in with it.
|
|
101
|
+
def change_password(token:, current_password:, new_password:)
|
|
102
|
+
unless [current_password, new_password].all? { |value| value.is_a?(String) }
|
|
103
|
+
raise InvalidRequest, "passwords must be strings"
|
|
104
|
+
end
|
|
105
|
+
body = JSON.generate(current_password: current_password, new_password: new_password)
|
|
106
|
+
raise InvalidRequest, "password change request too large" if body.bytesize > 16_384
|
|
107
|
+
response = request(:post, "auth/password", token: Token.coerce(token), body: body)
|
|
108
|
+
issue(response) do |successor|
|
|
109
|
+
declared = metadata(response)
|
|
110
|
+
current = session(token: successor)
|
|
111
|
+
raise ProtocolError, "inconsistent password change response" unless same_session?(declared, current)
|
|
112
|
+
current
|
|
113
|
+
end
|
|
114
|
+
rescue JSON::GeneratorError
|
|
115
|
+
raise InvalidRequest, "invalid password change payload", cause: nil
|
|
116
|
+
end
|
|
117
|
+
|
|
92
118
|
def logout(token:)
|
|
93
119
|
request(:delete, "auth/logout", token: Token.coerce(token), status: 204)
|
|
94
120
|
nil
|
|
@@ -151,6 +177,24 @@ module Strongroom
|
|
|
151
177
|
Verification.new(token: VerificationToken.new(values["token"]), expires_at: values["expires_at"])
|
|
152
178
|
end
|
|
153
179
|
|
|
180
|
+
# A recovery link for the account holding the email address `identity` in
|
|
181
|
+
# this namespace; earlier recovery links of that address stop working. The
|
|
182
|
+
# browser completes it: POST {"token", "password"} to
|
|
183
|
+
# `/accounts/<namespace>/recovery` on the kernel's public listener. Raises
|
|
184
|
+
# `NotFound` when no usable account holds the address: answer your users
|
|
185
|
+
# the same way either way.
|
|
186
|
+
def issue_recovery(identity:)
|
|
187
|
+
raise InvalidRequest, "identity must be a string" unless identity.is_a?(String)
|
|
188
|
+
body = JSON.generate(identity_kind: "email", identity: identity)
|
|
189
|
+
raise InvalidRequest, "recovery request too large" if body.bytesize > 16_384
|
|
190
|
+
response = service_request(:post, "recoveries", body: body, statuses: [201])
|
|
191
|
+
values = object(response, {"token" => :string, "expires_at" => :timestamp, "user_id" => :uuid})
|
|
192
|
+
Recovery.new(token: RecoveryToken.new(values["token"]), expires_at: values["expires_at"],
|
|
193
|
+
user_id: values["user_id"].freeze)
|
|
194
|
+
rescue JSON::GeneratorError
|
|
195
|
+
raise InvalidRequest, "invalid recovery payload", cause: nil
|
|
196
|
+
end
|
|
197
|
+
|
|
154
198
|
# One page of the namespace's event feed, in commit-safe order.
|
|
155
199
|
def events(after: nil, limit: 100)
|
|
156
200
|
unless (after.nil? || (after.is_a?(String) && after.match?(CURSOR))) &&
|
|
@@ -244,6 +288,8 @@ module Strongroom
|
|
|
244
288
|
case response.status
|
|
245
289
|
when 401 then raise Unauthorized, "authentication failed"
|
|
246
290
|
when 403 then raise ProtocolError, "authentication request refused by service policy"
|
|
291
|
+
when 409 then conflict!(response)
|
|
292
|
+
when 422 then invalid_password!(response)
|
|
247
293
|
when 429 then raise RateLimited.new(retry_after: retry_after(response))
|
|
248
294
|
when 503 then raise Busy.new(retry_after: retry_after(response))
|
|
249
295
|
when 500..599 then raise Unavailable, "authentication service unavailable"
|
|
@@ -273,16 +319,19 @@ module Strongroom
|
|
|
273
319
|
when 401, 403 then raise ProtocolError, "service key or caller refused by the kernel"
|
|
274
320
|
when 404 then raise NotFound, "not found in this namespace"
|
|
275
321
|
when 409 then conflict!(response)
|
|
276
|
-
when 422
|
|
277
|
-
reason = service_error(response)&.fetch("reason", nil)
|
|
278
|
-
raise InvalidPassword.new(reason: reason) if InvalidPassword::REASONS.include?(reason)
|
|
279
|
-
raise ProtocolError, "invalid service response"
|
|
322
|
+
when 422 then invalid_password!(response)
|
|
280
323
|
when 429 then raise RateLimited.new(retry_after: retry_after(response))
|
|
281
324
|
when 503 then raise Busy.new(retry_after: retry_after(response))
|
|
282
325
|
when 500..599 then raise Unavailable, "authentication service unavailable"
|
|
283
326
|
end
|
|
284
327
|
end
|
|
285
328
|
|
|
329
|
+
def invalid_password!(response)
|
|
330
|
+
reason = service_error(response)&.fetch("reason", nil)
|
|
331
|
+
raise InvalidPassword.new(reason: reason) if InvalidPassword::REASONS.include?(reason)
|
|
332
|
+
raise ProtocolError, "invalid authentication response"
|
|
333
|
+
end
|
|
334
|
+
|
|
286
335
|
def conflict!(response)
|
|
287
336
|
case service_error(response)&.fetch("error", nil)
|
|
288
337
|
when "identity_taken" then raise IdentityTaken, "identity already belongs to another account"
|
|
@@ -290,6 +339,8 @@ module Strongroom
|
|
|
290
339
|
when "already_verified" then raise AlreadyVerified, "identity is already verified"
|
|
291
340
|
when "identity_disabled" then raise IdentityDisabled, "identity is disabled"
|
|
292
341
|
when "cursor_ahead" then raise CursorAhead, "feed cursor is ahead of the kernel's database"
|
|
342
|
+
when "identity_unverified" then raise IdentityUnverified, "identity is not verified"
|
|
343
|
+
when "credential_changed" then raise CredentialChanged, "session predates the identity's password"
|
|
293
344
|
else raise ProtocolError, "invalid service response"
|
|
294
345
|
end
|
|
295
346
|
end
|
data/lib/strongroom/errors.rb
CHANGED
|
@@ -16,6 +16,14 @@ module Strongroom
|
|
|
16
16
|
class AlreadyVerified < Conflict; end
|
|
17
17
|
class IdentityDisabled < Conflict; end
|
|
18
18
|
|
|
19
|
+
# A password change for an unverified identity: nobody has proven they own
|
|
20
|
+
# the address, so knowing its password proves nothing. Nothing changed.
|
|
21
|
+
class IdentityUnverified < Conflict; end
|
|
22
|
+
|
|
23
|
+
# A password change from a session issued with a password the identity no
|
|
24
|
+
# longer logs in with: sign in again, then change it. Nothing changed.
|
|
25
|
+
class CredentialChanged < Conflict; end
|
|
26
|
+
|
|
19
27
|
# The feed cursor names a point the kernel's database has not reached (it was
|
|
20
28
|
# restored, failed over or copied): drop the cursor and read from the start.
|
|
21
29
|
class CursorAhead < Conflict; end
|
|
@@ -48,6 +48,18 @@ module Strongroom
|
|
|
48
48
|
client.issue_verification(user_id: user_id)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# A recovery link for the account holding `identity`; needs only the
|
|
52
|
+
# service key, so imported and registered accounts alike can recover.
|
|
53
|
+
def issue_recovery(identity:)
|
|
54
|
+
client.issue_recovery(identity: identity)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Changes the password of `token`'s session and returns it rotated onto
|
|
58
|
+
# the new one; prefer the controller helper, which also replaces the cookie.
|
|
59
|
+
def change_password(token:, current_password:, new_password:)
|
|
60
|
+
client.change_password(token: token, current_password: current_password, new_password: new_password)
|
|
61
|
+
end
|
|
62
|
+
|
|
51
63
|
# The namespace's event feed; needs only the service key.
|
|
52
64
|
def events(after: nil, limit: 100)
|
|
53
65
|
client.events(after: after, limit: limit)
|
|
@@ -50,6 +50,22 @@ module Strongroom
|
|
|
50
50
|
strongroom_resolver.forget(namespace)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# Changes the password of the request's session and replaces the cookie
|
|
54
|
+
# with the session rotated onto the new password. Every other session of
|
|
55
|
+
# the user in the org ends; a wrong current password raises
|
|
56
|
+
# `Unauthorized`, an unverified identity `IdentityUnverified`, and
|
|
57
|
+
# neither changes anything.
|
|
58
|
+
def strongroom_change_password(namespace:, current_password:, new_password:)
|
|
59
|
+
strongroom_verify_mutation!(:post)
|
|
60
|
+
account = strongroom_registry.fetch(namespace)
|
|
61
|
+
token = strongroom_resolver.credential(namespace) or raise Unauthorized, "authentication failed"
|
|
62
|
+
changed = account.change_password(token: token, current_password: current_password,
|
|
63
|
+
new_password: new_password)
|
|
64
|
+
strongroom_publish(account, changed)
|
|
65
|
+
ensure
|
|
66
|
+
strongroom_resolver.forget(namespace)
|
|
67
|
+
end
|
|
68
|
+
|
|
53
69
|
def strongroom_logout(namespace:)
|
|
54
70
|
strongroom_verify_mutation!(:delete)
|
|
55
71
|
account = strongroom_registry.fetch(namespace)
|
|
@@ -17,6 +17,8 @@ module Strongroom
|
|
|
17
17
|
"Strongroom::AlreadyVerified" => :conflict,
|
|
18
18
|
"Strongroom::IdentityDisabled" => :conflict,
|
|
19
19
|
"Strongroom::CursorAhead" => :conflict,
|
|
20
|
+
"Strongroom::IdentityUnverified" => :conflict,
|
|
21
|
+
"Strongroom::CredentialChanged" => :conflict,
|
|
20
22
|
"Strongroom::RateLimited" => :too_many_requests,
|
|
21
23
|
"Strongroom::Busy" => :service_unavailable,
|
|
22
24
|
"Strongroom::ProtocolError" => :service_unavailable,
|
|
@@ -25,7 +27,7 @@ module Strongroom
|
|
|
25
27
|
|
|
26
28
|
initializer "strongroom.filter_credentials" do |app|
|
|
27
29
|
app.config.filter_parameters |= %i[password password_confirmation identity authorization cookie strongroom_token
|
|
28
|
-
service_key verification_token]
|
|
30
|
+
service_key verification_token recovery_token]
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
config.after_initialize do |app|
|
data/lib/strongroom/session.rb
CHANGED
|
@@ -37,10 +37,10 @@ module Strongroom
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
# A
|
|
41
|
-
# own page,
|
|
40
|
+
# A link's secret and its expiry. Put the token in the URL fragment of a link
|
|
41
|
+
# to your own page, which posts it to the kernel; nothing here is safe to log
|
|
42
42
|
# or serialize.
|
|
43
|
-
class
|
|
43
|
+
class Link
|
|
44
44
|
attr_reader :token, :expires_at
|
|
45
45
|
|
|
46
46
|
def initialize(token:, expires_at:)
|
|
@@ -48,16 +48,33 @@ module Strongroom
|
|
|
48
48
|
freeze
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def inspect = "
|
|
51
|
+
def inspect = "#<#{self.class.name} expires_at=#{expires_at.iso8601} token=[FILTERED]>"
|
|
52
52
|
alias to_s inspect
|
|
53
53
|
def pretty_print(printer) = printer.text(inspect)
|
|
54
54
|
|
|
55
55
|
def as_json(*)
|
|
56
|
-
raise TypeError, "
|
|
56
|
+
raise TypeError, "links must not be serialized"
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def marshal_dump
|
|
60
|
-
raise TypeError, "
|
|
60
|
+
raise TypeError, "links must not be serialized"
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
|
+
|
|
64
|
+
# A verification link: its page's button confirms the address.
|
|
65
|
+
class Verification < Link; end
|
|
66
|
+
|
|
67
|
+
# A recovery link for `user_id`: its page asks for a new password and posts
|
|
68
|
+
# both to the kernel.
|
|
69
|
+
class Recovery < Link
|
|
70
|
+
attr_reader :user_id
|
|
71
|
+
|
|
72
|
+
def initialize(token:, expires_at:, user_id:)
|
|
73
|
+
@user_id = user_id
|
|
74
|
+
super(token: token, expires_at: expires_at)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inspect = "#<Strongroom::Recovery user_id=#{user_id} expires_at=#{expires_at.iso8601} token=[FILTERED]>"
|
|
78
|
+
alias to_s inspect
|
|
79
|
+
end
|
|
63
80
|
end
|
data/lib/strongroom/token.rb
CHANGED
|
@@ -75,6 +75,12 @@ module Strongroom
|
|
|
75
75
|
def self.refusal = [ProtocolError, "invalid verification token"]
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# A recovery link's secret, `srr1_...`: mail it, never log it.
|
|
79
|
+
class RecoveryToken < Credential
|
|
80
|
+
def self.prefix = "srr1_"
|
|
81
|
+
def self.refusal = [ProtocolError, "invalid recovery token"]
|
|
82
|
+
end
|
|
83
|
+
|
|
78
84
|
# A namespace's service key, `srk1_...`, sent only to the service listener.
|
|
79
85
|
class ServiceKey < Credential
|
|
80
86
|
def self.prefix = "srk1_"
|