rodauth 2.5.0 → 2.10.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 +18 -6
- data/doc/argon2.rdoc +49 -0
- data/doc/base.rdoc +3 -2
- 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 +8 -0
- data/doc/login_password_requirements_base.rdoc +1 -1
- data/doc/recovery_codes.rdoc +2 -1
- data/doc/release_notes/2.10.0.txt +47 -0
- data/doc/release_notes/2.6.0.txt +37 -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/javascript/webauthn_auth.js +9 -9
- data/javascript/webauthn_setup.js +9 -6
- data/lib/rodauth.rb +14 -6
- data/lib/rodauth/features/argon2.rb +69 -0
- data/lib/rodauth/features/base.rb +12 -3
- data/lib/rodauth/features/confirm_password.rb +2 -2
- data/lib/rodauth/features/disallow_password_reuse.rb +20 -7
- data/lib/rodauth/features/json.rb +189 -0
- data/lib/rodauth/features/jwt.rb +22 -170
- data/lib/rodauth/features/jwt_refresh.rb +63 -13
- data/lib/rodauth/features/login_password_requirements_base.rb +4 -0
- 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/update_password_hash.rb +1 -1
- data/lib/rodauth/features/verify_account.rb +6 -7
- 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,99 +107,19 @@ module Rodauth
|
|
150
107
|
super
|
151
108
|
end
|
152
109
|
|
153
|
-
def
|
154
|
-
|
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
|
110
|
+
def _jwt_decode_opts
|
111
|
+
jwt_decode_opts
|
230
112
|
end
|
231
113
|
|
232
114
|
def jwt_payload
|
233
115
|
return @jwt_payload if defined?(@jwt_payload)
|
234
|
-
@jwt_payload = JWT.decode(jwt_token, jwt_secret, true,
|
235
|
-
rescue JWT::DecodeError
|
236
|
-
|
116
|
+
@jwt_payload = JWT.decode(jwt_token, jwt_secret, true, _jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
|
117
|
+
rescue JWT::DecodeError => e
|
118
|
+
rescue_jwt_payload(e)
|
237
119
|
end
|
238
120
|
|
239
|
-
def
|
240
|
-
|
241
|
-
return_json_response
|
242
|
-
end
|
243
|
-
|
244
|
-
def include_success_messages?
|
245
|
-
!json_response_success_key.nil?
|
121
|
+
def rescue_jwt_payload(_)
|
122
|
+
@jwt_payload = false
|
246
123
|
end
|
247
124
|
|
248
125
|
def set_session_value(key, value)
|
@@ -257,38 +134,13 @@ module Rodauth
|
|
257
134
|
value
|
258
135
|
end
|
259
136
|
|
260
|
-
def json_response
|
261
|
-
@json_response ||= {}
|
262
|
-
end
|
263
|
-
|
264
|
-
def _json_response_body(hash)
|
265
|
-
request.send(:convert_to_json, hash)
|
266
|
-
end
|
267
|
-
|
268
137
|
def return_json_response
|
269
|
-
response.status ||= json_response_error_status if json_response[json_response_error_key]
|
270
138
|
set_jwt
|
271
|
-
|
272
|
-
response.write(_json_response_body(json_response))
|
273
|
-
request.halt
|
139
|
+
super
|
274
140
|
end
|
275
141
|
|
276
142
|
def set_jwt
|
277
143
|
set_jwt_token(session_jwt)
|
278
144
|
end
|
279
|
-
|
280
|
-
def set_redirect_error_status(status)
|
281
|
-
if use_jwt? && json_response_custom_error_status?
|
282
|
-
response.status = status
|
283
|
-
end
|
284
|
-
end
|
285
|
-
|
286
|
-
def set_response_error_status(status)
|
287
|
-
if use_jwt? && !json_response_custom_error_status?
|
288
|
-
status = json_response_error_status
|
289
|
-
end
|
290
|
-
|
291
|
-
super
|
292
|
-
end
|
293
145
|
end
|
294
146
|
end
|
@@ -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
|
@@ -21,12 +24,15 @@ module Rodauth
|
|
21
24
|
auth_value_method :jwt_refresh_token_table, :account_jwt_refresh_keys
|
22
25
|
translatable_method :jwt_refresh_without_access_token_message, 'no JWT access token provided during refresh'
|
23
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
|
24
29
|
|
25
30
|
auth_private_methods(
|
26
31
|
:account_from_refresh_token
|
27
32
|
)
|
28
33
|
|
29
34
|
route do |r|
|
35
|
+
@jwt_refresh_route = true
|
30
36
|
before_jwt_refresh_route
|
31
37
|
|
32
38
|
r.post do
|
@@ -38,17 +44,15 @@ module Rodauth
|
|
38
44
|
before_refresh_token
|
39
45
|
formatted_token = generate_refresh_token
|
40
46
|
remove_jwt_refresh_token_key(refresh_token)
|
47
|
+
set_jwt_refresh_token_hmac_session_key(formatted_token)
|
41
48
|
json_response[jwt_refresh_token_key] = formatted_token
|
42
49
|
json_response[jwt_access_token_key] = session_jwt
|
43
50
|
after_refresh_token
|
44
51
|
end
|
45
52
|
else
|
46
53
|
json_response[json_response_error_key] = jwt_refresh_invalid_token_message
|
47
|
-
response.status ||= json_response_error_status
|
48
54
|
end
|
49
|
-
|
50
|
-
response.write(_json_response_body(json_response))
|
51
|
-
request.halt
|
55
|
+
_return_json_response
|
52
56
|
end
|
53
57
|
end
|
54
58
|
|
@@ -58,7 +62,9 @@ module Rodauth
|
|
58
62
|
# JWT login puts the access token in the header.
|
59
63
|
# We put the refresh token in the body.
|
60
64
|
# Note, do not put the access_token in the body here, as the access token content is not yet finalised.
|
61
|
-
json_response[
|
65
|
+
token = json_response[jwt_refresh_token_key] = generate_refresh_token
|
66
|
+
|
67
|
+
set_jwt_refresh_token_hmac_session_key(token)
|
62
68
|
end
|
63
69
|
|
64
70
|
def set_jwt_token(token)
|
@@ -85,12 +91,33 @@ module Rodauth
|
|
85
91
|
|
86
92
|
private
|
87
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
|
+
|
88
111
|
def _account_from_refresh_token(token)
|
89
112
|
id, token_id, key = _account_refresh_token_split(token)
|
90
113
|
|
91
|
-
|
92
|
-
|
93
|
-
|
114
|
+
unless key &&
|
115
|
+
(id == session_value.to_s) &&
|
116
|
+
(actual = get_active_refresh_token(id, token_id)) &&
|
117
|
+
timing_safe_eql?(key, convert_token_key(actual)) &&
|
118
|
+
jwt_refresh_token_match?(key)
|
119
|
+
return
|
120
|
+
end
|
94
121
|
|
95
122
|
ds = account_ds(id)
|
96
123
|
ds = ds.where(account_status_column=>account_open_status_value) unless skip_status_checks?
|
@@ -107,6 +134,23 @@ module Rodauth
|
|
107
134
|
[id, token_id, key]
|
108
135
|
end
|
109
136
|
|
137
|
+
def _jwt_decode_opts
|
138
|
+
if allow_refresh_with_expired_jwt_access_token? && @jwt_refresh_route
|
139
|
+
Hash[super].merge!(:verify_expiration=>false)
|
140
|
+
else
|
141
|
+
super
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def jwt_refresh_token_match?(key)
|
146
|
+
# We don't need to match tokens if we are requiring a valid current access token
|
147
|
+
return true unless allow_refresh_with_expired_jwt_access_token?
|
148
|
+
|
149
|
+
# If allowing with expired jwt access token, check the expired session contains
|
150
|
+
# hmac matching submitted and active refresh token.
|
151
|
+
timing_safe_eql?(compute_hmac(session[jwt_refresh_token_data_session_key].to_s + key), session[jwt_refresh_token_hmac_session_key].to_s)
|
152
|
+
end
|
153
|
+
|
110
154
|
def get_active_refresh_token(account_id, token_id)
|
111
155
|
jwt_refresh_token_account_ds(account_id).
|
112
156
|
where(Sequel::CURRENT_TIMESTAMP > jwt_refresh_token_deadline_column).
|
@@ -130,8 +174,7 @@ module Rodauth
|
|
130
174
|
end
|
131
175
|
|
132
176
|
def remove_jwt_refresh_token_key(token)
|
133
|
-
account_id,
|
134
|
-
token_id, _ = split_token(token)
|
177
|
+
account_id, token_id, _ = _account_refresh_token_split(token)
|
135
178
|
jwt_refresh_token_account_token_ds(account_id, token_id).delete
|
136
179
|
end
|
137
180
|
|
@@ -146,6 +189,15 @@ module Rodauth
|
|
146
189
|
hash
|
147
190
|
end
|
148
191
|
|
192
|
+
def set_jwt_refresh_token_hmac_session_key(token)
|
193
|
+
if allow_refresh_with_expired_jwt_access_token?
|
194
|
+
key = _account_refresh_token_split(token).last
|
195
|
+
data = random_key
|
196
|
+
set_session_value(jwt_refresh_token_data_session_key, data)
|
197
|
+
set_session_value(jwt_refresh_token_hmac_session_key, compute_hmac(data + key))
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
149
201
|
def before_logout
|
150
202
|
if token = param_or_nil(jwt_refresh_token_key_param)
|
151
203
|
if token == 'all'
|
@@ -154,9 +206,7 @@ module Rodauth
|
|
154
206
|
id, token_id, key = _account_refresh_token_split(token)
|
155
207
|
|
156
208
|
if id && token_id && key && (actual = get_active_refresh_token(session_value, token_id)) && timing_safe_eql?(key, convert_token_key(actual))
|
157
|
-
|
158
|
-
where(jwt_refresh_token_id_column=>token_id).
|
159
|
-
delete
|
209
|
+
jwt_refresh_token_account_token_ds(id, token_id).delete
|
160
210
|
end
|
161
211
|
end
|
162
212
|
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
|