devise_invitable 3.0.4 → 3.1.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 +15 -0
- data/README.md +259 -23
- data/app/controllers/devise/invitations_controller.rb +2 -1
- data/app/controllers/devise_invitable/registrations_controller.rb +1 -1
- data/lib/devise_invitable/acceptance_token.rb +58 -0
- data/lib/devise_invitable/invitation.rb +26 -5
- data/lib/devise_invitable/invitation_delivery.rb +114 -0
- data/lib/devise_invitable/invitation_delivery_job.rb +15 -0
- data/lib/devise_invitable/models.rb +160 -35
- data/lib/devise_invitable/version.rb +1 -1
- data/lib/devise_invitable.rb +42 -2
- data/lib/generators/devise_invitable/install_generator.rb +21 -0
- data/test/integration/invitation_test.rb +26 -0
- data/test/jobs/invitation_delivery_job_test.rb +138 -0
- data/test/lazy_loading_test.rb +17 -0
- data/test/mailers/invitation_mail_test.rb +9 -5
- data/test/models/invitable_test.rb +61 -7
- data/test/models/separate_invitations_test.rb +144 -3
- data/test/models_test.rb +17 -0
- data/test/rails_app/app/models/user.rb +4 -1
- data/test/rails_app/db/migrate/20261103170000_add_options_to_users.rb +5 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14df8dd3f270570211fc502f10b3e0a10a4f81b064a11217b0963f5118f68255
|
|
4
|
+
data.tar.gz: b1e0b2ffada6cc6099c6aeffef44677549732f08f842e65c6832549f69ba9ef3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ea404feea290eb01ed9108f1c541b8df0df3a664813c7ee630ae96c198647858560e126196d1173d521106a11e22a6d000308102d60719a5f220484ff08e4d57
|
|
7
|
+
data.tar.gz: 35541ff5985d4d3c85ea9b2444b5d8f4ef54f18d1a7bd3ab5d6e77b262b50c5f6b77e38f36927bdc4760e9979f47d80099227080a46fe8048ef2b557cf80f06e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## 3.1.0
|
|
2
|
+
|
|
3
|
+
- Add bounded, encrypted `invitation_data` for application-specific acceptance
|
|
4
|
+
context, and an opt-in mode that preserves other invitations for independent
|
|
5
|
+
acceptance without changing its password or signing it in
|
|
6
|
+
- Keep invite keys out of new invitation records by carrying them in an
|
|
7
|
+
encrypted, authenticated acceptance token. Existing invitation links with
|
|
8
|
+
plaintext invite keys remain supported ([#777](https://github.com/scambra/devise_invitable/issues/777))
|
|
9
|
+
- Add opt-in temporary invite-key storage for resource-less invitations that
|
|
10
|
+
must be approved and delivered later; stored keys are cleared after delivery
|
|
11
|
+
- Add `DeviseInvitable::Invitation#deliver_later`, a lazily loaded default
|
|
12
|
+
Active Job, an opt-in `deliver_invitation_later` setting for the normal flow,
|
|
13
|
+
and configurable job class and queue settings for background invitation email
|
|
14
|
+
delivery without nesting jobs created by `send_devise_notification` overrides
|
|
15
|
+
|
|
1
16
|
## 3.0.4
|
|
2
17
|
|
|
3
18
|
- Avoid using distinct which may fail in postgresql if the model has json column ([935](https://github.com/scambra/devise_invitable/issues/935))
|
data/README.md
CHANGED
|
@@ -150,7 +150,9 @@ no limit for any user, `invitation_limit` column is not used. A setting
|
|
|
150
150
|
of `0` means they can't send invitations. A setting `n > 0` means they can
|
|
151
151
|
send `n` invitations. You can change `invitation_limit` column for some
|
|
152
152
|
users so they can send more or less invitations, even with global
|
|
153
|
-
`invitation_limit = 0`.
|
|
153
|
+
`invitation_limit = 0`. This setting controls how many invitations an inviter
|
|
154
|
+
may send; use `authenticate_inviter!` for authorization and other
|
|
155
|
+
application-specific conditions.
|
|
154
156
|
|
|
155
157
|
* `invite_key`: The key to be used to check existing users when sending an
|
|
156
158
|
invitation. You can use multiple keys. This value must be a hash with the
|
|
@@ -172,11 +174,34 @@ polymorphic `invited_by_id` and `invited_by_type` columns in the shared
|
|
|
172
174
|
invitations table.
|
|
173
175
|
|
|
174
176
|
* `create_resource_on_invitation`: create a placeholder resource when the
|
|
175
|
-
invitation is sent. It is disabled by default, so only
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
disabled.
|
|
177
|
+
invitation is sent. It is disabled by default, so only the invitation record is
|
|
178
|
+
persisted while invite keys remain in the encrypted acceptance token. Enable it
|
|
179
|
+
when the application needs an addressable resource before signup or needs to
|
|
180
|
+
store additional permitted resource attributes from the invitation form.
|
|
181
|
+
Arbitrary resource attributes are rejected when this is disabled.
|
|
182
|
+
|
|
183
|
+
* `store_invite_keys_until_delivery`: temporarily store invite keys in the
|
|
184
|
+
invitation record so a skipped, resource-less invitation can be loaded and
|
|
185
|
+
delivered later. It is disabled by default. Stored keys are cleared after
|
|
186
|
+
successful delivery, when they are available in the encrypted acceptance token.
|
|
187
|
+
|
|
188
|
+
* `deliver_invitation_later`: enqueue invitation emails with the configured
|
|
189
|
+
invitation delivery job. It is disabled by default, preserving the normal
|
|
190
|
+
`send_devise_notification` hook.
|
|
191
|
+
|
|
192
|
+
* `invitation_delivery_job`: job class used by `Invitation#deliver_later`.
|
|
193
|
+
It may be a class or class name and must respond to `perform_later`. The default
|
|
194
|
+
is `DeviseInvitable::InvitationDeliveryJob`.
|
|
195
|
+
|
|
196
|
+
* `invitation_delivery_queue_name`: queue used by the default invitation
|
|
197
|
+
delivery job. The default is `:mailers`.
|
|
198
|
+
|
|
199
|
+
* `supersede_other_invitations_on_accept`: supersede other pending
|
|
200
|
+
invitations for the same invite keys when one is accepted. Enabled by default.
|
|
201
|
+
Disable it only when each invitation carries independently acceptable
|
|
202
|
+
application context. Remaining invitations are linked to the provisioned
|
|
203
|
+
resource and their tokens stay valid; accepting them does not require or change
|
|
204
|
+
the resource's password.
|
|
180
205
|
|
|
181
206
|
* `invited_by_counter_cache`: the column name used for counter_cache column.
|
|
182
207
|
If this is `nil` (default value), the `invited_by` association is declared
|
|
@@ -417,6 +442,58 @@ that invitation accepted and supersedes the others. If the invitee registers
|
|
|
417
442
|
directly, all matching pending invitations are superseded rather than marked
|
|
418
443
|
accepted.
|
|
419
444
|
|
|
445
|
+
### Pass application context with an invitation
|
|
446
|
+
|
|
447
|
+
Small application-specific values can be carried in the encrypted acceptance
|
|
448
|
+
token with the `invitation_data` option:
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
User.invite!(
|
|
452
|
+
{ email: 'new_user@example.com' },
|
|
453
|
+
current_user,
|
|
454
|
+
invitation_data: { organization_id: organization.id, role: 'member' }
|
|
455
|
+
)
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`invitation_data` must be a JSON-compatible hash whose serialized form is no
|
|
459
|
+
larger than 1,024 bytes. It is deliberately not persisted by devise_invitable.
|
|
460
|
+
After token decoding, JSON object keys are strings. The data is available as
|
|
461
|
+
`current_invitation.invitation_data` in invitation callbacks and controller
|
|
462
|
+
code. For example:
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
after_invitation_created do |user|
|
|
466
|
+
InvitationContext.create!(
|
|
467
|
+
devise_invitation: user.current_invitation,
|
|
468
|
+
data: user.current_invitation.invitation_data
|
|
469
|
+
)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
after_invitation_accepted do |user|
|
|
473
|
+
organization_id = user.current_invitation.invitation_data['organization_id']
|
|
474
|
+
user.memberships.find_or_create_by!(organization_id: organization_id)
|
|
475
|
+
end
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Persist the context in an application model when it must survive independently
|
|
479
|
+
of the email link, be processed after another invitation is accepted, or be
|
|
480
|
+
combined with other invitations. Keep it URL-only when each invitation should
|
|
481
|
+
be accepted separately. To preserve the other links in that case:
|
|
482
|
+
|
|
483
|
+
```ruby
|
|
484
|
+
class User < ApplicationRecord
|
|
485
|
+
devise :database_authenticatable, :invitable,
|
|
486
|
+
supersede_other_invitations_on_accept: false
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
The first accepted invitation provisions the resource normally. Later
|
|
491
|
+
invitations run the acceptance callbacks with their own `invitation_data`, but
|
|
492
|
+
cannot replace or require the existing password and do not automatically sign
|
|
493
|
+
the resource in. Invitation data is a bearer credential; applications must
|
|
494
|
+
re-check referenced resources, authorization, and current roles when accepting
|
|
495
|
+
it.
|
|
496
|
+
|
|
420
497
|
Placeholder resources expose `invitation_pending?`. Pending placeholders are
|
|
421
498
|
rejected by both Devise's password validation and
|
|
422
499
|
`active_for_authentication?` checks until an invitation is accepted or the
|
|
@@ -426,15 +503,21 @@ resource is claimed by direct registration.
|
|
|
426
503
|
|
|
427
504
|
Invitation keys commonly contain personal data such as an email address. With
|
|
428
505
|
the default `create_resource_on_invitation: false`, no resource row is created,
|
|
429
|
-
|
|
430
|
-
`devise_invitations.invite_key` JSON column
|
|
431
|
-
|
|
432
|
-
|
|
506
|
+
and new invitations do not persist the invite-key values in the
|
|
507
|
+
`devise_invitations.invite_key` JSON column. The values are carried in the
|
|
508
|
+
encrypted and authenticated acceptance token and restored only when the link
|
|
509
|
+
is accepted. `invite_key_digest` is stored so the values in the token can be
|
|
510
|
+
verified against the invitation record. Invitations created before this change
|
|
511
|
+
may still have plaintext invite keys; their existing links remain supported.
|
|
433
512
|
|
|
434
513
|
With `create_resource_on_invitation: true`, the resource and any additional
|
|
435
514
|
attributes or associations supplied to `invite!` are persisted immediately.
|
|
436
515
|
Choose this mode only when the application needs a placeholder and its privacy
|
|
437
|
-
policy permits storing that data before acceptance.
|
|
516
|
+
policy permits storing that data before acceptance. An application may clear
|
|
517
|
+
the placeholder's invite-key attributes after delivery, for example in an
|
|
518
|
+
`after_invitation_created` callback; the encrypted acceptance token restores
|
|
519
|
+
them during acceptance. The application is responsible for allowing the blank
|
|
520
|
+
placeholder attributes and persisting the cleanup.
|
|
438
521
|
|
|
439
522
|
`invite_for` limits token validity but does not delete invitation or resource
|
|
440
523
|
records. Applications are responsible for choosing a lawful basis for
|
|
@@ -445,12 +528,10 @@ Consent is one possible lawful basis under GDPR, but it is not the only one.
|
|
|
445
528
|
See the [European Commission GDPR principles](https://commission.europa.eu/law/law-topic/data-protection/information-business-and-organisations/principles-gdpr_en)
|
|
446
529
|
and obtain appropriate legal advice for the application.
|
|
447
530
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
and verifying it against a stored digest, or using suitably protected
|
|
453
|
-
reversible storage.
|
|
531
|
+
The acceptance token is a bearer credential: avoid logging it or exposing it
|
|
532
|
+
to third parties. Its encryption key is derived from the Rails application's
|
|
533
|
+
key generator, so changing `secret_key_base` invalidates outstanding encrypted
|
|
534
|
+
invitation links.
|
|
454
535
|
|
|
455
536
|
If you want to create the invitation but not send it, you can set
|
|
456
537
|
`skip_invitation` to `true`.
|
|
@@ -463,8 +544,10 @@ end
|
|
|
463
544
|
```
|
|
464
545
|
|
|
465
546
|
When generating the `accept_user_invitation_url` yourself, you must use the
|
|
466
|
-
`raw_invitation_token`.
|
|
467
|
-
|
|
547
|
+
`raw_invitation_token`. For new invitations this is an encrypted, authenticated
|
|
548
|
+
envelope containing the underlying Devise token and invite keys. It is
|
|
549
|
+
temporarily available on the object that creates the invitation and is not
|
|
550
|
+
persisted in plaintext.
|
|
468
551
|
|
|
469
552
|
```ruby
|
|
470
553
|
accept_user_invitation_url(invitation_token: user.raw_invitation_token)
|
|
@@ -474,15 +557,22 @@ When `skip_invitation` is used, the resulting invitation remains pending with
|
|
|
474
557
|
an unset `invitation_sent_at`. Deliver it later by calling `deliver` on the
|
|
475
558
|
invitation:
|
|
476
559
|
|
|
560
|
+
For resource-less invitations, first enable temporary invite-key storage:
|
|
561
|
+
|
|
562
|
+
```ruby
|
|
563
|
+
devise :invitable, store_invite_keys_until_delivery: true
|
|
564
|
+
```
|
|
565
|
+
|
|
477
566
|
```ruby
|
|
478
567
|
invitation = user.current_invitation
|
|
479
568
|
invitation.deliver
|
|
480
569
|
```
|
|
481
570
|
|
|
482
|
-
This works for
|
|
483
|
-
resource-less invitation, `deliver` reconstructs the resource from the
|
|
484
|
-
invite key. It generates a fresh
|
|
485
|
-
|
|
571
|
+
This works for placeholder-backed invitations without additional configuration.
|
|
572
|
+
For a resource-less invitation, `deliver` reconstructs the resource from the
|
|
573
|
+
temporarily stored invite key. It generates a fresh encrypted acceptance token,
|
|
574
|
+
sends the email, clears the stored invite key, and sets `invitation_sent_at`.
|
|
575
|
+
Calling it again after successful delivery does nothing.
|
|
486
576
|
|
|
487
577
|
For example, an approval workflow can find unsent pending invitations and
|
|
488
578
|
deliver an approved one after applying its own authorization policy:
|
|
@@ -508,6 +598,98 @@ User.invite!(email: 'new_user@example.com', skip_invitation: true)
|
|
|
508
598
|
`DeviseInvitable::Invitation`. With placeholders enabled,
|
|
509
599
|
`invited_to_sign_up?` and `invitation_pending?` return `true` on the resource.
|
|
510
600
|
|
|
601
|
+
### Background delivery
|
|
602
|
+
|
|
603
|
+
Devise Invitable includes a lazily loaded Active Job for background invitation
|
|
604
|
+
delivery. To use it for the normal invitation flow, enable it in the Devise
|
|
605
|
+
initializer:
|
|
606
|
+
|
|
607
|
+
```ruby
|
|
608
|
+
config.deliver_invitation_later = true
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
```ruby
|
|
612
|
+
User.invite!(email: 'new_user@example.com')
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
It can also enqueue an invitation that was created without sending its email:
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
user = User.invite!(email: 'new_user@example.com', skip_invitation: true)
|
|
619
|
+
user.current_invitation.deliver_later
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
The default job runs on the `mailers` queue. It reconstructs a resource-less
|
|
623
|
+
invitee from the encrypted acceptance token and calls `deliver_now` on the
|
|
624
|
+
configured Devise mailer because it is already executing inside a background
|
|
625
|
+
job. It retains `invitation_sent_at` after successful delivery and leaves
|
|
626
|
+
failed invitations retryable by clearing that timestamp if delivery raises.
|
|
627
|
+
|
|
628
|
+
The example above does not require `store_invite_keys_until_delivery` because
|
|
629
|
+
the invitation is enqueued while its acceptance token is still available. If
|
|
630
|
+
an invitation will be reloaded and approved before it is enqueued, enable
|
|
631
|
+
temporary invite-key storage:
|
|
632
|
+
|
|
633
|
+
```ruby
|
|
634
|
+
class User < ApplicationRecord
|
|
635
|
+
devise :invitable, store_invite_keys_until_delivery: true
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
invitation = DeviseInvitable::Invitation.find(approved_invitation_id)
|
|
639
|
+
invitation.deliver_later
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Stored invite keys are cleared after successful delivery. Placeholder-backed
|
|
643
|
+
invitations do not need this setting because their persisted resource contains
|
|
644
|
+
the delivery address.
|
|
645
|
+
|
|
646
|
+
Configure the default queue in the Devise initializer:
|
|
647
|
+
|
|
648
|
+
```ruby
|
|
649
|
+
config.invitation_delivery_queue_name = :critical_mailers
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
To inherit from the application's `ApplicationJob`, add retries or callbacks,
|
|
653
|
+
or use another compatible job implementation, configure a custom job class:
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
class ApplicationInvitationDeliveryJob < ApplicationJob
|
|
657
|
+
queue_as :mailers
|
|
658
|
+
retry_on MailProviderError
|
|
659
|
+
|
|
660
|
+
def perform(invitation, acceptance_token = nil, options = {})
|
|
661
|
+
DeviseInvitable::InvitationDelivery.deliver_now(
|
|
662
|
+
invitation,
|
|
663
|
+
acceptance_token: acceptance_token,
|
|
664
|
+
options: options
|
|
665
|
+
)
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
```ruby
|
|
671
|
+
config.invitation_delivery_job = 'ApplicationInvitationDeliveryJob'
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
By default, the normal invitation flow and synchronous `Invitation#deliver`
|
|
675
|
+
continue to use `send_devise_notification`. This is compatible with Devise's
|
|
676
|
+
documented model override that calls Action Mailer's `deliver_later`, including
|
|
677
|
+
for persisted placeholder resources. Action Mailer cannot enqueue an
|
|
678
|
+
unpersisted, resource-less invitee, so enable `deliver_invitation_later` when
|
|
679
|
+
`create_resource_on_invitation` is disabled. Enabling the setting or calling
|
|
680
|
+
`Invitation#deliver_later` gives the invitation job ownership of the queue
|
|
681
|
+
boundary. Inside that job, the gem calls the configured Devise mailer directly
|
|
682
|
+
with `deliver_now`, so a `send_devise_notification` override does not create a
|
|
683
|
+
second job. Other Devise notifications continue to use that override normally.
|
|
684
|
+
|
|
685
|
+
Applications without Active Job can leave `deliver_invitation_later` disabled
|
|
686
|
+
and continue to use synchronous delivery, or configure any compatible job class
|
|
687
|
+
that responds to `perform_later`.
|
|
688
|
+
|
|
689
|
+
For immediate resource-less background delivery, the encrypted acceptance
|
|
690
|
+
token is a job argument. Treat the queue as sensitive storage and configure job
|
|
691
|
+
logging so bearer tokens are not exposed. Mail options must also be serializable.
|
|
692
|
+
|
|
511
693
|
To check if a particular user is created by invitation, irrespective to state
|
|
512
694
|
of invitation one can use `created_by_invite?`
|
|
513
695
|
|
|
@@ -653,6 +835,60 @@ class Admin < ActiveRecord::Base
|
|
|
653
835
|
end
|
|
654
836
|
```
|
|
655
837
|
|
|
838
|
+
### Restricting or disabling invitation endpoints
|
|
839
|
+
|
|
840
|
+
`authenticate_inviter!` may enforce any application-specific condition, not
|
|
841
|
+
only authentication. For example, it can check roles, permissions,
|
|
842
|
+
relationships, tenant membership, or whether an application workflow has been
|
|
843
|
+
completed.
|
|
844
|
+
|
|
845
|
+
The filter protects only the invitation controller's `new` and `create`
|
|
846
|
+
actions. Calling `User.invite!` from an authorized application service,
|
|
847
|
+
controller, callback, or background job is unaffected.
|
|
848
|
+
|
|
849
|
+
To prevent invitations from being sent through the standard invitation form
|
|
850
|
+
while still allowing controlled application workflows, override the filter in
|
|
851
|
+
a custom invitations controller:
|
|
852
|
+
|
|
853
|
+
```ruby
|
|
854
|
+
class Users::InvitationsController < Devise::InvitationsController
|
|
855
|
+
protected
|
|
856
|
+
|
|
857
|
+
def authenticate_inviter!
|
|
858
|
+
redirect_to root_path, alert: "Invitations cannot be sent from this page."
|
|
859
|
+
end
|
|
860
|
+
end
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
Configure Devise to use the controller:
|
|
864
|
+
|
|
865
|
+
```ruby
|
|
866
|
+
devise_for :users, controllers: { invitations: "users/invitations" }
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
The application can then create invitations from its authorized workflow:
|
|
870
|
+
|
|
871
|
+
```ruby
|
|
872
|
+
User.invite!(email: email)
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
When the standard form should remain available to some users, call `super` and
|
|
876
|
+
apply the additional policy:
|
|
877
|
+
|
|
878
|
+
```ruby
|
|
879
|
+
class Users::InvitationsController < Devise::InvitationsController
|
|
880
|
+
protected
|
|
881
|
+
|
|
882
|
+
def authenticate_inviter!
|
|
883
|
+
super
|
|
884
|
+
return if performed?
|
|
885
|
+
return if current_user.allowed_to_invite_users?
|
|
886
|
+
|
|
887
|
+
redirect_to root_path, alert: "You are not allowed to send invitations."
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
```
|
|
891
|
+
|
|
656
892
|
## Has many invitations
|
|
657
893
|
|
|
658
894
|
The `DeviseInvitable::Inviter` module defines a `sent_invitations` association
|
|
@@ -51,7 +51,8 @@ class Devise::InvitationsController < DeviseController
|
|
|
51
51
|
yield resource if block_given?
|
|
52
52
|
|
|
53
53
|
if invitation_accepted
|
|
54
|
-
if resource.class.allow_insecure_sign_in_after_accept
|
|
54
|
+
if resource.class.allow_insecure_sign_in_after_accept &&
|
|
55
|
+
!resource.accepted_invitation_for_provisioned_resource?
|
|
55
56
|
flash_message = resource.active_for_authentication? ? :updated : :updated_not_active
|
|
56
57
|
set_flash_message :notice, flash_message if is_flashing_format?
|
|
57
58
|
resource.after_database_authentication
|
|
@@ -11,7 +11,7 @@ class DeviseInvitable::RegistrationsController < Devise::RegistrationsController
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def build_invited_resource(hash)
|
|
14
|
-
self.resource = resource_class.
|
|
14
|
+
self.resource = resource_class.to_adapter.find_first(email: hash[:email])
|
|
15
15
|
if self.resource && self.resource.respond_to?(:invited_to_sign_up?) && self.resource.invited_to_sign_up?
|
|
16
16
|
self.resource.attributes = hash
|
|
17
17
|
self.resource.send_confirmation_instructions if self.resource.confirmation_required_for_invited?
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require 'active_support/key_generator'
|
|
2
|
+
require 'active_support/message_encryptor'
|
|
3
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module DeviseInvitable
|
|
7
|
+
class AcceptanceToken
|
|
8
|
+
CIPHER = 'aes-256-gcm'.freeze
|
|
9
|
+
MAX_INVITATION_DATA_BYTES = 1_024
|
|
10
|
+
PURPOSE = 'devise_invitable.invitation'.freeze
|
|
11
|
+
SALT = 'devise_invitable encrypted invitation'.freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def generate(invitation:, raw_token:, invite_key:, invitation_data: nil)
|
|
15
|
+
invitation_data = normalize_invitation_data(invitation_data)
|
|
16
|
+
encryptor.encrypt_and_sign(
|
|
17
|
+
{
|
|
18
|
+
invitation_id: invitation.id.to_s,
|
|
19
|
+
invitable_type: invitation.invitable_type,
|
|
20
|
+
raw_token: raw_token,
|
|
21
|
+
invite_key: invite_key.to_h,
|
|
22
|
+
invitation_data: invitation_data,
|
|
23
|
+
},
|
|
24
|
+
purpose: PURPOSE
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def normalize_invitation_data(invitation_data)
|
|
29
|
+
return {} if invitation_data.nil?
|
|
30
|
+
|
|
31
|
+
data = invitation_data.to_h
|
|
32
|
+
serialized_data = JSON.generate(data)
|
|
33
|
+
if serialized_data.bytesize > MAX_INVITATION_DATA_BYTES
|
|
34
|
+
raise InvitationDataTooLargeError,
|
|
35
|
+
"invitation_data exceeds #{MAX_INVITATION_DATA_BYTES} serialized bytes"
|
|
36
|
+
end
|
|
37
|
+
data
|
|
38
|
+
rescue NoMethodError, TypeError, JSON::GeneratorError
|
|
39
|
+
raise ArgumentError, 'invitation_data must be a JSON-compatible hash'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def decode(token)
|
|
43
|
+
payload = encryptor.decrypt_and_verify(token, purpose: PURPOSE)
|
|
44
|
+
payload&.with_indifferent_access
|
|
45
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def encryptor
|
|
52
|
+
key_length = ActiveSupport::MessageEncryptor.key_len(CIPHER)
|
|
53
|
+
key = Rails.application.key_generator.generate_key(SALT, key_length)
|
|
54
|
+
ActiveSupport::MessageEncryptor.new(key, cipher: CIPHER, serializer: JSON)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -9,6 +9,8 @@ module DeviseInvitable
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
class Invitation < invitation_base
|
|
12
|
+
attr_accessor :invitation_data, :transient_invite_key
|
|
13
|
+
|
|
12
14
|
self.table_name = 'devise_invitations' if respond_to?(:table_name=)
|
|
13
15
|
|
|
14
16
|
if defined?(Mongoid::Document) && !defined?(ActiveRecord::Base)
|
|
@@ -80,11 +82,11 @@ module DeviseInvitable
|
|
|
80
82
|
# Deliver a pending invitation that has not been sent yet. Resource-less
|
|
81
83
|
# invitations are reconstructed from their stored invite key.
|
|
82
84
|
def deliver(options = {})
|
|
83
|
-
|
|
85
|
+
DeviseInvitable::InvitationDelivery.call(self, options: options)
|
|
86
|
+
end
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
resource.deliver_invitation(options)
|
|
88
|
+
def deliver_later(options = {})
|
|
89
|
+
DeviseInvitable::InvitationDelivery.enqueue(self, options: options)
|
|
88
90
|
end
|
|
89
91
|
|
|
90
92
|
def invite_key
|
|
@@ -92,6 +94,15 @@ module DeviseInvitable
|
|
|
92
94
|
value.respond_to?(:with_indifferent_access) ? value.with_indifferent_access : value
|
|
93
95
|
end
|
|
94
96
|
|
|
97
|
+
def invite_key_for_acceptance
|
|
98
|
+
transient_invite_key.presence || invite_key
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def clear_invite_key
|
|
102
|
+
self.transient_invite_key = nil
|
|
103
|
+
self.invite_key = {} if invite_key.present?
|
|
104
|
+
end
|
|
105
|
+
|
|
95
106
|
def self.digest_for(resource_type, invite_key)
|
|
96
107
|
components = invite_key.to_h.map { |key, value| [key.to_s, value.to_s] }.sort.flatten
|
|
97
108
|
Digest::SHA256.hexdigest(([resource_type] + components).join("\0"))
|
|
@@ -100,7 +111,17 @@ module DeviseInvitable
|
|
|
100
111
|
private
|
|
101
112
|
|
|
102
113
|
def set_invite_key_digest
|
|
103
|
-
|
|
114
|
+
return if transient_invite_key.nil? && invite_key.blank? && invite_key_digest.present?
|
|
115
|
+
|
|
116
|
+
self.invite_key_digest = self.class.digest_for(invitable_type, invite_key_for_acceptance)
|
|
117
|
+
unless transient_invite_key.nil?
|
|
118
|
+
self.invite_key =
|
|
119
|
+
if invitable_type.constantize.store_invite_keys_until_delivery
|
|
120
|
+
transient_invite_key
|
|
121
|
+
else
|
|
122
|
+
{}
|
|
123
|
+
end
|
|
124
|
+
end
|
|
104
125
|
end
|
|
105
126
|
end
|
|
106
127
|
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module DeviseInvitable
|
|
2
|
+
class InvitationDelivery
|
|
3
|
+
def self.call(invitation, resource: nil, acceptance_token: nil, options: {})
|
|
4
|
+
new(
|
|
5
|
+
invitation,
|
|
6
|
+
resource: resource,
|
|
7
|
+
acceptance_token: acceptance_token,
|
|
8
|
+
options: options
|
|
9
|
+
).deliver
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.deliver_now(invitation, resource: nil, acceptance_token: nil, options: {})
|
|
13
|
+
new(
|
|
14
|
+
invitation,
|
|
15
|
+
resource: resource,
|
|
16
|
+
acceptance_token: acceptance_token,
|
|
17
|
+
options: options,
|
|
18
|
+
deliver_now: true
|
|
19
|
+
).deliver
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.enqueue(invitation, resource: nil, acceptance_token: nil, options: {})
|
|
23
|
+
delivery = new(
|
|
24
|
+
invitation,
|
|
25
|
+
resource: resource,
|
|
26
|
+
acceptance_token: acceptance_token,
|
|
27
|
+
options: options
|
|
28
|
+
)
|
|
29
|
+
return unless delivery.deliverable?
|
|
30
|
+
|
|
31
|
+
DeviseInvitable.invitation_delivery_job.perform_later(
|
|
32
|
+
invitation,
|
|
33
|
+
delivery.acceptance_token,
|
|
34
|
+
options
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(invitation, resource: nil, acceptance_token: nil, options: {}, deliver_now: false)
|
|
39
|
+
@invitation = invitation
|
|
40
|
+
@resource = resource
|
|
41
|
+
@acceptance_token = acceptance_token
|
|
42
|
+
@options = options
|
|
43
|
+
@deliver_now = deliver_now
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def deliver
|
|
47
|
+
return unless deliverable?
|
|
48
|
+
|
|
49
|
+
token = acceptance_token # Token generation may save the invitation.
|
|
50
|
+
invitation.invitation_sent_at = Time.now.utc
|
|
51
|
+
delivery = send_notification(token)
|
|
52
|
+
invitation.clear_invite_key
|
|
53
|
+
invitation.save!
|
|
54
|
+
delivery
|
|
55
|
+
rescue
|
|
56
|
+
invitation.invitation_sent_at = nil
|
|
57
|
+
raise
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def deliverable?
|
|
61
|
+
invitation.pending? && invitation.invitation_sent_at.nil?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def acceptance_token
|
|
65
|
+
@acceptance_token ||= resource.send(:prepare_invitation_delivery)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
attr_reader :invitation, :options
|
|
71
|
+
|
|
72
|
+
def send_notification(token)
|
|
73
|
+
if @deliver_now
|
|
74
|
+
resource.send(:send_invitation_notification_now, token, options)
|
|
75
|
+
else
|
|
76
|
+
resource.send(
|
|
77
|
+
:send_devise_notification,
|
|
78
|
+
:invitation_instructions,
|
|
79
|
+
token,
|
|
80
|
+
options
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resource
|
|
86
|
+
@resource ||= begin
|
|
87
|
+
resolved_resource = invitation.invitable || resource_from_invite_key
|
|
88
|
+
resolved_resource.instance_variable_set(:@current_invitation, invitation)
|
|
89
|
+
resolved_resource
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def resource_from_invite_key
|
|
94
|
+
invite_key = invitation.invite_key_for_acceptance.presence || invite_key_from_token
|
|
95
|
+
if invite_key.blank?
|
|
96
|
+
raise DeviseInvitable::MissingInviteKeyError,
|
|
97
|
+
'Cannot deliver a resource-less invitation without an acceptance token or stored invite key'
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
invitation.invitable_type.constantize.new(invite_key)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def invite_key_from_token
|
|
104
|
+
return unless @acceptance_token
|
|
105
|
+
|
|
106
|
+
resource_class = invitation.invitable_type.constantize
|
|
107
|
+
token_invitation, invite_key = resource_class.send(
|
|
108
|
+
:invitation_and_key_from_token,
|
|
109
|
+
@acceptance_token
|
|
110
|
+
)
|
|
111
|
+
invite_key if token_invitation&.id == invitation.id
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require 'active_job'
|
|
2
|
+
|
|
3
|
+
module DeviseInvitable
|
|
4
|
+
class InvitationDeliveryJob < ActiveJob::Base
|
|
5
|
+
queue_as { Devise.invitation_delivery_queue_name }
|
|
6
|
+
|
|
7
|
+
def perform(invitation, acceptance_token = nil, options = {})
|
|
8
|
+
DeviseInvitable::InvitationDelivery.deliver_now(
|
|
9
|
+
invitation,
|
|
10
|
+
acceptance_token: acceptance_token,
|
|
11
|
+
options: options
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|