rodauth 2.6.0 → 2.11.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 +42 -0
- data/MIT-LICENSE +1 -1
- data/README.rdoc +21 -6
- data/doc/argon2.rdoc +49 -0
- data/doc/base.rdoc +1 -1
- data/doc/change_login.rdoc +1 -0
- data/doc/guides/migrate_password_hash_algorithm.rdoc +15 -0
- data/doc/json.rdoc +47 -0
- data/doc/jwt.rdoc +1 -28
- data/doc/jwt_refresh.rdoc +2 -0
- data/doc/login_password_requirements_base.rdoc +2 -1
- data/doc/recovery_codes.rdoc +2 -1
- data/doc/release_notes/2.10.0.txt +47 -0
- data/doc/release_notes/2.11.0.txt +31 -0
- data/doc/release_notes/2.7.0.txt +33 -0
- data/doc/release_notes/2.8.0.txt +20 -0
- data/doc/release_notes/2.9.0.txt +21 -0
- data/doc/remember.rdoc +1 -1
- data/lib/rodauth.rb +17 -4
- data/lib/rodauth/features/argon2.rb +69 -0
- data/lib/rodauth/features/base.rb +6 -2
- data/lib/rodauth/features/change_login.rb +2 -1
- data/lib/rodauth/features/disallow_password_reuse.rb +20 -7
- data/lib/rodauth/features/email_base.rb +5 -2
- data/lib/rodauth/features/json.rb +189 -0
- data/lib/rodauth/features/jwt.rb +19 -171
- data/lib/rodauth/features/jwt_refresh.rb +23 -10
- data/lib/rodauth/features/login_password_requirements_base.rb +6 -1
- data/lib/rodauth/features/otp.rb +0 -2
- data/lib/rodauth/features/recovery_codes.rb +22 -1
- data/lib/rodauth/features/remember.rb +6 -1
- data/lib/rodauth/features/reset_password.rb +1 -0
- data/lib/rodauth/features/update_password_hash.rb +1 -1
- data/lib/rodauth/features/verify_account.rb +0 -1
- data/lib/rodauth/features/webauthn_verify_account.rb +1 -1
- data/lib/rodauth/migrations.rb +31 -5
- data/lib/rodauth/version.rb +1 -1
- metadata +55 -24
data/lib/rodauth/features/jwt.rb
CHANGED
@@ -4,41 +4,29 @@ require 'jwt'
|
|
4
4
|
|
5
5
|
module Rodauth
|
6
6
|
Feature.define(:jwt, :Jwt) do
|
7
|
+
depends :json
|
8
|
+
|
7
9
|
translatable_method :invalid_jwt_format_error_message, "invalid JWT format or claim in Authorization header"
|
8
|
-
translatable_method :json_non_post_error_message, 'non-POST method used in JSON API'
|
9
|
-
translatable_method :json_not_accepted_error_message, 'Unsupported Accept header. Must accept "application/json" or compatible content type'
|
10
|
-
auth_value_method :json_accept_regexp, /(?:(?:\*|\bapplication)\/\*|\bapplication\/(?:vnd\.api\+)?json\b)/i
|
11
|
-
auth_value_method :json_request_content_type_regexp, /\bapplication\/(?:vnd\.api\+)?json\b/i
|
12
|
-
auth_value_method :json_response_content_type, 'application/json'
|
13
|
-
auth_value_method :json_response_error_status, 400
|
14
|
-
auth_value_method :json_response_custom_error_status?, true
|
15
|
-
auth_value_method :json_response_error_key, "error"
|
16
|
-
auth_value_method :json_response_field_error_key, "field-error"
|
17
|
-
auth_value_method :json_response_success_key, "success"
|
18
10
|
auth_value_method :jwt_algorithm, "HS256"
|
19
11
|
auth_value_method :jwt_authorization_ignore, /\A(?:Basic|Digest) /
|
20
12
|
auth_value_method :jwt_authorization_remove, /\ABearer:?\s+/
|
21
|
-
auth_value_method :jwt_check_accept?, true
|
22
13
|
auth_value_method :jwt_decode_opts, {}.freeze
|
23
14
|
auth_value_method :jwt_session_key, nil
|
24
15
|
auth_value_method :jwt_symbolize_deeply?, false
|
25
|
-
translatable_method :non_json_request_error_message, 'Only JSON format requests are allowed'
|
26
16
|
|
27
17
|
auth_value_methods(
|
28
|
-
:only_json?,
|
29
18
|
:jwt_secret,
|
30
19
|
:use_jwt?
|
31
20
|
)
|
32
21
|
|
33
22
|
auth_methods(
|
34
|
-
:json_request?,
|
35
23
|
:jwt_session_hash,
|
36
24
|
:jwt_token,
|
37
25
|
:session_jwt,
|
38
26
|
:set_jwt_token
|
39
27
|
)
|
40
28
|
|
41
|
-
|
29
|
+
def_deprecated_alias :json_check_accept?, :jwt_check_accept?
|
42
30
|
|
43
31
|
def session
|
44
32
|
return @session if defined?(@session)
|
@@ -47,11 +35,8 @@ module Rodauth
|
|
47
35
|
s = {}
|
48
36
|
if jwt_token
|
49
37
|
unless session_data = jwt_payload
|
50
|
-
json_response[json_response_error_key]
|
51
|
-
|
52
|
-
response['Content-Type'] ||= json_response_content_type
|
53
|
-
response.write(_json_response_body(json_response))
|
54
|
-
request.halt
|
38
|
+
json_response[json_response_error_key] ||= invalid_jwt_format_error_message
|
39
|
+
_return_json_response
|
55
40
|
end
|
56
41
|
|
57
42
|
if jwt_session_key
|
@@ -73,37 +58,10 @@ module Rodauth
|
|
73
58
|
|
74
59
|
def clear_session
|
75
60
|
super
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
return super unless use_jwt?
|
81
|
-
json_response[json_response_field_error_key] = [field, message]
|
82
|
-
end
|
83
|
-
|
84
|
-
def set_error_flash(message)
|
85
|
-
return super unless use_jwt?
|
86
|
-
json_response[json_response_error_key] = message
|
87
|
-
end
|
88
|
-
|
89
|
-
def set_redirect_error_flash(message)
|
90
|
-
return super unless use_jwt?
|
91
|
-
json_response[json_response_error_key] = message
|
92
|
-
end
|
93
|
-
|
94
|
-
def set_notice_flash(message)
|
95
|
-
return super unless use_jwt?
|
96
|
-
json_response[json_response_success_key] = message if include_success_messages?
|
97
|
-
end
|
98
|
-
|
99
|
-
def set_notice_now_flash(message)
|
100
|
-
return super unless use_jwt?
|
101
|
-
json_response[json_response_success_key] = message if include_success_messages?
|
102
|
-
end
|
103
|
-
|
104
|
-
def json_request?
|
105
|
-
return @json_request if defined?(@json_request)
|
106
|
-
@json_request = request.content_type =~ json_request_content_type_regexp
|
61
|
+
if use_jwt?
|
62
|
+
session.clear
|
63
|
+
set_jwt
|
64
|
+
end
|
107
65
|
end
|
108
66
|
|
109
67
|
def jwt_secret
|
@@ -131,16 +89,15 @@ module Rodauth
|
|
131
89
|
end
|
132
90
|
|
133
91
|
def use_jwt?
|
134
|
-
|
92
|
+
use_json?
|
135
93
|
end
|
136
94
|
|
137
|
-
def
|
138
|
-
|
95
|
+
def use_json?
|
96
|
+
jwt_token || super
|
139
97
|
end
|
140
98
|
|
141
|
-
def
|
142
|
-
|
143
|
-
return_json_response
|
99
|
+
def valid_jwt?
|
100
|
+
!!(jwt_token && jwt_payload)
|
144
101
|
end
|
145
102
|
|
146
103
|
private
|
@@ -150,85 +107,6 @@ module Rodauth
|
|
150
107
|
super
|
151
108
|
end
|
152
109
|
|
153
|
-
def before_rodauth
|
154
|
-
if json_request?
|
155
|
-
if jwt_check_accept? && (accept = request.env['HTTP_ACCEPT']) && accept !~ json_accept_regexp
|
156
|
-
response.status = 406
|
157
|
-
json_response[json_response_error_key] = json_not_accepted_error_message
|
158
|
-
response['Content-Type'] ||= json_response_content_type
|
159
|
-
response.write(_json_response_body(json_response))
|
160
|
-
request.halt
|
161
|
-
end
|
162
|
-
|
163
|
-
unless request.post?
|
164
|
-
response.status = 405
|
165
|
-
response.headers['Allow'] = 'POST'
|
166
|
-
json_response[json_response_error_key] = json_non_post_error_message
|
167
|
-
return_json_response
|
168
|
-
end
|
169
|
-
elsif only_json?
|
170
|
-
response.status = json_response_error_status
|
171
|
-
response.write non_json_request_error_message
|
172
|
-
request.halt
|
173
|
-
end
|
174
|
-
|
175
|
-
super
|
176
|
-
end
|
177
|
-
|
178
|
-
def before_view_recovery_codes
|
179
|
-
super if defined?(super)
|
180
|
-
if use_jwt?
|
181
|
-
json_response[:codes] = recovery_codes
|
182
|
-
json_response[json_response_success_key] ||= "" if include_success_messages?
|
183
|
-
end
|
184
|
-
end
|
185
|
-
|
186
|
-
def before_webauthn_setup_route
|
187
|
-
super if defined?(super)
|
188
|
-
if use_jwt? && !param_or_nil(webauthn_setup_param)
|
189
|
-
cred = new_webauthn_credential
|
190
|
-
json_response[webauthn_setup_param] = cred.as_json
|
191
|
-
json_response[webauthn_setup_challenge_param] = cred.challenge
|
192
|
-
json_response[webauthn_setup_challenge_hmac_param] = compute_hmac(cred.challenge)
|
193
|
-
end
|
194
|
-
end
|
195
|
-
|
196
|
-
def before_webauthn_auth_route
|
197
|
-
super if defined?(super)
|
198
|
-
if use_jwt? && !param_or_nil(webauthn_auth_param)
|
199
|
-
cred = webauth_credential_options_for_get
|
200
|
-
json_response[webauthn_auth_param] = cred.as_json
|
201
|
-
json_response[webauthn_auth_challenge_param] = cred.challenge
|
202
|
-
json_response[webauthn_auth_challenge_hmac_param] = compute_hmac(cred.challenge)
|
203
|
-
end
|
204
|
-
end
|
205
|
-
|
206
|
-
def before_webauthn_login_route
|
207
|
-
super if defined?(super)
|
208
|
-
if use_jwt? && !param_or_nil(webauthn_auth_param) && account_from_login(param(login_param))
|
209
|
-
cred = webauth_credential_options_for_get
|
210
|
-
json_response[webauthn_auth_param] = cred.as_json
|
211
|
-
json_response[webauthn_auth_challenge_param] = cred.challenge
|
212
|
-
json_response[webauthn_auth_challenge_hmac_param] = compute_hmac(cred.challenge)
|
213
|
-
end
|
214
|
-
end
|
215
|
-
|
216
|
-
def before_webauthn_remove_route
|
217
|
-
super if defined?(super)
|
218
|
-
if use_jwt? && !param_or_nil(webauthn_remove_param)
|
219
|
-
json_response[webauthn_remove_param] = account_webauthn_usage
|
220
|
-
end
|
221
|
-
end
|
222
|
-
|
223
|
-
def before_otp_setup_route
|
224
|
-
super if defined?(super)
|
225
|
-
if use_jwt? && otp_keys_use_hmac? && !param_or_nil(otp_setup_raw_param)
|
226
|
-
_otp_tmp_key(otp_new_secret)
|
227
|
-
json_response[otp_setup_param] = otp_user_key
|
228
|
-
json_response[otp_setup_raw_param] = otp_key
|
229
|
-
end
|
230
|
-
end
|
231
|
-
|
232
110
|
def _jwt_decode_opts
|
233
111
|
jwt_decode_opts
|
234
112
|
end
|
@@ -236,17 +114,12 @@ module Rodauth
|
|
236
114
|
def jwt_payload
|
237
115
|
return @jwt_payload if defined?(@jwt_payload)
|
238
116
|
@jwt_payload = JWT.decode(jwt_token, jwt_secret, true, _jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
|
239
|
-
rescue JWT::DecodeError
|
240
|
-
|
117
|
+
rescue JWT::DecodeError => e
|
118
|
+
rescue_jwt_payload(e)
|
241
119
|
end
|
242
120
|
|
243
|
-
def
|
244
|
-
|
245
|
-
return_json_response
|
246
|
-
end
|
247
|
-
|
248
|
-
def include_success_messages?
|
249
|
-
!json_response_success_key.nil?
|
121
|
+
def rescue_jwt_payload(_)
|
122
|
+
@jwt_payload = false
|
250
123
|
end
|
251
124
|
|
252
125
|
def set_session_value(key, value)
|
@@ -261,38 +134,13 @@ module Rodauth
|
|
261
134
|
value
|
262
135
|
end
|
263
136
|
|
264
|
-
def json_response
|
265
|
-
@json_response ||= {}
|
266
|
-
end
|
267
|
-
|
268
|
-
def _json_response_body(hash)
|
269
|
-
request.send(:convert_to_json, hash)
|
270
|
-
end
|
271
|
-
|
272
137
|
def return_json_response
|
273
|
-
response.status ||= json_response_error_status if json_response[json_response_error_key]
|
274
138
|
set_jwt
|
275
|
-
|
276
|
-
response.write(_json_response_body(json_response))
|
277
|
-
request.halt
|
139
|
+
super
|
278
140
|
end
|
279
141
|
|
280
142
|
def set_jwt
|
281
143
|
set_jwt_token(session_jwt)
|
282
144
|
end
|
283
|
-
|
284
|
-
def set_redirect_error_status(status)
|
285
|
-
if use_jwt? && json_response_custom_error_status?
|
286
|
-
response.status = status
|
287
|
-
end
|
288
|
-
end
|
289
|
-
|
290
|
-
def set_response_error_status(status)
|
291
|
-
if use_jwt? && !json_response_custom_error_status?
|
292
|
-
status = json_response_error_status
|
293
|
-
end
|
294
|
-
|
295
|
-
super
|
296
|
-
end
|
297
145
|
end
|
298
146
|
end
|
@@ -24,6 +24,8 @@ module Rodauth
|
|
24
24
|
auth_value_method :jwt_refresh_token_table, :account_jwt_refresh_keys
|
25
25
|
translatable_method :jwt_refresh_without_access_token_message, 'no JWT access token provided during refresh'
|
26
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
|
27
29
|
|
28
30
|
auth_private_methods(
|
29
31
|
:account_from_refresh_token
|
@@ -49,11 +51,8 @@ module Rodauth
|
|
49
51
|
end
|
50
52
|
else
|
51
53
|
json_response[json_response_error_key] = jwt_refresh_invalid_token_message
|
52
|
-
response.status ||= json_response_error_status
|
53
54
|
end
|
54
|
-
|
55
|
-
response.write(_json_response_body(json_response))
|
56
|
-
request.halt
|
55
|
+
_return_json_response
|
57
56
|
end
|
58
57
|
end
|
59
58
|
|
@@ -63,7 +62,7 @@ module Rodauth
|
|
63
62
|
# JWT login puts the access token in the header.
|
64
63
|
# We put the refresh token in the body.
|
65
64
|
# Note, do not put the access_token in the body here, as the access token content is not yet finalised.
|
66
|
-
token = json_response[
|
65
|
+
token = json_response[jwt_refresh_token_key] = generate_refresh_token
|
67
66
|
|
68
67
|
set_jwt_refresh_token_hmac_session_key(token)
|
69
68
|
end
|
@@ -92,6 +91,23 @@ module Rodauth
|
|
92
91
|
|
93
92
|
private
|
94
93
|
|
94
|
+
def rescue_jwt_payload(e)
|
95
|
+
if e.instance_of?(JWT::ExpiredSignature)
|
96
|
+
begin
|
97
|
+
# Some versions of jwt will raise JWT::ExpiredSignature even when the
|
98
|
+
# JWT is invalid for other reasons. Make sure the expiration is the
|
99
|
+
# only reason the JWT isn't valid before treating this as an expired token.
|
100
|
+
JWT.decode(jwt_token, jwt_secret, true, Hash[jwt_decode_opts].merge!(:verify_expiration=>false, :algorithm=>jwt_algorithm))[0]
|
101
|
+
rescue => e
|
102
|
+
else
|
103
|
+
json_response[json_response_error_key] = expired_jwt_access_token_message
|
104
|
+
response.status ||= expired_jwt_access_token_status
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
super
|
109
|
+
end
|
110
|
+
|
95
111
|
def _account_from_refresh_token(token)
|
96
112
|
id, token_id, key = _account_refresh_token_split(token)
|
97
113
|
|
@@ -158,8 +174,7 @@ module Rodauth
|
|
158
174
|
end
|
159
175
|
|
160
176
|
def remove_jwt_refresh_token_key(token)
|
161
|
-
account_id,
|
162
|
-
token_id, _ = split_token(token)
|
177
|
+
account_id, token_id, _ = _account_refresh_token_split(token)
|
163
178
|
jwt_refresh_token_account_token_ds(account_id, token_id).delete
|
164
179
|
end
|
165
180
|
|
@@ -191,9 +206,7 @@ module Rodauth
|
|
191
206
|
id, token_id, key = _account_refresh_token_split(token)
|
192
207
|
|
193
208
|
if id && token_id && key && (actual = get_active_refresh_token(session_value, token_id)) && timing_safe_eql?(key, convert_token_key(actual))
|
194
|
-
|
195
|
-
where(jwt_refresh_token_id_column=>token_id).
|
196
|
-
delete
|
209
|
+
jwt_refresh_token_account_token_ds(id, token_id).delete
|
197
210
|
end
|
198
211
|
end
|
199
212
|
end
|
@@ -16,6 +16,7 @@ module Rodauth
|
|
16
16
|
auth_value_method :require_login_confirmation?, true
|
17
17
|
auth_value_method :require_password_confirmation?, true
|
18
18
|
translatable_method :same_as_existing_password_message, "invalid password, same as current password"
|
19
|
+
translatable_method :contains_null_byte_message, 'contains null byte'
|
19
20
|
|
20
21
|
auth_value_methods(
|
21
22
|
:login_confirm_label,
|
@@ -124,7 +125,7 @@ module Rodauth
|
|
124
125
|
|
125
126
|
def password_does_not_contain_null_byte?(password)
|
126
127
|
return true unless password.include?("\0")
|
127
|
-
@password_requirement_message =
|
128
|
+
@password_requirement_message = contains_null_byte_message
|
128
129
|
false
|
129
130
|
end
|
130
131
|
|
@@ -140,6 +141,10 @@ module Rodauth
|
|
140
141
|
# :nocov:
|
141
142
|
end
|
142
143
|
|
144
|
+
def extract_password_hash_cost(hash)
|
145
|
+
hash[4, 2].to_i
|
146
|
+
end
|
147
|
+
|
143
148
|
def password_hash(password)
|
144
149
|
BCrypt::Password.create(password, :cost=>password_hash_cost)
|
145
150
|
end
|
data/lib/rodauth/features/otp.rb
CHANGED
@@ -34,6 +34,7 @@ module Rodauth
|
|
34
34
|
auth_value_method :add_recovery_codes_param, 'add'
|
35
35
|
translatable_method :add_recovery_codes_heading, '<h2>Add Additional Recovery Codes</h2>'
|
36
36
|
auth_value_method :auto_add_recovery_codes?, false
|
37
|
+
auth_value_method :auto_remove_recovery_codes?, false
|
37
38
|
translatable_method :invalid_recovery_code_message, "Invalid recovery code"
|
38
39
|
auth_value_method :recovery_codes_limit, 16
|
39
40
|
auth_value_method :recovery_codes_column, :code
|
@@ -56,7 +57,6 @@ module Rodauth
|
|
56
57
|
:can_add_recovery_codes?,
|
57
58
|
:new_recovery_code,
|
58
59
|
:recovery_code_match?,
|
59
|
-
:recovery_codes
|
60
60
|
)
|
61
61
|
|
62
62
|
route(:recovery_auth) do |r|
|
@@ -213,6 +213,21 @@ module Rodauth
|
|
213
213
|
super
|
214
214
|
end
|
215
215
|
|
216
|
+
def after_otp_disable
|
217
|
+
super if defined?(super)
|
218
|
+
auto_remove_recovery_codes
|
219
|
+
end
|
220
|
+
|
221
|
+
def after_sms_disable
|
222
|
+
super if defined?(super)
|
223
|
+
auto_remove_recovery_codes
|
224
|
+
end
|
225
|
+
|
226
|
+
def after_webauthn_remove
|
227
|
+
super if defined?(super)
|
228
|
+
auto_remove_recovery_codes
|
229
|
+
end
|
230
|
+
|
216
231
|
def new_recovery_code
|
217
232
|
random_key
|
218
233
|
end
|
@@ -227,6 +242,12 @@ module Rodauth
|
|
227
242
|
end
|
228
243
|
end
|
229
244
|
|
245
|
+
def auto_remove_recovery_codes
|
246
|
+
if auto_remove_recovery_codes? && (%w'totp webauthn sms_code' & possible_authentication_methods).empty?
|
247
|
+
recovery_codes_remove
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
230
251
|
def _recovery_codes
|
231
252
|
recovery_codes_ds.select_map(recovery_codes_column)
|
232
253
|
end
|
@@ -132,11 +132,16 @@ module Rodauth
|
|
132
132
|
opts = Hash[remember_cookie_options]
|
133
133
|
opts[:value] = "#{account_id}_#{convert_token_key(remember_key_value)}"
|
134
134
|
opts[:expires] = convert_timestamp(active_remember_key_ds.get(remember_deadline_column))
|
135
|
+
opts[:path] = "/" unless opts.key?(:path)
|
136
|
+
opts[:httponly] = true unless opts.key?(:httponly)
|
137
|
+
opts[:secure] = true unless opts.key?(:secure) || !request.ssl?
|
135
138
|
::Rack::Utils.set_cookie_header!(response.headers, remember_cookie_key, opts)
|
136
139
|
end
|
137
140
|
|
138
141
|
def forget_login
|
139
|
-
|
142
|
+
opts = Hash[remember_cookie_options]
|
143
|
+
opts[:path] = "/" unless opts.key?(:path)
|
144
|
+
::Rack::Utils.delete_cookie_header!(response.headers, remember_cookie_key, opts)
|
140
145
|
end
|
141
146
|
|
142
147
|
def get_remember_key
|