rodauth 2.6.0 → 2.11.0

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