rodauth 2.2.0 → 2.7.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|