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