devise-passwordless 0.7.0 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|