devise-passwordless 0.7.1 → 1.0.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 +44 -0
- data/README.md +397 -42
- data/UPGRADING.md +143 -0
- data/{lib/generators/devise/passwordless/templates/magic_links_controller.rb.erb → app/controllers/devise/magic_links_controller.rb} +2 -5
- data/app/controllers/devise/passwordless/sessions_controller.rb +49 -0
- data/app/mailers/devise/passwordless/mailer.rb +15 -0
- data/lib/devise/hooks/magic_link_authenticatable.rb +10 -0
- data/lib/devise/models/magic_link_authenticatable.rb +79 -9
- data/lib/devise/monkeypatch.rb +82 -0
- data/lib/devise/passwordless/login_token.rb +7 -52
- data/lib/devise/passwordless/rails.rb +25 -0
- data/lib/devise/passwordless/routing.rb +11 -0
- data/lib/devise/passwordless/tokenizers/message_encryptor_tokenizer.rb +66 -0
- data/lib/devise/passwordless/tokenizers/signed_global_id_tokenizer.rb +20 -0
- data/lib/devise/passwordless/version.rb +3 -1
- data/lib/devise/passwordless.rb +24 -0
- data/lib/devise/strategies/magic_link_authenticatable.rb +5 -25
- data/lib/generators/devise/passwordless/install_generator.rb +8 -11
- metadata +46 -17
- data/.github/workflows/test.yml +0 -45
- data/.gitignore +0 -16
- data/.rspec +0 -4
- data/.travis.yml +0 -7
- data/Gemfile +0 -13
- data/Rakefile +0 -6
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/devise-passwordless.gemspec +0 -41
- data/lib/devise/passwordless/mailer.rb +0 -12
- data/lib/generators/devise/passwordless/templates/sessions_controller.rb.erb +0 -34
data/UPGRADING.md
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
## Upgrading from 0.x to 1.0
|
2
|
+
|
3
|
+
⭐ The 1.0 release includes significant breaking changes! ⭐
|
4
|
+
|
5
|
+
This is a big release that cleans up the DX quite a bit. 🎉 Make these changes
|
6
|
+
to have a successful upgrade:
|
7
|
+
|
8
|
+
* Generated `MagicLinksController` is no longer required
|
9
|
+
* Delete `app/controllers/devise/passwordless/magic_links_controller.rb`
|
10
|
+
* Generated `SessionsController` is no longer required
|
11
|
+
* If you haven't customized the generated controller in:
|
12
|
+
|
13
|
+
```
|
14
|
+
app/controllers/devise/passwordless/sessions_controller.rb
|
15
|
+
```
|
16
|
+
|
17
|
+
then go ahead and delete the file!
|
18
|
+
* If you **have** customized the controller, then we're going to move it. Move the file to somewhere like
|
19
|
+
|
20
|
+
```
|
21
|
+
app/controllers/custom_sessions_controller.rb
|
22
|
+
```
|
23
|
+
|
24
|
+
And change the inside class definition to match, e.g. from
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
class Devise::Passwordless::SessionsController < Devise::SessionsController
|
28
|
+
```
|
29
|
+
|
30
|
+
to
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
class CustomSessionsController < Devise::Passwordless::SessionsController
|
34
|
+
```
|
35
|
+
|
36
|
+
Then, change the route of your resource to match it:
|
37
|
+
|
38
|
+
```ruby
|
39
|
+
devise_for :users,
|
40
|
+
controllers: { sessions: "custom_sessions" }
|
41
|
+
```
|
42
|
+
|
43
|
+
Finally, you should review the latest source of
|
44
|
+
`Devise::Passwordless::SessionsController` as its implementation has changed,
|
45
|
+
so you'll want to sync up your customizations.
|
46
|
+
* Routing no longer requires custom `devise_scope` for magic links
|
47
|
+
* Delete any route declarations from `config/routes.rb` that look like this:
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
devise_scope :user do
|
51
|
+
get "/users/magic_link",
|
52
|
+
to: "devise/passwordless/magic_links#show",
|
53
|
+
as: "users_magic_link"
|
54
|
+
```
|
55
|
+
|
56
|
+
* Changes are required to the Devise initializer in `config/initializers/devise.rb`:
|
57
|
+
* Delete this line:
|
58
|
+
```
|
59
|
+
require 'devise/passwordless/mailer'
|
60
|
+
```
|
61
|
+
* New config value `passwordless_tokenizer` is required. Check README for
|
62
|
+
an explanation of tokenizers.
|
63
|
+
* Add this section to `config/initializers/devise.rb`:
|
64
|
+
|
65
|
+
```ruby
|
66
|
+
# Which algorithm to use for tokenizing magic links. See README for descriptions
|
67
|
+
config.passwordless_tokenizer = "MessageEncryptorTokenizer"
|
68
|
+
```
|
69
|
+
|
70
|
+
* There is a new `:magic_link_sent_paranoid` i18n key you should add to your `config/locales/devise.en.yml` file:
|
71
|
+
|
72
|
+
```diff
|
73
|
+
@@ -58,6 +58,7 @@
|
74
|
+
passwordless:
|
75
|
+
not_found_in_database: "Could not find a user for that email address"
|
76
|
+
magic_link_sent: "A login link has been sent to your email address. Please follow the link to log in to your account."
|
77
|
+
+ magic_link_sent_paranoid: "If your account exists, you will receive an email with a login link. Please follow the link to log in to your account."
|
78
|
+
errors:
|
79
|
+
messages:
|
80
|
+
already_confirmed: "was already confirmed, please try signing in"
|
81
|
+
|
82
|
+
```
|
83
|
+
* If Devise's paranoid mode is enabled in your Devise initializer, this new key
|
84
|
+
replaces the use of both `:magic_link_sent` and `:not_found_in_database` to be
|
85
|
+
ambiguous about the existence of user accounts to prevent account enumeration
|
86
|
+
vulnerabilities. If you want the old behavior back, ensure `Devise.paranoid`
|
87
|
+
is `false` by setting `config.paranoid = false` in your Devise initializer.
|
88
|
+
|
89
|
+
* `magic_link` path and URL helpers now work
|
90
|
+
* Delete any code that looks like this:
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
send("#{@scope_name.to_s.pluralize}_magic_link_url", Hash[@scope_name, {email: @resource.email, token: @token, remember_me: @remember_me}])
|
94
|
+
```
|
95
|
+
|
96
|
+
and replace it with this:
|
97
|
+
|
98
|
+
```ruby
|
99
|
+
magic_link_url(@resource, @scope_name => {email: @resource.email, token: @token, remember_me: @remember_me})
|
100
|
+
```
|
101
|
+
* The routes are no longer pluralized, so change any references like:
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
users_magic_link_url
|
105
|
+
```
|
106
|
+
|
107
|
+
to:
|
108
|
+
|
109
|
+
```ruby
|
110
|
+
user_magic_link_url
|
111
|
+
```
|
112
|
+
|
113
|
+
* `Devise::Passwordless::LoginToken` is deprecated.
|
114
|
+
* Calls to `::encode` and `::decode` should be replaced with calls to these
|
115
|
+
methods on the resource model (e.g. `User`): `#encode_passwordless_token`
|
116
|
+
and `::decode_passwordless_token`.
|
117
|
+
* References to `Devise::Passwordless::LoginToken.secret_key` should be
|
118
|
+
changed to `Devise::Passwordless.secret_key`.
|
119
|
+
|
120
|
+
* Hotwire/Turbo support
|
121
|
+
* If your Rails app uses Hotwire / Turbo, make sure you're using Devise >= 4.9
|
122
|
+
and setting the `config.responder` value in your Devise configuration
|
123
|
+
(see Devise Turbo upgrade guide: https://github.com/heartcombo/devise/wiki/How-To:-Upgrade-to-Devise-4.9.0-%5BHotwire-Turbo-integration%5D)
|
124
|
+
|
125
|
+
* The `#send_magic_link` method now uses keyword arguments instead of positional arguments.
|
126
|
+
* Change any instances of
|
127
|
+
|
128
|
+
```ruby
|
129
|
+
remember_me = true
|
130
|
+
user.send_magic_link(remember_me, subject: "Custom email subject")
|
131
|
+
```
|
132
|
+
|
133
|
+
to:
|
134
|
+
|
135
|
+
```ruby
|
136
|
+
user.send_magic_link(remember_me: true, subject: "Custom email subject")
|
137
|
+
```
|
138
|
+
|
139
|
+
* After sending a magic link, users will now be redirected rather than
|
140
|
+
re-rendering the sign-in form.
|
141
|
+
* [See the README][after-magic-link-sent-readme] for details on how to customize the redirect behavior
|
142
|
+
|
143
|
+
[after-magic-link-sent-readme]: https://github.com/abevoelker/devise-passwordless#redirecting-after-magic-link-is-sent
|
@@ -1,11 +1,9 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
<% module_namespacing do -%>
|
4
|
-
class Devise::Passwordless::MagicLinksController < DeviseController
|
1
|
+
class Devise::MagicLinksController < DeviseController
|
5
2
|
prepend_before_action :require_no_authentication, only: :show
|
6
3
|
prepend_before_action :allow_params_authentication!, only: :show
|
7
4
|
prepend_before_action(only: [:show]) { request.env["devise.skip_timeout"] = true }
|
8
5
|
|
6
|
+
# GET /resource/magic_link
|
9
7
|
def show
|
10
8
|
self.resource = warden.authenticate!(auth_options)
|
11
9
|
set_flash_message!(:notice, :signed_in)
|
@@ -31,4 +29,3 @@ class Devise::Passwordless::MagicLinksController < DeviseController
|
|
31
29
|
resource_params.permit(:email, :remember_me)
|
32
30
|
end
|
33
31
|
end
|
34
|
-
<% end -%>
|
@@ -0,0 +1,49 @@
|
|
1
|
+
class Devise::Passwordless::SessionsController < Devise::SessionsController
|
2
|
+
def create
|
3
|
+
if (self.resource = resource_class.find_by(email: create_params[:email]))
|
4
|
+
resource.send_magic_link(remember_me: create_params[:remember_me])
|
5
|
+
if Devise.paranoid
|
6
|
+
set_flash_message!(:notice, :magic_link_sent_paranoid)
|
7
|
+
else
|
8
|
+
set_flash_message!(:notice, :magic_link_sent)
|
9
|
+
end
|
10
|
+
else
|
11
|
+
self.resource = resource_class.new(create_params)
|
12
|
+
if Devise.paranoid
|
13
|
+
set_flash_message!(:notice, :magic_link_sent_paranoid)
|
14
|
+
else
|
15
|
+
set_flash_message!(:alert, :not_found_in_database, now: true)
|
16
|
+
render :new, status: devise_error_status
|
17
|
+
return
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
redirect_to(after_magic_link_sent_path_for(resource), status: devise_redirect_status)
|
22
|
+
end
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
def translation_scope
|
27
|
+
if action_name == "create"
|
28
|
+
"devise.passwordless"
|
29
|
+
else
|
30
|
+
super
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def create_params
|
37
|
+
resource_params.permit(:email, :remember_me)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Devise < 4.9 fallback support
|
41
|
+
# See: https://github.com/heartcombo/devise/wiki/How-To:-Upgrade-to-Devise-4.9.0-%5BHotwire-Turbo-integration%5D
|
42
|
+
def devise_redirect_status
|
43
|
+
Devise.try(:responder).try(:redirect_status) || :found
|
44
|
+
end
|
45
|
+
|
46
|
+
def devise_error_status
|
47
|
+
Devise.try(:responder).try(:error_status) || :ok
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
if defined?(ActionMailer)
|
4
|
+
module Devise
|
5
|
+
module Passwordless
|
6
|
+
class Mailer < Devise::Mailer
|
7
|
+
def magic_link(record, token, remember_me, opts = {})
|
8
|
+
@token = token
|
9
|
+
@remember_me = remember_me
|
10
|
+
devise_mail(record, :magic_link, opts)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Deny user access when magic link authentication is disabled
|
4
|
+
Warden::Manager.after_set_user do |record, warden, options|
|
5
|
+
if record && record.respond_to?(:active_for_magic_link_authentication?) && !record.active_for_magic_link_authentication?
|
6
|
+
scope = options[:scope]
|
7
|
+
warden.logout(scope)
|
8
|
+
throw :warden, scope: scope, message: record.magic_link_inactive_message
|
9
|
+
end
|
10
|
+
end
|
@@ -1,22 +1,41 @@
|
|
1
1
|
require 'devise/strategies/magic_link_authenticatable'
|
2
|
+
require 'devise/hooks/magic_link_authenticatable'
|
2
3
|
|
3
4
|
module Devise
|
4
5
|
module Models
|
5
6
|
module MagicLinkAuthenticatable
|
6
7
|
extend ActiveSupport::Concern
|
7
8
|
|
8
|
-
|
9
|
-
|
9
|
+
# Models using the :database_authenticatable strategy will already
|
10
|
+
# have #password_required? and #password defined - we will defer
|
11
|
+
# to those methods if they already exist so that people can use
|
12
|
+
# both strategies together. Otherwise, for :magic_link_authenticatable-
|
13
|
+
# only users, we define them in order to disable password validations:
|
14
|
+
|
15
|
+
unless instance_methods.include?(:password_required?)
|
16
|
+
def password_required?
|
17
|
+
false
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
unless instance_methods.include?(:password)
|
22
|
+
# Not having a #password method breaks the :validatable module
|
23
|
+
#
|
24
|
+
# NOTE I proposed a change to Devise to fix this:
|
25
|
+
# https://github.com/heartcombo/devise/issues/5346#issuecomment-822022834
|
26
|
+
# As of yet it hasn't been accepted due to unknowns of the legacy code's purpose
|
27
|
+
def password
|
28
|
+
nil
|
29
|
+
end
|
10
30
|
end
|
11
31
|
|
12
|
-
|
13
|
-
|
14
|
-
nil
|
32
|
+
def encode_passwordless_token(*args, **kwargs)
|
33
|
+
self.class.passwordless_tokenizer_class.encode(self, *args, **kwargs)
|
15
34
|
end
|
16
35
|
|
17
|
-
def send_magic_link(remember_me,
|
18
|
-
token =
|
19
|
-
send_devise_notification(:magic_link, token, remember_me,
|
36
|
+
def send_magic_link(remember_me: false, **kwargs)
|
37
|
+
token = self.encode_passwordless_token
|
38
|
+
send_devise_notification(:magic_link, token, remember_me, **kwargs)
|
20
39
|
end
|
21
40
|
|
22
41
|
# A callback initiated after successfully authenticating. This can be
|
@@ -32,9 +51,38 @@ module Devise
|
|
32
51
|
def after_magic_link_authentication
|
33
52
|
end
|
34
53
|
|
54
|
+
# Set this to false to disable magic link auth for this model instance.
|
55
|
+
# Magic links will still be generated by the sign-in page, but visiting
|
56
|
+
# them will instead display an error message.
|
57
|
+
def active_for_magic_link_authentication?
|
58
|
+
true
|
59
|
+
end
|
60
|
+
|
61
|
+
# This method determines which error message to display when magic link
|
62
|
+
# auth is disabled for this model instance.
|
63
|
+
def magic_link_inactive_message
|
64
|
+
:magic_link_invalid
|
65
|
+
end
|
66
|
+
|
35
67
|
protected
|
36
68
|
|
37
69
|
module ClassMethods
|
70
|
+
def passwordless_tokenizer_class
|
71
|
+
@passwordless_tokenizer_class ||= self.passwordless_tokenizer.is_a?(Class) ? (
|
72
|
+
self.passwordless_tokenizer
|
73
|
+
) : (
|
74
|
+
self.passwordless_tokenizer.start_with?("::") ? (
|
75
|
+
self.passwordless_tokenizer.constantize
|
76
|
+
) : (
|
77
|
+
"Devise::Passwordless::#{self.passwordless_tokenizer}".constantize
|
78
|
+
)
|
79
|
+
)
|
80
|
+
end
|
81
|
+
|
82
|
+
def decode_passwordless_token(*args, **kwargs)
|
83
|
+
passwordless_tokenizer_class.decode(*args, **kwargs)
|
84
|
+
end
|
85
|
+
|
38
86
|
# We assume this method already gets the sanitized values from the
|
39
87
|
# MagicLinkAuthenticatable strategy. If you are using this method on
|
40
88
|
# your own, be sure to sanitize the conditions hash to only include
|
@@ -44,8 +92,9 @@ module Devise
|
|
44
92
|
end
|
45
93
|
|
46
94
|
Devise::Models.config(self,
|
95
|
+
:passwordless_tokenizer,
|
47
96
|
:passwordless_login_within,
|
48
|
-
|
97
|
+
#:passwordless_secret_key,
|
49
98
|
:passwordless_expire_old_tokens_on_sign_in
|
50
99
|
)
|
51
100
|
end
|
@@ -54,6 +103,27 @@ module Devise
|
|
54
103
|
end
|
55
104
|
|
56
105
|
module Devise
|
106
|
+
mattr_accessor :passwordless_tokenizer
|
107
|
+
@@passwordless_tokenizer = nil
|
108
|
+
def self.passwordless_tokenizer
|
109
|
+
if @@passwordless_tokenizer.blank?
|
110
|
+
Devise::Passwordless.deprecator.warn <<-DEPRECATION.strip_heredoc
|
111
|
+
[Devise-Passwordless] `Devise.passwordless_tokenizer` is a required
|
112
|
+
config option. If you are upgrading to Devise-Passwordless 1.0 from
|
113
|
+
a previous install, you should use "MessageEncryptorTokenizer" for
|
114
|
+
backwards compatibility. New installs are templated with
|
115
|
+
"SignedGlobalIDTokenizer". Read the README for a comparison of
|
116
|
+
options and UPGRADING for upgrade instructions. Execution will
|
117
|
+
now proceed with a value of "MessageEncryptorTokenizer" but future
|
118
|
+
releases will raise an error if this option is unset.
|
119
|
+
DEPRECATION
|
120
|
+
|
121
|
+
"MessageEncryptorTokenizer"
|
122
|
+
else
|
123
|
+
@@passwordless_tokenizer
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
57
127
|
mattr_accessor :passwordless_login_within
|
58
128
|
@@passwordless_login_within = 20.minutes
|
59
129
|
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# Monkeypatch to allow multiple routes for a single module
|
2
|
+
# TODO this should be submitted as a PR to devise to deleted if/when merged
|
3
|
+
module Devise
|
4
|
+
class Mapping
|
5
|
+
def routes
|
6
|
+
@routes ||= ROUTES.values_at(*self.modules).compact.flatten.uniq
|
7
|
+
end
|
8
|
+
end
|
9
|
+
def self.add_module(module_name, options = {})
|
10
|
+
options.assert_valid_keys(:strategy, :model, :controller, :route, :no_input, :insert_at)
|
11
|
+
|
12
|
+
ALL.insert (options[:insert_at] || -1), module_name
|
13
|
+
|
14
|
+
if strategy = options[:strategy]
|
15
|
+
strategy = (strategy == true ? module_name : strategy)
|
16
|
+
STRATEGIES[module_name] = strategy
|
17
|
+
end
|
18
|
+
|
19
|
+
if controller = options[:controller]
|
20
|
+
controller = (controller == true ? module_name : controller)
|
21
|
+
CONTROLLERS[module_name] = controller
|
22
|
+
end
|
23
|
+
|
24
|
+
NO_INPUT << strategy if options[:no_input]
|
25
|
+
|
26
|
+
if route = options[:route]
|
27
|
+
routes = {}
|
28
|
+
|
29
|
+
case route
|
30
|
+
when TrueClass
|
31
|
+
routes[module_name] = []
|
32
|
+
when Symbol
|
33
|
+
routes[route] = []
|
34
|
+
when Hash
|
35
|
+
routes = route
|
36
|
+
else
|
37
|
+
raise ArgumentError, ":route should be true, a Symbol or a Hash"
|
38
|
+
end
|
39
|
+
|
40
|
+
routes.each do |key, value|
|
41
|
+
URL_HELPERS[key] ||= []
|
42
|
+
URL_HELPERS[key].concat(value)
|
43
|
+
URL_HELPERS[key].uniq!
|
44
|
+
|
45
|
+
ROUTES[module_name] = key
|
46
|
+
end
|
47
|
+
|
48
|
+
if routes.size > 1
|
49
|
+
ROUTES[module_name] = routes.keys
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
if options[:model]
|
54
|
+
path = (options[:model] == true ? "devise/models/#{module_name}" : options[:model])
|
55
|
+
camelized = ActiveSupport::Inflector.camelize(module_name.to_s)
|
56
|
+
Devise::Models.send(:autoload, camelized.to_sym, path)
|
57
|
+
end
|
58
|
+
|
59
|
+
Devise::Mapping.add_module module_name
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Extend Devise's Helpers module to add our after_magic_link_sent_path_for
|
64
|
+
# This is defined here as a helper rather than in the sessions controller
|
65
|
+
# directly so that it can be overridden in the main ApplicationController
|
66
|
+
module Devise
|
67
|
+
module Controllers
|
68
|
+
module Helpers
|
69
|
+
# Method used by sessions controller to redirect user after a magic link
|
70
|
+
# is sent from the sign in page. You can overwrite it in your
|
71
|
+
# ApplicationController to provide a custom hook for a custom scope.
|
72
|
+
#
|
73
|
+
# By default it is the root_path.
|
74
|
+
def after_magic_link_sent_path_for(resource_or_scope)
|
75
|
+
scope = Devise::Mapping.find_scope!(resource_or_scope)
|
76
|
+
router_name = Devise.mappings[scope].router_name
|
77
|
+
context = router_name ? send(router_name) : self
|
78
|
+
context.respond_to?(:root_path) ? context.root_path : "/"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -1,57 +1,12 @@
|
|
1
1
|
module Devise::Passwordless
|
2
2
|
class LoginToken
|
3
|
-
class InvalidOrExpiredTokenError < StandardError; end
|
4
|
-
|
5
|
-
def self.encode(resource)
|
6
|
-
now = Time.current
|
7
|
-
len = ActiveSupport::MessageEncryptor.key_len
|
8
|
-
salt = SecureRandom.random_bytes(len)
|
9
|
-
key = ActiveSupport::KeyGenerator.new(self.secret_key).generate_key(salt, len)
|
10
|
-
crypt = ActiveSupport::MessageEncryptor.new(key, serializer: JSON)
|
11
|
-
encrypted_data = crypt.encrypt_and_sign({
|
12
|
-
data: {
|
13
|
-
resource: {
|
14
|
-
key: resource.to_key,
|
15
|
-
email: resource.email,
|
16
|
-
},
|
17
|
-
},
|
18
|
-
created_at: now.to_f,
|
19
|
-
})
|
20
|
-
salt_base64 = Base64.strict_encode64(salt)
|
21
|
-
"#{salt_base64}:#{encrypted_data}"
|
22
|
-
end
|
23
|
-
|
24
|
-
def self.decode(token, as_of=Time.current, expire_duration=Devise.passwordless_login_within)
|
25
|
-
raise InvalidOrExpiredTokenError if token.blank?
|
26
|
-
salt_base64, encrypted_data = token.split(":")
|
27
|
-
begin
|
28
|
-
salt = Base64.strict_decode64(salt_base64)
|
29
|
-
rescue ArgumentError
|
30
|
-
raise InvalidOrExpiredTokenError
|
31
|
-
end
|
32
|
-
len = ActiveSupport::MessageEncryptor.key_len
|
33
|
-
key = ActiveSupport::KeyGenerator.new(self.secret_key).generate_key(salt, len)
|
34
|
-
crypt = ActiveSupport::MessageEncryptor.new(key, serializer: JSON)
|
35
|
-
begin
|
36
|
-
decrypted_data = crypt.decrypt_and_verify(encrypted_data)
|
37
|
-
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage
|
38
|
-
raise InvalidOrExpiredTokenError
|
39
|
-
end
|
40
|
-
|
41
|
-
created_at = ActiveSupport::TimeZone["UTC"].at(decrypted_data["created_at"])
|
42
|
-
if as_of.to_f > (created_at + expire_duration).to_f
|
43
|
-
raise InvalidOrExpiredTokenError
|
44
|
-
end
|
45
|
-
|
46
|
-
decrypted_data
|
47
|
-
end
|
48
|
-
|
49
3
|
def self.secret_key
|
50
|
-
|
51
|
-
Devise.
|
52
|
-
|
53
|
-
Devise.secret_key
|
54
|
-
|
4
|
+
Devise::Passwordless.deprecator.warn <<-DEPRECATION.strip_heredoc
|
5
|
+
[Devise-Passwordless] `Devise::Passwordless::LoginToken.secret_key` is
|
6
|
+
deprecated and will be removed in a future release. Please use
|
7
|
+
`Devise::Passwordless.secret_key` instead.
|
8
|
+
DEPRECATION
|
9
|
+
Devise::Passwordless.secret_key
|
55
10
|
end
|
56
11
|
end
|
57
|
-
end
|
12
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Devise::Passwordless
|
2
|
+
class Engine < Rails::Engine
|
3
|
+
initializer "devise_passwordless.routing" do
|
4
|
+
require "devise/passwordless/routing"
|
5
|
+
|
6
|
+
Devise.add_module(:magic_link_authenticatable, {
|
7
|
+
model: true,
|
8
|
+
strategy: true,
|
9
|
+
route: { magic_link: [nil, :show], session: [nil, :new, :destroy] },
|
10
|
+
controller: :sessions,
|
11
|
+
})
|
12
|
+
end
|
13
|
+
|
14
|
+
initializer "devise_passwordless.log_filter_check" do
|
15
|
+
params = Rails.try(:application).try(:config).try(:filter_parameters) || []
|
16
|
+
|
17
|
+
unless params.map(&:to_sym).include?(:token)
|
18
|
+
warn "[DEVISE-PASSWORDLESS] We have detected that your Rails configuration does not " \
|
19
|
+
"filter :token parameters out of your logs. You should append :token to your " \
|
20
|
+
"config.filter_parameters Rails setting so that magic link tokens don't " \
|
21
|
+
"leak out of your logs."
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Devise::Passwordless
|
2
|
+
class MessageEncryptorTokenizer
|
3
|
+
def self.encode(resource, extra: nil, expires_at: nil)
|
4
|
+
now = Time.current
|
5
|
+
len = ActiveSupport::MessageEncryptor.key_len
|
6
|
+
salt = SecureRandom.random_bytes(len)
|
7
|
+
key = ActiveSupport::KeyGenerator.new(Devise::Passwordless.secret_key).generate_key(salt, len)
|
8
|
+
crypt = ActiveSupport::MessageEncryptor.new(key, serializer: JSON)
|
9
|
+
data = {
|
10
|
+
data: {
|
11
|
+
resource: {
|
12
|
+
key: resource.to_key,
|
13
|
+
email: resource.email,
|
14
|
+
},
|
15
|
+
},
|
16
|
+
created_at: now.to_f,
|
17
|
+
}
|
18
|
+
data[:data][:extra] = extra if extra
|
19
|
+
data[:expires_at] = expires_at.to_f if expires_at
|
20
|
+
encrypted_data = crypt.encrypt_and_sign(data)
|
21
|
+
salt_base64 = Base64.strict_encode64(salt)
|
22
|
+
"#{salt_base64}:#{encrypted_data}"
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.decode(token, resource_class, as_of: Time.current, expire_duration: Devise.passwordless_login_within)
|
26
|
+
raise InvalidTokenError if token.blank?
|
27
|
+
salt_base64, encrypted_data = token.split(":")
|
28
|
+
raise InvalidTokenError if salt_base64.blank? || encrypted_data.blank?
|
29
|
+
begin
|
30
|
+
salt = Base64.strict_decode64(salt_base64)
|
31
|
+
rescue ArgumentError
|
32
|
+
raise InvalidTokenError
|
33
|
+
end
|
34
|
+
len = ActiveSupport::MessageEncryptor.key_len
|
35
|
+
key = ActiveSupport::KeyGenerator.new(Devise::Passwordless.secret_key).generate_key(salt, len)
|
36
|
+
crypt = ActiveSupport::MessageEncryptor.new(key, serializer: JSON)
|
37
|
+
begin
|
38
|
+
decrypted_data = crypt.decrypt_and_verify(encrypted_data)
|
39
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage
|
40
|
+
raise InvalidTokenError
|
41
|
+
end
|
42
|
+
|
43
|
+
unless (expiration_time = decrypted_data["expires_at"])
|
44
|
+
created_at = ActiveSupport::TimeZone["UTC"].at(decrypted_data["created_at"])
|
45
|
+
expiration_time = (created_at + expire_duration).to_f
|
46
|
+
end
|
47
|
+
|
48
|
+
if as_of.to_f > expiration_time
|
49
|
+
raise ExpiredTokenError
|
50
|
+
end
|
51
|
+
|
52
|
+
resource = resource_class.find_by(id: decrypted_data["data"]["resource"]["key"])
|
53
|
+
|
54
|
+
if resource_class.passwordless_expire_old_tokens_on_sign_in
|
55
|
+
if (last_login = resource.try(:current_sign_in_at))
|
56
|
+
token_created_at = ActiveSupport::TimeZone["UTC"].at(decrypted_data["created_at"])
|
57
|
+
if token_created_at < last_login
|
58
|
+
raise ExpiredTokenError
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
[resource, decrypted_data]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require "globalid"
|
2
|
+
|
3
|
+
module Devise::Passwordless
|
4
|
+
class SignedGlobalIDTokenizer
|
5
|
+
def self.encode(resource, expires_in: nil, expires_at: nil)
|
6
|
+
if expires_at
|
7
|
+
resource.to_sgid(expires_at: expires_at, for: "login").to_s
|
8
|
+
else
|
9
|
+
resource.to_sgid(expires_in: expires_in || resource.class.passwordless_login_within, for: "login").to_s
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.decode(token, resource_class)
|
14
|
+
resource = GlobalID::Locator.locate_signed(token, for: "login")
|
15
|
+
raise ExpiredTokenError unless resource
|
16
|
+
raise InvalidTokenError if resource.class != resource_class
|
17
|
+
[resource, {}]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/lib/devise/passwordless.rb
CHANGED
@@ -1,3 +1,27 @@
|
|
1
1
|
require "devise/passwordless/version"
|
2
|
+
require "devise/monkeypatch"
|
3
|
+
require "devise/passwordless/rails" if defined?(Rails::Engine)
|
2
4
|
require "devise/models/magic_link_authenticatable"
|
3
5
|
require "generators/devise/passwordless/install_generator"
|
6
|
+
require "devise/passwordless/tokenizers/message_encryptor_tokenizer"
|
7
|
+
require "devise/passwordless/tokenizers/signed_global_id_tokenizer"
|
8
|
+
|
9
|
+
module Devise
|
10
|
+
module Passwordless
|
11
|
+
class InvalidOrExpiredTokenError < StandardError; end
|
12
|
+
class InvalidTokenError < InvalidOrExpiredTokenError; end
|
13
|
+
class ExpiredTokenError < InvalidOrExpiredTokenError; end
|
14
|
+
|
15
|
+
def self.deprecator
|
16
|
+
@deprecator ||= ActiveSupport::Deprecation.new("1.1", "Devise-Passwordless")
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.secret_key
|
20
|
+
if Devise.passwordless_secret_key.present?
|
21
|
+
Devise.passwordless_secret_key
|
22
|
+
else
|
23
|
+
Devise.secret_key
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|