strongroom-rails 0.2.0 → 0.4.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 +14 -0
- data/README.md +164 -13
- data/lib/strongroom/client.rb +142 -5
- data/lib/strongroom/errors.rb +16 -0
- data/lib/strongroom/rails/account.rb +27 -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 +37 -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: 5561c0010955953673794759017eb0f3683d838d60dd70ed09ae88f185954717
|
|
4
|
+
data.tar.gz: 0d620abade30695c4779dc8e123c9444b4ff723904e3bc19bd903985f1f47d53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aa14aa3c3c14336f886876097bb85afb3c56332583567eadbf88b3839abbf518e8af4841a80023d0085ad5e7df6b58a9dff0962c875b0a028751dd4ff6c42046
|
|
7
|
+
data.tar.gz: 333bce6b18f73ec77edbe98645668cd40bcbcaa3443eff5e501706c4e915aee4c18c1808298022222622bf4708cf80f21dd4f6e0923aaa0fc03aded6e51dac61
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.3.0...strongroom-rails-v0.4.0) (2026-10-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add account suspension and session revocation, service imports with conditional password replacement and in-daemon pruning; recover only verified identities ([be52bd1](https://github.com/seuros/strongroom/commit/be52bd1654b9f104aa8e82eb2129d0751a62f3d1))
|
|
9
|
+
|
|
10
|
+
## [0.3.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.2.0...strongroom-rails-v0.3.0) (2026-10-25)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* add password recovery links and self-service password change ([b4f9160](https://github.com/seuros/strongroom/commit/b4f9160b352fb0122086c57a56c701ddb29b461a))
|
|
16
|
+
|
|
3
17
|
## [0.2.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.1.0...strongroom-rails/v0.2.0) (2026-10-25)
|
|
4
18
|
|
|
5
19
|
|
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,10 @@ 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`, `events`, `suspend`,
|
|
111
|
+
`reinstate` and `revoke_sessions` need only the service key, and
|
|
112
|
+
`import_account` and `replace_imported_password` a namespace with
|
|
113
|
+
`import = true`. The gem still installs no route.
|
|
109
114
|
|
|
110
115
|
Controllers opt in explicitly and keep their own frontend response contract:
|
|
111
116
|
|
|
@@ -365,7 +370,9 @@ verifies the address.
|
|
|
365
370
|
- `events(after: nil, limit: 100)` returns a `Strongroom::EventPage` of
|
|
366
371
|
`Strongroom::Event`s (`Provision`, `Register`, `RegistrationCancel`,
|
|
367
372
|
`VerificationIssue`, `IdentityVerify`, `PasswordHashUpgrade`,
|
|
368
|
-
`PasswordReplace`, `Revoke
|
|
373
|
+
`PasswordReplace`, `Revoke`, `RecoveryIssue`, `RecoveryComplete`,
|
|
374
|
+
`PasswordChange`, `AccountSuspend`, `AccountReinstate`); keep `page.next`
|
|
375
|
+
and pass it as `after:`.
|
|
369
376
|
`next` is `nil` for an empty page: ask again later with the cursor you have.
|
|
370
377
|
`page.oldest_at` is the oldest event the kernel still holds; if your last
|
|
371
378
|
event is older, retention pruned events you never read. `Strongroom::CursorAhead`
|
|
@@ -379,6 +386,141 @@ Service-key refusals raise `Strongroom::ProtocolError` (a deployment error, not
|
|
|
379
386
|
a user's), rate limits `Strongroom::RateLimited`/`Busy` with `retry_after`. The
|
|
380
387
|
Railtie maps `Conflict`s to 409, `InvalidPassword` to 422 and `NotFound` to 404.
|
|
381
388
|
|
|
389
|
+
## Account control and imports
|
|
390
|
+
|
|
391
|
+
On the client of any keyed namespace:
|
|
392
|
+
|
|
393
|
+
- `suspend(user_id:)` suspends the user's account in the namespace's org: every
|
|
394
|
+
session it holds there ends, its verification and recovery links stop
|
|
395
|
+
working, and nothing authenticates until `reinstate(user_id:)`, which leaves
|
|
396
|
+
those sessions and links ended. Both return a `Strongroom::AccountChange`
|
|
397
|
+
(`state`, `changed`: false for a repeat, `revoked_sessions`).
|
|
398
|
+
- `revoke_sessions(user_id:)` ends every live session of the user in the org
|
|
399
|
+
("sign out everywhere") and returns how many ended.
|
|
400
|
+
- All three raise `Strongroom::NotFound` for a user outside the namespace,
|
|
401
|
+
`Strongroom::AccountDisabled` (suspension and reinstatement) for a disabled
|
|
402
|
+
account, and `Strongroom::Busy` while one of the user's sessions is being
|
|
403
|
+
rotated or logged out: retry, nothing changed.
|
|
404
|
+
|
|
405
|
+
With `import = true` on the namespace:
|
|
406
|
+
|
|
407
|
+
- `import_account(source:, external_id:, identity:, password_hash:,
|
|
408
|
+
account_state:, grant_state:, identity_state:, credential_state:,
|
|
409
|
+
mfa_required:)` imports an account with a pre-hashed password, as
|
|
410
|
+
`strongroom-admin provision` does, and returns a `Strongroom::Import`
|
|
411
|
+
(`user_id`, `identity_id`, `credential_id`, `created`: false for a replay).
|
|
412
|
+
Every state is explicit. It raises `Strongroom::BindingConflict`,
|
|
413
|
+
`Strongroom::IdentityTaken` or `Strongroom::InvalidRequest`.
|
|
414
|
+
- `replace_imported_password(source:, external_id:, expected_password_hash:,
|
|
415
|
+
password_hash:)` replaces that account's password only while it still holds
|
|
416
|
+
`expected_password_hash`, and returns a `Strongroom::PasswordReplacement`
|
|
417
|
+
(`status` `"replaced"` or `"unchanged"`). Once the kernel owns the password
|
|
418
|
+
(a login upgraded the hash, a recovery or password change replaced it), it
|
|
419
|
+
raises `Strongroom::CredentialChanged` and changes nothing: stop sending
|
|
420
|
+
hashes for that account.
|
|
421
|
+
|
|
422
|
+
## Password change and recovery
|
|
423
|
+
|
|
424
|
+
### Changing a password
|
|
425
|
+
|
|
426
|
+
A signed-in user changes their password through your controller; the helper
|
|
427
|
+
verifies mutation intent like the others, sends the request's session to the
|
|
428
|
+
kernel and replaces the encrypted cookie with the session the kernel rotated
|
|
429
|
+
onto the new password. The user stays signed in; every other session of theirs
|
|
430
|
+
in the org ends, in every app.
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
def update # POST; verified CSRF required.
|
|
434
|
+
user = strongroom_change_password(namespace: :users,
|
|
435
|
+
current_password: params.require(:current_password), new_password: params.require(:new_password))
|
|
436
|
+
render json: {id: user.id}
|
|
437
|
+
end
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
A wrong current password raises `Strongroom::Unauthorized` (401) and changes
|
|
441
|
+
nothing: the session keeps working. A refused new password raises
|
|
442
|
+
`Strongroom::InvalidPassword` with `reason` `too_short`, `too_long` or
|
|
443
|
+
`invalid` (422), before the current password is even checked. An unverified
|
|
444
|
+
identity raises `Strongroom::IdentityUnverified` (409): nobody has proven they
|
|
445
|
+
own the address, so its password proves nothing; have the user confirm it
|
|
446
|
+
first. A session issued with a password the identity no longer logs in with
|
|
447
|
+
raises `Strongroom::CredentialChanged` (409): sign in again. The kernel's
|
|
448
|
+
per-user budget raises `Strongroom::RateLimited` (429). Add your own per-IP
|
|
449
|
+
`rate_limit` too, as for logins. Recovery links the user asked for keep
|
|
450
|
+
working after a change. `client.change_password(token:, current_password:,
|
|
451
|
+
new_password:)` (or `account.change_password`) is the same call without the
|
|
452
|
+
cookie: it returns the rotated `Strongroom::IssuedSession`, confirmed through
|
|
453
|
+
`/me` like a rotation. If that confirmation fails after the kernel made the
|
|
454
|
+
change, the rotated session is logged out before `ProtocolError` is raised:
|
|
455
|
+
the new password is set, and the user signs in with it.
|
|
456
|
+
|
|
457
|
+
### Recovering a password
|
|
458
|
+
|
|
459
|
+
Any namespace whose client has `service_endpoint:` and `service_key:` can
|
|
460
|
+
issue recovery links, for imported and registered accounts alike; `register:`
|
|
461
|
+
is not needed. Ask for a link where the mail is built, never in the request
|
|
462
|
+
that asked for it, and never hand the token to a job:
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
class RecoveriesController < ApplicationController
|
|
466
|
+
rate_limit to: 5, within: 10.minutes, by: -> { request.remote_ip }, only: :create
|
|
467
|
+
|
|
468
|
+
def create # POST; the same answer whether or not the address has an account.
|
|
469
|
+
RecoveryMailer.with(email: params.require(:email)).reset.deliver_later # the address, never a token
|
|
470
|
+
head :accepted
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
class RecoveryMailer < ApplicationMailer
|
|
475
|
+
def reset
|
|
476
|
+
users = Rails.configuration.x.strongroom_accounts.fetch(:users)
|
|
477
|
+
link = users.issue_recovery(identity: params.fetch(:email))
|
|
478
|
+
# The fragment of a link to your own page: browsers never send it, so
|
|
479
|
+
# neither your logs nor a Referer header see the token.
|
|
480
|
+
@url = "https://app.example.com/password/reset##{link.token.reveal}"
|
|
481
|
+
mail(to: params.fetch(:email), subject: "Choose a new password")
|
|
482
|
+
rescue Strongroom::NotFound
|
|
483
|
+
# No usable account holds the address: without `mail`, nothing is sent.
|
|
484
|
+
rescue Strongroom::IdentityUnverified
|
|
485
|
+
# Registered, never verified: cancel that registration instead (see below).
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`issue_recovery(identity:)` returns a `Strongroom::Recovery` (`token`, a
|
|
491
|
+
`Strongroom::RecoveryToken` redacted everywhere and never a session credential;
|
|
492
|
+
`expires_at`, by default 30 minutes away; `user_id`). Earlier links of that
|
|
493
|
+
address stop working. It raises `Strongroom::NotFound` when no usable account
|
|
494
|
+
holds the address in the namespace, and `Strongroom::IdentityUnverified` when
|
|
495
|
+
the address was registered but never verified: answer your users the same way
|
|
496
|
+
in every case, or the form tells anyone which addresses have accounts. If the token must
|
|
497
|
+
travel through a job after all, only in an argument your own
|
|
498
|
+
`ActiveJob::Serializers::ObjectSerializer` encrypts.
|
|
499
|
+
|
|
500
|
+
The page at `/password/reset` asks for the new password and posts it with the
|
|
501
|
+
fragment to the kernel's public listener (list the page's origin in its
|
|
502
|
+
`[browser] allowed_origins`; the gem does not proxy it):
|
|
503
|
+
|
|
504
|
+
```js
|
|
505
|
+
const reply = await fetch(`${STRONGROOM_ORIGIN}/accounts/users/recovery`, {
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: { "Content-Type": "application/json", "X-Strongroom-Request": "1" },
|
|
508
|
+
body: JSON.stringify({ token: location.hash.slice(1), password: form.password.value }),
|
|
509
|
+
}); // 200 {"status": "recovered"}; 422 {"error": "invalid_password", "reason": ...} keeps the link
|
|
510
|
+
// usable; 400 {"error": "invalid_token", "reason": "invalid" | "expired"}; 503: retry
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
A completed recovery sets the new password and ends every session of the user
|
|
514
|
+
in the org (in every app) and their other recovery links. Send the person to
|
|
515
|
+
your sign-in page afterwards. A second use of the link answers
|
|
516
|
+
`invalid_token`. Links belong to the address across every app of the org.
|
|
517
|
+
|
|
518
|
+
Only verified addresses recover: anybody can register an address, and its
|
|
519
|
+
owner would inherit the account whoever registered it prepared (your records
|
|
520
|
+
for that `external_id`). On `Strongroom::IdentityUnverified`, cancel that
|
|
521
|
+
registration and let the owner register afresh; do not send a verification
|
|
522
|
+
link instead, which would let the owner verify that account and recover it.
|
|
523
|
+
|
|
382
524
|
## Running the daemon
|
|
383
525
|
|
|
384
526
|
This gem is only the client: it ships no binary and needs none to install. The
|
|
@@ -406,8 +548,9 @@ The two gems are versioned separately.
|
|
|
406
548
|
|
|
407
549
|
- Keep direct backend access restricted and TLS/service networking trusted.
|
|
408
550
|
Local eligibility checks do not synchronize kernel account state or revoke
|
|
409
|
-
all remote sessions.
|
|
410
|
-
|
|
551
|
+
all remote sessions. Disablement and MFA enablement still need authoritative
|
|
552
|
+
credential/state updates and invalidation; password changes and recoveries
|
|
553
|
+
through the kernel revoke its sessions themselves.
|
|
411
554
|
- Imports remain an existing, confirmed, password-only cohort; registration
|
|
412
555
|
creates unverified accounts the kernel refuses until they are verified (or
|
|
413
556
|
until a configured grace runs out). Real admins and MFA-required accounts
|
|
@@ -422,8 +565,8 @@ The two gems are versioned separately.
|
|
|
422
565
|
Ruby/parser/network/framework copies cannot be reliably erased. Do not log
|
|
423
566
|
headers, cookie jars, passwords, transport bodies or explicit token strings.
|
|
424
567
|
The Railtie adds parameter filters; separately audit proxy/middleware/APM logs.
|
|
425
|
-
- Mail delivery,
|
|
426
|
-
|
|
568
|
+
- Mail delivery, bulk mapping orchestration, remote admin operations,
|
|
569
|
+
deployment SLOs and production approval are not shipped.
|
|
427
570
|
|
|
428
571
|
## Local checks (no installs, network or database required)
|
|
429
572
|
|
|
@@ -495,7 +638,8 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
|
|
|
495
638
|
`__Host-` cookie and a free admin port, and waits for `/admin/ready`. If the
|
|
496
639
|
daemon exits, the failure carries its stderr (database URL redacted).
|
|
497
640
|
- Seeds only synthetic org/app bindings, then uses the actual admin CLI to
|
|
498
|
-
provision
|
|
641
|
+
provision three distinct, confirmed password-only principals, two of them with
|
|
642
|
+
the same email in different namespaces.
|
|
499
643
|
The `admin` fixture is a realm-isolation test, not real admin/MFA migration.
|
|
500
644
|
- Installs a fresh package in a temporary consumer bundle, then uses its real
|
|
501
645
|
Net::HTTP client for login, audience isolation, rotation, reuse detection,
|
|
@@ -511,9 +655,16 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
|
|
|
511
655
|
nothing, then confirms it by `POST` on the public listener and logs in with
|
|
512
656
|
`identity_verified` true, reads the event feed, and cancels a squatted
|
|
513
657
|
registration so the address can be registered again.
|
|
514
|
-
-
|
|
515
|
-
|
|
516
|
-
|
|
658
|
+
- Changes a registered account's password: a wrong current password and a weak
|
|
659
|
+
new one change nothing, the session that asked continues on the new
|
|
660
|
+
password, its other session and the old password stop working. Recovers a
|
|
661
|
+
registered account (refused weak password, one-time link, every session
|
|
662
|
+
ended, the new password logs in), an address that was never verified (the
|
|
663
|
+
recovery verifies it) and an imported bcrypt account.
|
|
664
|
+
- Checks the database for the imports, the verified registrations, the bcrypt
|
|
665
|
+
upgrades, the rotations and no live sessions, and the daemon's
|
|
666
|
+
`/admin/metrics` for the exact login, registration, verification, recovery
|
|
667
|
+
and password change outcomes.
|
|
517
668
|
- Bounds subprocesses and kills/reaps its owned process groups, drops its schema
|
|
518
669
|
and removes temporary files on completion or handled failure/interruption.
|
|
519
670
|
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 AccountSuspend AccountReinstate].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,97 @@ 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, and
|
|
185
|
+
# `IdentityUnverified` when the address was registered but never verified:
|
|
186
|
+
# whoever registered it may not own it, so cancel that registration and let
|
|
187
|
+
# the owner register afresh rather than recover it. Answer your users the
|
|
188
|
+
# same way in every case.
|
|
189
|
+
def issue_recovery(identity:)
|
|
190
|
+
raise InvalidRequest, "identity must be a string" unless identity.is_a?(String)
|
|
191
|
+
body = JSON.generate(identity_kind: "email", identity: identity)
|
|
192
|
+
raise InvalidRequest, "recovery request too large" if body.bytesize > 16_384
|
|
193
|
+
response = service_request(:post, "recoveries", body: body, statuses: [201])
|
|
194
|
+
values = object(response, {"token" => :string, "expires_at" => :timestamp, "user_id" => :uuid})
|
|
195
|
+
Recovery.new(token: RecoveryToken.new(values["token"]), expires_at: values["expires_at"],
|
|
196
|
+
user_id: values["user_id"].freeze)
|
|
197
|
+
rescue JSON::GeneratorError
|
|
198
|
+
raise InvalidRequest, "invalid recovery payload", cause: nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Suspends the account of `user_id` in this namespace's org: every session
|
|
202
|
+
# it holds there ends, its verification and recovery links stop working,
|
|
203
|
+
# and it authenticates nowhere in the org until `reinstate`. Raises
|
|
204
|
+
# `NotFound`, `AccountDisabled`, or `Busy` while one of its sessions is
|
|
205
|
+
# being rotated or logged out (retry).
|
|
206
|
+
def suspend(user_id:)
|
|
207
|
+
account_change("suspensions", user_id, "Suspended")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Lifts a suspension. The sessions and links it ended stay ended.
|
|
211
|
+
def reinstate(user_id:)
|
|
212
|
+
account_change("reinstatements", user_id, "Active")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Ends every live session of `user_id` in this namespace's org, whatever
|
|
216
|
+
# its account's state, and returns how many ended. `Busy` as for `suspend`.
|
|
217
|
+
def revoke_sessions(user_id:)
|
|
218
|
+
raise InvalidRequest, "user_id must be a canonical UUID" unless canonical_uuid?(user_id)
|
|
219
|
+
response = service_request(:post, "revocations", body: JSON.generate(user_id: user_id), statuses: [200])
|
|
220
|
+
object(response, {"revoked_sessions" => :count})["revoked_sessions"]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Imports one account with a pre-hashed password (bcrypt or Argon2id), keyed
|
|
224
|
+
# by your `source` and `external_id`, as `strongroom-admin provision` does;
|
|
225
|
+
# the namespace needs `import = true`. Every state is explicit: the kernel
|
|
226
|
+
# refuses MFA and unverified identities. Replaying the same import returns
|
|
227
|
+
# its receipt (`created: false`); a changed one raises `BindingConflict`,
|
|
228
|
+
# an address another account holds `IdentityTaken`.
|
|
229
|
+
def import_account(source:, external_id:, identity:, password_hash:, account_state:, grant_state:,
|
|
230
|
+
identity_state:, credential_state:, mfa_required:, identity_kind: "email")
|
|
231
|
+
fields = {source: source, external_id: external_id, identity_kind: identity_kind, identity: identity,
|
|
232
|
+
account_state: account_state, grant_state: grant_state, identity_state: identity_state,
|
|
233
|
+
credential_state: credential_state, password_hash: password_hash}
|
|
234
|
+
unless fields.values.all?(String) && [true, false].include?(mfa_required)
|
|
235
|
+
raise InvalidRequest, "import fields must be strings and mfa_required a boolean"
|
|
236
|
+
end
|
|
237
|
+
body = JSON.generate(fields.merge(mfa_required: mfa_required))
|
|
238
|
+
raise InvalidRequest, "import request too large" if body.bytesize > 16_384
|
|
239
|
+
response = service_request(:post, "imports", body: body, statuses: [200, 201])
|
|
240
|
+
values = object(response, {"user_id" => :uuid, "identity_id" => :uuid, "credential_id" => :uuid})
|
|
241
|
+
Import.new(user_id: values["user_id"], identity_id: values["identity_id"],
|
|
242
|
+
credential_id: values["credential_id"], created: response.status == 201)
|
|
243
|
+
rescue JSON::GeneratorError
|
|
244
|
+
raise InvalidRequest, "invalid import payload", cause: nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Replaces the password of the account an import bound to `source` and
|
|
248
|
+
# `external_id` with `password_hash`, but only while its active credential
|
|
249
|
+
# still holds `expected_password_hash`, the hash you imported or last
|
|
250
|
+
# replaced. Once the kernel owns the password (a login upgraded its hash, a
|
|
251
|
+
# recovery or password change replaced it) this raises `CredentialChanged`
|
|
252
|
+
# and changes nothing. Its sessions end with the old credential.
|
|
253
|
+
def replace_imported_password(source:, external_id:, expected_password_hash:, password_hash:)
|
|
254
|
+
fields = {source: source, external_id: external_id, expected_password_hash: expected_password_hash,
|
|
255
|
+
password_hash: password_hash}
|
|
256
|
+
raise InvalidRequest, "replacement fields must be strings" unless fields.values.all?(String)
|
|
257
|
+
body = JSON.generate(fields)
|
|
258
|
+
raise InvalidRequest, "replacement request too large" if body.bytesize > 16_384
|
|
259
|
+
response = service_request(:post, "imports/password", body: body, statuses: [200])
|
|
260
|
+
values = object(response, {"status" => :string, "user_id" => :uuid, "identity_id" => :uuid,
|
|
261
|
+
"credential_id" => :uuid, "revoked_credential_id" => :uuid_or_nil})
|
|
262
|
+
unless (values["status"] == "replaced") == !values["revoked_credential_id"].nil? &&
|
|
263
|
+
%w[replaced unchanged].include?(values["status"])
|
|
264
|
+
raise ProtocolError, "invalid service response"
|
|
265
|
+
end
|
|
266
|
+
PasswordReplacement.new(**values.transform_keys(&:to_sym))
|
|
267
|
+
rescue JSON::GeneratorError
|
|
268
|
+
raise InvalidRequest, "invalid replacement payload", cause: nil
|
|
269
|
+
end
|
|
270
|
+
|
|
154
271
|
# One page of the namespace's event feed, in commit-safe order.
|
|
155
272
|
def events(after: nil, limit: 100)
|
|
156
273
|
unless (after.nil? || (after.is_a?(String) && after.match?(CURSOR))) &&
|
|
@@ -170,6 +287,16 @@ module Strongroom
|
|
|
170
287
|
|
|
171
288
|
private
|
|
172
289
|
|
|
290
|
+
def account_change(suffix, user_id, state)
|
|
291
|
+
raise InvalidRequest, "user_id must be a canonical UUID" unless canonical_uuid?(user_id)
|
|
292
|
+
response = service_request(:post, suffix, body: JSON.generate(user_id: user_id), statuses: [200])
|
|
293
|
+
schema = {"state" => :string, "changed" => :boolean}
|
|
294
|
+
schema["revoked_sessions"] = :count if state == "Suspended"
|
|
295
|
+
values = object(response, schema)
|
|
296
|
+
raise ProtocolError, "invalid service response" unless values["state"] == state
|
|
297
|
+
AccountChange.new(state: state, changed: values["changed"], revoked_sessions: values.fetch("revoked_sessions", 0))
|
|
298
|
+
end
|
|
299
|
+
|
|
173
300
|
def configure_service(endpoint, key, transport)
|
|
174
301
|
if endpoint.nil? && key.nil? && transport.nil?
|
|
175
302
|
@service_endpoint = @service_key = @service_transport = nil
|
|
@@ -244,6 +371,8 @@ module Strongroom
|
|
|
244
371
|
case response.status
|
|
245
372
|
when 401 then raise Unauthorized, "authentication failed"
|
|
246
373
|
when 403 then raise ProtocolError, "authentication request refused by service policy"
|
|
374
|
+
when 409 then conflict!(response)
|
|
375
|
+
when 422 then invalid_password!(response)
|
|
247
376
|
when 429 then raise RateLimited.new(retry_after: retry_after(response))
|
|
248
377
|
when 503 then raise Busy.new(retry_after: retry_after(response))
|
|
249
378
|
when 500..599 then raise Unavailable, "authentication service unavailable"
|
|
@@ -273,16 +402,19 @@ module Strongroom
|
|
|
273
402
|
when 401, 403 then raise ProtocolError, "service key or caller refused by the kernel"
|
|
274
403
|
when 404 then raise NotFound, "not found in this namespace"
|
|
275
404
|
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"
|
|
405
|
+
when 422 then invalid_password!(response)
|
|
280
406
|
when 429 then raise RateLimited.new(retry_after: retry_after(response))
|
|
281
407
|
when 503 then raise Busy.new(retry_after: retry_after(response))
|
|
282
408
|
when 500..599 then raise Unavailable, "authentication service unavailable"
|
|
283
409
|
end
|
|
284
410
|
end
|
|
285
411
|
|
|
412
|
+
def invalid_password!(response)
|
|
413
|
+
reason = service_error(response)&.fetch("reason", nil)
|
|
414
|
+
raise InvalidPassword.new(reason: reason) if InvalidPassword::REASONS.include?(reason)
|
|
415
|
+
raise ProtocolError, "invalid authentication response"
|
|
416
|
+
end
|
|
417
|
+
|
|
286
418
|
def conflict!(response)
|
|
287
419
|
case service_error(response)&.fetch("error", nil)
|
|
288
420
|
when "identity_taken" then raise IdentityTaken, "identity already belongs to another account"
|
|
@@ -290,6 +422,10 @@ module Strongroom
|
|
|
290
422
|
when "already_verified" then raise AlreadyVerified, "identity is already verified"
|
|
291
423
|
when "identity_disabled" then raise IdentityDisabled, "identity is disabled"
|
|
292
424
|
when "cursor_ahead" then raise CursorAhead, "feed cursor is ahead of the kernel's database"
|
|
425
|
+
when "identity_unverified" then raise IdentityUnverified, "identity is not verified"
|
|
426
|
+
when "credential_changed" then raise CredentialChanged, "the identity's password credential changed"
|
|
427
|
+
when "account_disabled" then raise AccountDisabled, "account is disabled"
|
|
428
|
+
when "no_active_credential" then raise NoActiveCredential, "account has no active password"
|
|
293
429
|
else raise ProtocolError, "invalid service response"
|
|
294
430
|
end
|
|
295
431
|
end
|
|
@@ -347,6 +483,7 @@ module Strongroom
|
|
|
347
483
|
when :uuid then canonical_uuid?(value)
|
|
348
484
|
when :boolean then value == true || value == false
|
|
349
485
|
when :uuid_or_nil then value.nil? || canonical_uuid?(value)
|
|
486
|
+
when :count then value.is_a?(Integer) && value >= 0
|
|
350
487
|
else value.is_a?(String)
|
|
351
488
|
end
|
|
352
489
|
end
|
data/lib/strongroom/errors.rb
CHANGED
|
@@ -16,6 +16,22 @@ 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. For an imported
|
|
25
|
+
# password replacement: the kernel's credential no longer holds the expected
|
|
26
|
+
# hash, so the kernel owns that password now. Nothing changed.
|
|
27
|
+
class CredentialChanged < Conflict; end
|
|
28
|
+
|
|
29
|
+
# A suspension or reinstatement of an account that is disabled, which is final.
|
|
30
|
+
class AccountDisabled < Conflict; end
|
|
31
|
+
|
|
32
|
+
# An imported password replacement for an account without an active password.
|
|
33
|
+
class NoActiveCredential < Conflict; end
|
|
34
|
+
|
|
19
35
|
# The feed cursor names a point the kernel's database has not reached (it was
|
|
20
36
|
# restored, failed over or copied): drop the cursor and read from the start.
|
|
21
37
|
class CursorAhead < Conflict; end
|
|
@@ -48,11 +48,38 @@ 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)
|
|
54
66
|
end
|
|
55
67
|
|
|
68
|
+
# Suspends the user's account in the namespace's org, ending its sessions
|
|
69
|
+
# and links; needs only the service key.
|
|
70
|
+
def suspend(user_id:)
|
|
71
|
+
client.suspend(user_id: user_id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reinstate(user_id:)
|
|
75
|
+
client.reinstate(user_id: user_id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Ends every live session of the user in the namespace's org.
|
|
79
|
+
def revoke_sessions(user_id:)
|
|
80
|
+
client.revoke_sessions(user_id: user_id)
|
|
81
|
+
end
|
|
82
|
+
|
|
56
83
|
def resolve(session)
|
|
57
84
|
subject = client.subject(session)
|
|
58
85
|
record = @resolver.call(subject)
|
|
@@ -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
|
@@ -10,6 +10,20 @@ module Strongroom
|
|
|
10
10
|
# changed nothing; `identity_state` is the identity's current state.
|
|
11
11
|
Registration = Data.define(:user_id, :identity_id, :identity_state, :created)
|
|
12
12
|
|
|
13
|
+
# What a suspension or reinstatement did: the account's `state` now
|
|
14
|
+
# ("Suspended" or "Active"), whether this call `changed` it, and how many
|
|
15
|
+
# sessions a suspension ended (0 for a reinstatement or a repeat).
|
|
16
|
+
AccountChange = Data.define(:state, :changed, :revoked_sessions)
|
|
17
|
+
|
|
18
|
+
# An import's receipt. `created` is false for an exact replay, which changed
|
|
19
|
+
# nothing.
|
|
20
|
+
Import = Data.define(:user_id, :identity_id, :credential_id, :created)
|
|
21
|
+
|
|
22
|
+
# A replaced imported password. `status` is "replaced" (the credential
|
|
23
|
+
# `revoked_credential_id` was revoked for `credential_id`) or "unchanged"
|
|
24
|
+
# (the active credential held that hash already; `revoked_credential_id` nil).
|
|
25
|
+
PasswordReplacement = Data.define(:status, :user_id, :identity_id, :credential_id, :revoked_credential_id)
|
|
26
|
+
|
|
13
27
|
# One account event from the namespace's feed. `meta` is a frozen Hash of
|
|
14
28
|
# non-secret IDs (session, credential, token).
|
|
15
29
|
Event = Data.define(:id, :type, :user_id, :identity_id, :at, :meta)
|
|
@@ -37,10 +51,10 @@ module Strongroom
|
|
|
37
51
|
end
|
|
38
52
|
end
|
|
39
53
|
|
|
40
|
-
# A
|
|
41
|
-
# own page,
|
|
54
|
+
# A link's secret and its expiry. Put the token in the URL fragment of a link
|
|
55
|
+
# to your own page, which posts it to the kernel; nothing here is safe to log
|
|
42
56
|
# or serialize.
|
|
43
|
-
class
|
|
57
|
+
class Link
|
|
44
58
|
attr_reader :token, :expires_at
|
|
45
59
|
|
|
46
60
|
def initialize(token:, expires_at:)
|
|
@@ -48,16 +62,33 @@ module Strongroom
|
|
|
48
62
|
freeze
|
|
49
63
|
end
|
|
50
64
|
|
|
51
|
-
def inspect = "
|
|
65
|
+
def inspect = "#<#{self.class.name} expires_at=#{expires_at.iso8601} token=[FILTERED]>"
|
|
52
66
|
alias to_s inspect
|
|
53
67
|
def pretty_print(printer) = printer.text(inspect)
|
|
54
68
|
|
|
55
69
|
def as_json(*)
|
|
56
|
-
raise TypeError, "
|
|
70
|
+
raise TypeError, "links must not be serialized"
|
|
57
71
|
end
|
|
58
72
|
|
|
59
73
|
def marshal_dump
|
|
60
|
-
raise TypeError, "
|
|
74
|
+
raise TypeError, "links must not be serialized"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A verification link: its page's button confirms the address.
|
|
79
|
+
class Verification < Link; end
|
|
80
|
+
|
|
81
|
+
# A recovery link for `user_id`: its page asks for a new password and posts
|
|
82
|
+
# both to the kernel.
|
|
83
|
+
class Recovery < Link
|
|
84
|
+
attr_reader :user_id
|
|
85
|
+
|
|
86
|
+
def initialize(token:, expires_at:, user_id:)
|
|
87
|
+
@user_id = user_id
|
|
88
|
+
super(token: token, expires_at: expires_at)
|
|
61
89
|
end
|
|
90
|
+
|
|
91
|
+
def inspect = "#<Strongroom::Recovery user_id=#{user_id} expires_at=#{expires_at.iso8601} token=[FILTERED]>"
|
|
92
|
+
alias to_s inspect
|
|
62
93
|
end
|
|
63
94
|
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_"
|