rodauth 2.2.0 → 2.7.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 +50 -0
- data/README.rdoc +14 -0
- data/doc/base.rdoc +3 -1
- data/doc/jwt_refresh.rdoc +13 -0
- data/doc/login.rdoc +8 -0
- data/doc/login_password_requirements_base.rdoc +3 -0
- data/doc/password_pepper.rdoc +44 -0
- data/doc/recovery_codes.rdoc +2 -1
- data/doc/release_notes/2.3.0.txt +37 -0
- data/doc/release_notes/2.4.0.txt +22 -0
- data/doc/release_notes/2.5.0.txt +20 -0
- data/doc/release_notes/2.6.0.txt +37 -0
- data/doc/release_notes/2.7.0.txt +33 -0
- data/doc/remember.rdoc +1 -1
- data/doc/verify_login_change.rdoc +1 -0
- data/javascript/webauthn_auth.js +9 -9
- data/javascript/webauthn_setup.js +9 -6
- data/lib/rodauth.rb +14 -6
- data/lib/rodauth/features/base.rb +19 -4
- data/lib/rodauth/features/change_password.rb +1 -1
- data/lib/rodauth/features/close_account.rb +8 -6
- data/lib/rodauth/features/confirm_password.rb +2 -2
- data/lib/rodauth/features/disallow_password_reuse.rb +4 -2
- data/lib/rodauth/features/email_auth.rb +1 -1
- data/lib/rodauth/features/jwt.rb +11 -3
- data/lib/rodauth/features/jwt_refresh.rb +70 -8
- data/lib/rodauth/features/login.rb +23 -12
- data/lib/rodauth/features/login_password_requirements_base.rb +9 -4
- data/lib/rodauth/features/otp.rb +0 -2
- data/lib/rodauth/features/password_pepper.rb +45 -0
- data/lib/rodauth/features/recovery_codes.rb +22 -1
- data/lib/rodauth/features/remember.rb +6 -1
- data/lib/rodauth/features/session_expiration.rb +1 -6
- data/lib/rodauth/features/verify_account.rb +6 -7
- data/lib/rodauth/features/verify_login_change.rb +2 -1
- data/lib/rodauth/features/webauthn_login.rb +1 -1
- data/lib/rodauth/migrations.rb +16 -5
- data/lib/rodauth/version.rb +1 -1
- metadata +16 -3
@@ -1,26 +1,29 @@
|
|
1
1
|
(function() {
|
2
|
+
var pack = function(v) { return btoa(String.fromCharCode.apply(null, new Uint8Array(v))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); };
|
3
|
+
var unpack = function(v) { return Uint8Array.from(atob(v.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); };
|
2
4
|
var element = document.getElementById('webauthn-setup-form');
|
3
5
|
var f = function(e) {
|
4
6
|
//console.log(e);
|
5
7
|
e.preventDefault();
|
6
8
|
if (navigator.credentials) {
|
7
9
|
var opts = JSON.parse(element.getAttribute("data-credential-options"));
|
8
|
-
opts.challenge =
|
9
|
-
opts.user.id =
|
10
|
+
opts.challenge = unpack(opts.challenge);
|
11
|
+
opts.user.id = unpack(opts.user.id);
|
12
|
+
opts.excludeCredentials.forEach(function(cred) { cred.id = unpack(cred.id); });
|
10
13
|
//console.log(opts);
|
11
14
|
navigator.credentials.create({publicKey: opts}).
|
12
15
|
then(function(cred){
|
13
16
|
//console.log(cred);
|
14
17
|
//window.cred = cred
|
15
|
-
|
16
|
-
var rawId =
|
18
|
+
|
19
|
+
var rawId = pack(cred.rawId);
|
17
20
|
document.getElementById('webauthn-setup').value = JSON.stringify({
|
18
21
|
type: cred.type,
|
19
22
|
id: rawId,
|
20
23
|
rawId: rawId,
|
21
24
|
response: {
|
22
|
-
attestationObject:
|
23
|
-
clientDataJSON:
|
25
|
+
attestationObject: pack(cred.response.attestationObject),
|
26
|
+
clientDataJSON: pack(cred.response.clientDataJSON)
|
24
27
|
}
|
25
28
|
});
|
26
29
|
element.removeEventListener("submit", f);
|
data/lib/rodauth.rb
CHANGED
@@ -66,6 +66,7 @@ module Rodauth
|
|
66
66
|
define_method(meth) do |&block|
|
67
67
|
@auth.send(:define_method, meth, &block)
|
68
68
|
@auth.send(:private, meth) if priv
|
69
|
+
@auth.send(:alias_method, meth, meth)
|
69
70
|
end
|
70
71
|
end
|
71
72
|
|
@@ -74,6 +75,7 @@ module Rodauth
|
|
74
75
|
define_method(meth) do |&block|
|
75
76
|
@auth.send(:define_method, umeth, &block)
|
76
77
|
@auth.send(:private, umeth)
|
78
|
+
@auth.send(:alias_method, umeth, umeth)
|
77
79
|
end
|
78
80
|
end
|
79
81
|
|
@@ -82,6 +84,7 @@ module Rodauth
|
|
82
84
|
block ||= proc{v}
|
83
85
|
@auth.send(:define_method, meth, &block)
|
84
86
|
@auth.send(:private, meth) if priv
|
87
|
+
@auth.send(:alias_method, meth, meth)
|
85
88
|
end
|
86
89
|
end
|
87
90
|
end
|
@@ -120,8 +123,10 @@ module Rodauth
|
|
120
123
|
define_method(handle_meth) do
|
121
124
|
request.is send(route_meth) do
|
122
125
|
check_csrf if check_csrf?
|
123
|
-
|
124
|
-
|
126
|
+
_around_rodauth do
|
127
|
+
before_rodauth
|
128
|
+
send(internal_handle_meth, request)
|
129
|
+
end
|
125
130
|
end
|
126
131
|
end
|
127
132
|
|
@@ -238,6 +243,7 @@ module Rodauth
|
|
238
243
|
instance_variable_set(iv, send(umeth))
|
239
244
|
end
|
240
245
|
end
|
246
|
+
alias_method(meth, meth)
|
241
247
|
auth_private_methods(meth)
|
242
248
|
end
|
243
249
|
|
@@ -288,15 +294,17 @@ module Rodauth
|
|
288
294
|
end
|
289
295
|
|
290
296
|
def enable(*features)
|
291
|
-
|
292
|
-
|
293
|
-
|
297
|
+
features.each do |feature|
|
298
|
+
next if @auth.features.include?(feature)
|
299
|
+
load_feature(feature)
|
300
|
+
@auth.features << feature
|
301
|
+
end
|
294
302
|
end
|
295
303
|
|
296
304
|
private
|
297
305
|
|
298
306
|
def load_feature(feature_name)
|
299
|
-
require "rodauth/features/#{feature_name}"
|
307
|
+
require "rodauth/features/#{feature_name}" unless FEATURES[feature_name]
|
300
308
|
feature = FEATURES[feature_name]
|
301
309
|
enable(*feature.dependencies)
|
302
310
|
extend feature.configuration
|
@@ -47,6 +47,7 @@ module Rodauth
|
|
47
47
|
session_key :authenticated_by_session_key, :authenticated_by
|
48
48
|
session_key :autologin_type_session_key, :autologin_type
|
49
49
|
auth_value_method :prefix, ''
|
50
|
+
auth_value_method :session_key_prefix, nil
|
50
51
|
auth_value_method :require_bcrypt?, true
|
51
52
|
auth_value_method :mark_input_fields_as_required?, true
|
52
53
|
auth_value_method :mark_input_fields_with_autocomplete?, true
|
@@ -101,7 +102,6 @@ module Rodauth
|
|
101
102
|
:set_redirect_error_flash,
|
102
103
|
:set_title,
|
103
104
|
:translate,
|
104
|
-
:unverified_account_message,
|
105
105
|
:update_session
|
106
106
|
)
|
107
107
|
|
@@ -110,7 +110,8 @@ module Rodauth
|
|
110
110
|
:account_from_session,
|
111
111
|
:field_attributes,
|
112
112
|
:field_error_attributes,
|
113
|
-
:formatted_field_error
|
113
|
+
:formatted_field_error,
|
114
|
+
:around_rodauth
|
114
115
|
)
|
115
116
|
|
116
117
|
configuration_module_eval do
|
@@ -259,6 +260,7 @@ module Rodauth
|
|
259
260
|
@password_field_autocomplete_value || 'current-password'
|
260
261
|
end
|
261
262
|
|
263
|
+
alias account_password_hash_column account_password_hash_column
|
262
264
|
# If the account_password_hash_column is set, the password hash is verified in
|
263
265
|
# ruby, it will not use a database function to do so, it will check the password
|
264
266
|
# hash using bcrypt.
|
@@ -393,9 +395,9 @@ module Rodauth
|
|
393
395
|
def password_match?(password)
|
394
396
|
if hash = get_password_hash
|
395
397
|
if account_password_hash_column || !use_database_authentication_functions?
|
396
|
-
|
398
|
+
password_hash_match?(hash, password)
|
397
399
|
else
|
398
|
-
|
400
|
+
database_function_password_match?(:rodauth_valid_password_hash, account_id, password, hash)
|
399
401
|
end
|
400
402
|
end
|
401
403
|
end
|
@@ -458,6 +460,18 @@ module Rodauth
|
|
458
460
|
|
459
461
|
private
|
460
462
|
|
463
|
+
def _around_rodauth
|
464
|
+
yield
|
465
|
+
end
|
466
|
+
|
467
|
+
def database_function_password_match?(name, hash_id, password, salt)
|
468
|
+
db.get(Sequel.function(function_name(name), hash_id, BCrypt::Engine.hash_secret(password, salt)))
|
469
|
+
end
|
470
|
+
|
471
|
+
def password_hash_match?(hash, password)
|
472
|
+
BCrypt::Password.new(hash) == password
|
473
|
+
end
|
474
|
+
|
461
475
|
def convert_token_key(key)
|
462
476
|
if key && hmac_secret
|
463
477
|
compute_hmac(key)
|
@@ -493,6 +507,7 @@ module Rodauth
|
|
493
507
|
end
|
494
508
|
|
495
509
|
def convert_session_key(key)
|
510
|
+
key = "#{session_key_prefix}#{key}".to_sym if session_key_prefix
|
496
511
|
scope.opts[:sessions_convert_symbols] ? key.to_s : key
|
497
512
|
end
|
498
513
|
|
@@ -33,7 +33,11 @@ module Rodauth
|
|
33
33
|
end
|
34
34
|
|
35
35
|
r.post do
|
36
|
-
|
36
|
+
catch_error do
|
37
|
+
if close_account_requires_password? && !password_match?(param(password_param))
|
38
|
+
throw_error_status(invalid_password_error_status, password_param, invalid_password_message)
|
39
|
+
end
|
40
|
+
|
37
41
|
transaction do
|
38
42
|
before_close_account
|
39
43
|
close_account
|
@@ -46,12 +50,10 @@ module Rodauth
|
|
46
50
|
|
47
51
|
set_notice_flash close_account_notice_flash
|
48
52
|
redirect close_account_redirect
|
49
|
-
else
|
50
|
-
set_response_error_status(invalid_password_error_status)
|
51
|
-
set_field_error(password_param, invalid_password_message)
|
52
|
-
set_error_flash close_account_error_flash
|
53
|
-
close_account_view
|
54
53
|
end
|
54
|
+
|
55
|
+
set_error_flash close_account_error_flash
|
56
|
+
close_account_view
|
55
57
|
end
|
56
58
|
end
|
57
59
|
|
@@ -26,11 +26,11 @@ module Rodauth
|
|
26
26
|
require_account_session
|
27
27
|
before_confirm_password_route
|
28
28
|
|
29
|
-
|
29
|
+
r.get do
|
30
30
|
confirm_password_view
|
31
31
|
end
|
32
32
|
|
33
|
-
|
33
|
+
r.post do
|
34
34
|
if password_match?(param(password_param))
|
35
35
|
transaction do
|
36
36
|
before_confirm_password
|
@@ -51,11 +51,13 @@ module Rodauth
|
|
51
51
|
return true if salts.empty?
|
52
52
|
|
53
53
|
salts.any? do |hash_id, salt|
|
54
|
-
|
54
|
+
database_function_password_match?(:rodauth_previous_password_hash_match, hash_id, password, salt)
|
55
55
|
end
|
56
56
|
else
|
57
57
|
# :nocov:
|
58
|
-
previous_password_ds.select_map(previous_password_hash_column).any?
|
58
|
+
previous_password_ds.select_map(previous_password_hash_column).any? do |hash|
|
59
|
+
password_hash_match?(hash, password)
|
60
|
+
end
|
59
61
|
# :nocov:
|
60
62
|
end
|
61
63
|
|
data/lib/rodauth/features/jwt.rb
CHANGED
@@ -47,7 +47,7 @@ module Rodauth
|
|
47
47
|
s = {}
|
48
48
|
if jwt_token
|
49
49
|
unless session_data = jwt_payload
|
50
|
-
json_response[json_response_error_key]
|
50
|
+
json_response[json_response_error_key] ||= invalid_jwt_format_error_message
|
51
51
|
response.status ||= json_response_error_status
|
52
52
|
response['Content-Type'] ||= json_response_content_type
|
53
53
|
response.write(_json_response_body(json_response))
|
@@ -229,10 +229,18 @@ module Rodauth
|
|
229
229
|
end
|
230
230
|
end
|
231
231
|
|
232
|
+
def _jwt_decode_opts
|
233
|
+
jwt_decode_opts
|
234
|
+
end
|
235
|
+
|
232
236
|
def jwt_payload
|
233
237
|
return @jwt_payload if defined?(@jwt_payload)
|
234
|
-
@jwt_payload = JWT.decode(jwt_token, jwt_secret, true,
|
235
|
-
rescue JWT::DecodeError
|
238
|
+
@jwt_payload = JWT.decode(jwt_token, jwt_secret, true, _jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
|
239
|
+
rescue JWT::DecodeError => e
|
240
|
+
rescue_jwt_payload(e)
|
241
|
+
end
|
242
|
+
|
243
|
+
def rescue_jwt_payload(_)
|
236
244
|
@jwt_payload = false
|
237
245
|
end
|
238
246
|
|
@@ -7,6 +7,9 @@ module Rodauth
|
|
7
7
|
after 'refresh_token'
|
8
8
|
before 'refresh_token'
|
9
9
|
|
10
|
+
auth_value_method :allow_refresh_with_expired_jwt_access_token?, false
|
11
|
+
session_key :jwt_refresh_token_data_session_key, :jwt_refresh_token_data
|
12
|
+
session_key :jwt_refresh_token_hmac_session_key, :jwt_refresh_token_hash
|
10
13
|
auth_value_method :jwt_access_token_key, 'access_token'
|
11
14
|
auth_value_method :jwt_access_token_not_before_period, 5
|
12
15
|
auth_value_method :jwt_access_token_period, 1800
|
@@ -19,23 +22,33 @@ module Rodauth
|
|
19
22
|
auth_value_method :jwt_refresh_token_key_column, :key
|
20
23
|
auth_value_method :jwt_refresh_token_key_param, 'refresh_token'
|
21
24
|
auth_value_method :jwt_refresh_token_table, :account_jwt_refresh_keys
|
25
|
+
translatable_method :jwt_refresh_without_access_token_message, 'no JWT access token provided during refresh'
|
26
|
+
auth_value_method :jwt_refresh_without_access_token_status, 401
|
27
|
+
translatable_method :expired_jwt_access_token_message, "expired JWT access token"
|
28
|
+
auth_value_method :expired_jwt_access_token_status, 400
|
22
29
|
|
23
30
|
auth_private_methods(
|
24
31
|
:account_from_refresh_token
|
25
32
|
)
|
26
33
|
|
27
34
|
route do |r|
|
35
|
+
@jwt_refresh_route = true
|
36
|
+
before_jwt_refresh_route
|
37
|
+
|
28
38
|
r.post do
|
29
|
-
if
|
30
|
-
|
39
|
+
if !session_value
|
40
|
+
response.status ||= jwt_refresh_without_access_token_status
|
41
|
+
json_response[json_response_error_key] = jwt_refresh_without_access_token_message
|
42
|
+
elsif (refresh_token = param_or_nil(jwt_refresh_token_key_param)) && account_from_refresh_token(refresh_token)
|
31
43
|
transaction do
|
32
44
|
before_refresh_token
|
33
45
|
formatted_token = generate_refresh_token
|
34
46
|
remove_jwt_refresh_token_key(refresh_token)
|
47
|
+
set_jwt_refresh_token_hmac_session_key(formatted_token)
|
48
|
+
json_response[jwt_refresh_token_key] = formatted_token
|
49
|
+
json_response[jwt_access_token_key] = session_jwt
|
35
50
|
after_refresh_token
|
36
51
|
end
|
37
|
-
json_response[jwt_refresh_token_key] = formatted_token
|
38
|
-
json_response[jwt_access_token_key] = session_jwt
|
39
52
|
else
|
40
53
|
json_response[json_response_error_key] = jwt_refresh_invalid_token_message
|
41
54
|
response.status ||= json_response_error_status
|
@@ -52,7 +65,9 @@ module Rodauth
|
|
52
65
|
# JWT login puts the access token in the header.
|
53
66
|
# We put the refresh token in the body.
|
54
67
|
# Note, do not put the access_token in the body here, as the access token content is not yet finalised.
|
55
|
-
json_response['refresh_token'] = generate_refresh_token
|
68
|
+
token = json_response['refresh_token'] = generate_refresh_token
|
69
|
+
|
70
|
+
set_jwt_refresh_token_hmac_session_key(token)
|
56
71
|
end
|
57
72
|
|
58
73
|
def set_jwt_token(token)
|
@@ -79,12 +94,33 @@ module Rodauth
|
|
79
94
|
|
80
95
|
private
|
81
96
|
|
97
|
+
def rescue_jwt_payload(e)
|
98
|
+
if e.instance_of?(JWT::ExpiredSignature)
|
99
|
+
begin
|
100
|
+
# Some versions of jwt will raise JWT::ExpiredSignature even when the
|
101
|
+
# JWT is invalid for other reasons. Make sure the expiration is the
|
102
|
+
# only reason the JWT isn't valid before treating this as an expired token.
|
103
|
+
JWT.decode(jwt_token, jwt_secret, true, Hash[jwt_decode_opts].merge!(:verify_expiration=>false, :algorithm=>jwt_algorithm))[0]
|
104
|
+
rescue => e
|
105
|
+
else
|
106
|
+
json_response[json_response_error_key] = expired_jwt_access_token_message
|
107
|
+
response.status ||= expired_jwt_access_token_status
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
super
|
112
|
+
end
|
113
|
+
|
82
114
|
def _account_from_refresh_token(token)
|
83
115
|
id, token_id, key = _account_refresh_token_split(token)
|
84
116
|
|
85
|
-
|
86
|
-
|
87
|
-
|
117
|
+
unless key &&
|
118
|
+
(id == session_value.to_s) &&
|
119
|
+
(actual = get_active_refresh_token(id, token_id)) &&
|
120
|
+
timing_safe_eql?(key, convert_token_key(actual)) &&
|
121
|
+
jwt_refresh_token_match?(key)
|
122
|
+
return
|
123
|
+
end
|
88
124
|
|
89
125
|
ds = account_ds(id)
|
90
126
|
ds = ds.where(account_status_column=>account_open_status_value) unless skip_status_checks?
|
@@ -101,6 +137,23 @@ module Rodauth
|
|
101
137
|
[id, token_id, key]
|
102
138
|
end
|
103
139
|
|
140
|
+
def _jwt_decode_opts
|
141
|
+
if allow_refresh_with_expired_jwt_access_token? && @jwt_refresh_route
|
142
|
+
Hash[super].merge!(:verify_expiration=>false)
|
143
|
+
else
|
144
|
+
super
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def jwt_refresh_token_match?(key)
|
149
|
+
# We don't need to match tokens if we are requiring a valid current access token
|
150
|
+
return true unless allow_refresh_with_expired_jwt_access_token?
|
151
|
+
|
152
|
+
# If allowing with expired jwt access token, check the expired session contains
|
153
|
+
# hmac matching submitted and active refresh token.
|
154
|
+
timing_safe_eql?(compute_hmac(session[jwt_refresh_token_data_session_key].to_s + key), session[jwt_refresh_token_hmac_session_key].to_s)
|
155
|
+
end
|
156
|
+
|
104
157
|
def get_active_refresh_token(account_id, token_id)
|
105
158
|
jwt_refresh_token_account_ds(account_id).
|
106
159
|
where(Sequel::CURRENT_TIMESTAMP > jwt_refresh_token_deadline_column).
|
@@ -140,6 +193,15 @@ module Rodauth
|
|
140
193
|
hash
|
141
194
|
end
|
142
195
|
|
196
|
+
def set_jwt_refresh_token_hmac_session_key(token)
|
197
|
+
if allow_refresh_with_expired_jwt_access_token?
|
198
|
+
key = _account_refresh_token_split(token).last
|
199
|
+
data = random_key
|
200
|
+
set_session_value(jwt_refresh_token_data_session_key, data)
|
201
|
+
set_session_value(jwt_refresh_token_hmac_session_key, compute_hmac(data + key))
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
143
205
|
def before_logout
|
144
206
|
if token = param_or_nil(jwt_refresh_token_key_param)
|
145
207
|
if token == 'all'
|
@@ -23,6 +23,8 @@ module Rodauth
|
|
23
23
|
auth_cached_method :login_form_footer_links
|
24
24
|
auth_cached_method :login_form_footer
|
25
25
|
|
26
|
+
auth_value_methods :login_return_to_requested_location_path
|
27
|
+
|
26
28
|
route do |r|
|
27
29
|
check_already_logged_in
|
28
30
|
before_login_route
|
@@ -62,7 +64,7 @@ module Rodauth
|
|
62
64
|
throw_error_status(login_error_status, password_param, invalid_password_message)
|
63
65
|
end
|
64
66
|
|
65
|
-
|
67
|
+
login('password')
|
66
68
|
end
|
67
69
|
|
68
70
|
set_error_flash login_error_flash unless skip_error_flash
|
@@ -72,13 +74,29 @@ module Rodauth
|
|
72
74
|
|
73
75
|
attr_reader :login_form_header
|
74
76
|
|
77
|
+
def login(auth_type)
|
78
|
+
saved_login_redirect = remove_session_value(login_redirect_session_key)
|
79
|
+
transaction do
|
80
|
+
before_login
|
81
|
+
login_session(auth_type)
|
82
|
+
yield if block_given?
|
83
|
+
after_login
|
84
|
+
end
|
85
|
+
set_notice_flash login_notice_flash
|
86
|
+
redirect(saved_login_redirect || login_redirect)
|
87
|
+
end
|
88
|
+
|
75
89
|
def login_required
|
76
|
-
if login_return_to_requested_location?
|
77
|
-
set_session_value(login_redirect_session_key,
|
90
|
+
if login_return_to_requested_location? && (path = login_return_to_requested_location_path)
|
91
|
+
set_session_value(login_redirect_session_key, path)
|
78
92
|
end
|
79
93
|
super
|
80
94
|
end
|
81
95
|
|
96
|
+
def login_return_to_requested_location_path
|
97
|
+
request.fullpath if request.get?
|
98
|
+
end
|
99
|
+
|
82
100
|
def after_login_entered_during_multi_phase_login
|
83
101
|
set_notice_now_flash need_password_notice_flash
|
84
102
|
if multi_phase_login_forms.length == 1 && (meth = multi_phase_login_forms[0][2])
|
@@ -126,15 +144,8 @@ module Rodauth
|
|
126
144
|
end
|
127
145
|
|
128
146
|
def _login(auth_type)
|
129
|
-
|
130
|
-
|
131
|
-
before_login
|
132
|
-
login_session(auth_type)
|
133
|
-
yield if block_given?
|
134
|
-
after_login
|
135
|
-
end
|
136
|
-
set_notice_flash login_notice_flash
|
137
|
-
redirect(saved_login_redirect || login_redirect)
|
147
|
+
warn("Deprecated #_login method called, use #login instead.")
|
148
|
+
login(auth_type)
|
138
149
|
end
|
139
150
|
end
|
140
151
|
end
|