rodauth 2.5.0 → 2.10.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +42 -0
  3. data/MIT-LICENSE +1 -1
  4. data/README.rdoc +18 -6
  5. data/doc/argon2.rdoc +49 -0
  6. data/doc/base.rdoc +3 -2
  7. data/doc/guides/migrate_password_hash_algorithm.rdoc +15 -0
  8. data/doc/json.rdoc +47 -0
  9. data/doc/jwt.rdoc +1 -28
  10. data/doc/jwt_refresh.rdoc +8 -0
  11. data/doc/login_password_requirements_base.rdoc +1 -1
  12. data/doc/recovery_codes.rdoc +2 -1
  13. data/doc/release_notes/2.10.0.txt +47 -0
  14. data/doc/release_notes/2.6.0.txt +37 -0
  15. data/doc/release_notes/2.7.0.txt +33 -0
  16. data/doc/release_notes/2.8.0.txt +20 -0
  17. data/doc/release_notes/2.9.0.txt +21 -0
  18. data/doc/remember.rdoc +1 -1
  19. data/javascript/webauthn_auth.js +9 -9
  20. data/javascript/webauthn_setup.js +9 -6
  21. data/lib/rodauth.rb +14 -6
  22. data/lib/rodauth/features/argon2.rb +69 -0
  23. data/lib/rodauth/features/base.rb +12 -3
  24. data/lib/rodauth/features/confirm_password.rb +2 -2
  25. data/lib/rodauth/features/disallow_password_reuse.rb +20 -7
  26. data/lib/rodauth/features/json.rb +189 -0
  27. data/lib/rodauth/features/jwt.rb +22 -170
  28. data/lib/rodauth/features/jwt_refresh.rb +63 -13
  29. data/lib/rodauth/features/login_password_requirements_base.rb +4 -0
  30. data/lib/rodauth/features/otp.rb +0 -2
  31. data/lib/rodauth/features/recovery_codes.rb +22 -1
  32. data/lib/rodauth/features/remember.rb +6 -1
  33. data/lib/rodauth/features/update_password_hash.rb +1 -1
  34. data/lib/rodauth/features/verify_account.rb +6 -7
  35. data/lib/rodauth/features/webauthn_verify_account.rb +1 -1
  36. data/lib/rodauth/migrations.rb +31 -5
  37. data/lib/rodauth/version.rb +1 -1
  38. metadata +55 -24
@@ -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
- auth_private_methods :json_response_body
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] = invalid_jwt_format_error_message
51
- response.status ||= json_response_error_status
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
- set_jwt if use_jwt?
77
- end
78
-
79
- def set_field_error(field, message)
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
- jwt_token || only_json? || json_request?
92
+ use_json?
135
93
  end
136
94
 
137
- def valid_jwt?
138
- !!(jwt_token && jwt_payload)
95
+ def use_json?
96
+ jwt_token || super
139
97
  end
140
98
 
141
- def view(page, title)
142
- return super unless use_jwt?
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 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
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, jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
235
- rescue JWT::DecodeError
236
- @jwt_payload = false
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 redirect(_)
240
- return super unless use_jwt?
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
- response['Content-Type'] ||= json_response_content_type
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
- response['Content-Type'] ||= json_response_content_type
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['refresh_token'] = generate_refresh_token
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
- return unless key
92
- return unless actual = get_active_refresh_token(id, token_id)
93
- return unless timing_safe_eql?(key, convert_token_key(actual))
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, token = split_token(token)
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
- jwt_refresh_token_account_ds(id).
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
@@ -140,6 +140,10 @@ module Rodauth
140
140
  # :nocov:
141
141
  end
142
142
 
143
+ def extract_password_hash_cost(hash)
144
+ hash[4, 2].to_i
145
+ end
146
+
143
147
  def password_hash(password)
144
148
  BCrypt::Password.create(password, :cost=>password_hash_cost)
145
149
  end
@@ -76,9 +76,7 @@ module Rodauth
76
76
  )
77
77
 
78
78
  auth_methods(
79
- :otp,
80
79
  :otp_exists?,
81
- :otp_key,
82
80
  :otp_last_use,
83
81
  :otp_locked_out?,
84
82
  :otp_new_secret,
@@ -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