rodauth 2.5.0 → 2.10.0

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