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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +42 -0
  3. data/MIT-LICENSE +1 -1
  4. data/README.rdoc +21 -6
  5. data/doc/argon2.rdoc +49 -0
  6. data/doc/base.rdoc +1 -1
  7. data/doc/change_login.rdoc +1 -0
  8. data/doc/guides/migrate_password_hash_algorithm.rdoc +15 -0
  9. data/doc/json.rdoc +47 -0
  10. data/doc/jwt.rdoc +1 -28
  11. data/doc/jwt_refresh.rdoc +2 -0
  12. data/doc/login_password_requirements_base.rdoc +2 -1
  13. data/doc/recovery_codes.rdoc +2 -1
  14. data/doc/release_notes/2.10.0.txt +47 -0
  15. data/doc/release_notes/2.11.0.txt +31 -0
  16. data/doc/release_notes/2.7.0.txt +33 -0
  17. data/doc/release_notes/2.8.0.txt +20 -0
  18. data/doc/release_notes/2.9.0.txt +21 -0
  19. data/doc/remember.rdoc +1 -1
  20. data/lib/rodauth.rb +17 -4
  21. data/lib/rodauth/features/argon2.rb +69 -0
  22. data/lib/rodauth/features/base.rb +6 -2
  23. data/lib/rodauth/features/change_login.rb +2 -1
  24. data/lib/rodauth/features/disallow_password_reuse.rb +20 -7
  25. data/lib/rodauth/features/email_base.rb +5 -2
  26. data/lib/rodauth/features/json.rb +189 -0
  27. data/lib/rodauth/features/jwt.rb +19 -171
  28. data/lib/rodauth/features/jwt_refresh.rb +23 -10
  29. data/lib/rodauth/features/login_password_requirements_base.rb +6 -1
  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/reset_password.rb +1 -0
  34. data/lib/rodauth/features/update_password_hash.rb +1 -1
  35. data/lib/rodauth/features/verify_account.rb +0 -1
  36. data/lib/rodauth/features/webauthn_verify_account.rb +1 -1
  37. data/lib/rodauth/migrations.rb +31 -5
  38. data/lib/rodauth/version.rb +1 -1
  39. 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,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
- @jwt_payload = false
117
+ rescue JWT::DecodeError => e
118
+ rescue_jwt_payload(e)
241
119
  end
242
120
 
243
- def redirect(_)
244
- return super unless use_jwt?
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
- response['Content-Type'] ||= json_response_content_type
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
- response['Content-Type'] ||= json_response_content_type
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['refresh_token'] = generate_refresh_token
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, token = split_token(token)
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
- jwt_refresh_token_account_ds(id).
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 = 'contains null byte'
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
@@ -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
@@ -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
- ::Rack::Utils.delete_cookie_header!(response.headers, remember_cookie_key, remember_cookie_options)
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