rodauth 2.30.0 → 2.32.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +40 -0
  3. data/README.rdoc +4 -1
  4. data/doc/argon2.rdoc +9 -5
  5. data/doc/base.rdoc +1 -0
  6. data/doc/change_login.rdoc +1 -0
  7. data/doc/change_password.rdoc +1 -0
  8. data/doc/close_account.rdoc +1 -0
  9. data/doc/confirm_password.rdoc +1 -0
  10. data/doc/create_account.rdoc +1 -0
  11. data/doc/email_auth.rdoc +1 -0
  12. data/doc/error_reasons.rdoc +1 -0
  13. data/doc/guides/registration_field.rdoc +1 -1
  14. data/doc/guides/render_confirmation.rdoc +17 -0
  15. data/doc/internal_request.rdoc +76 -0
  16. data/doc/json.rdoc +1 -0
  17. data/doc/jwt.rdoc +7 -1
  18. data/doc/jwt_refresh.rdoc +1 -1
  19. data/doc/lockout.rdoc +4 -2
  20. data/doc/login.rdoc +2 -1
  21. data/doc/logout.rdoc +1 -0
  22. data/doc/otp.rdoc +3 -0
  23. data/doc/release_notes/2.31.0.txt +47 -0
  24. data/doc/release_notes/2.32.0.txt +65 -0
  25. data/doc/remember.rdoc +1 -0
  26. data/doc/reset_password.rdoc +2 -0
  27. data/doc/sms_codes.rdoc +5 -0
  28. data/doc/two_factor_base.rdoc +2 -0
  29. data/doc/verify_account.rdoc +2 -0
  30. data/doc/verify_login_change.rdoc +1 -0
  31. data/doc/webauthn.rdoc +2 -0
  32. data/doc/webauthn_autofill.rdoc +5 -0
  33. data/doc/webauthn_login.rdoc +1 -0
  34. data/lib/rodauth/features/active_sessions.rb +10 -4
  35. data/lib/rodauth/features/argon2.rb +54 -15
  36. data/lib/rodauth/features/base.rb +39 -4
  37. data/lib/rodauth/features/change_login.rb +2 -2
  38. data/lib/rodauth/features/change_password.rb +2 -2
  39. data/lib/rodauth/features/close_account.rb +2 -2
  40. data/lib/rodauth/features/confirm_password.rb +2 -2
  41. data/lib/rodauth/features/create_account.rb +2 -2
  42. data/lib/rodauth/features/email_auth.rb +7 -12
  43. data/lib/rodauth/features/email_base.rb +4 -6
  44. data/lib/rodauth/features/internal_request.rb +47 -1
  45. data/lib/rodauth/features/json.rb +6 -1
  46. data/lib/rodauth/features/jwt.rb +17 -1
  47. data/lib/rodauth/features/jwt_refresh.rb +4 -2
  48. data/lib/rodauth/features/lockout.rb +6 -6
  49. data/lib/rodauth/features/login.rb +13 -4
  50. data/lib/rodauth/features/logout.rb +2 -2
  51. data/lib/rodauth/features/otp.rb +35 -7
  52. data/lib/rodauth/features/remember.rb +15 -11
  53. data/lib/rodauth/features/reset_password.rb +5 -5
  54. data/lib/rodauth/features/single_session.rb +4 -3
  55. data/lib/rodauth/features/sms_codes.rb +23 -10
  56. data/lib/rodauth/features/two_factor_base.rb +8 -6
  57. data/lib/rodauth/features/update_password_hash.rb +2 -1
  58. data/lib/rodauth/features/verify_account.rb +7 -12
  59. data/lib/rodauth/features/verify_login_change.rb +2 -2
  60. data/lib/rodauth/features/webauthn.rb +12 -6
  61. data/lib/rodauth/features/webauthn_autofill.rb +7 -1
  62. data/lib/rodauth/features/webauthn_login.rb +19 -0
  63. data/lib/rodauth/version.rb +1 -1
  64. data/lib/rodauth.rb +16 -0
  65. metadata +7 -2
@@ -4,10 +4,15 @@ The webauthn_autofill feature enables autofill UI (aka "conditional mediation")
4
4
  for WebAuthn credentials, logging the user in on selection. It depends on the
5
5
  webauthn_login feature.
6
6
 
7
+ This feature allows generating WebAuthn credential options and submitting a
8
+ WebAuthn login request without providing a login, which can be used
9
+ independently from the autofill UI.
10
+
7
11
  == Auth Value Methods
8
12
 
9
13
  webauthn_autofill_js :: The javascript code to execute on the login page to enable autofill UI.
10
14
  webauthn_autofill_js_route :: The route to the webauthn autofill javascript file.
15
+ webauthn_invalid_webauthn_id_message :: The error message to show when provided WebAuthn ID wasn't found in the database.
11
16
 
12
17
  == Auth Methods
13
18
 
@@ -5,6 +5,7 @@ WebAuthn. It depends on the login and webauthn features.
5
5
 
6
6
  == Auth Value Methods
7
7
 
8
+ webauthn_login_user_verification_additional_factor? :: Whether passwordless login via WebAuthn should consider user verification as 2nd factor when using multifactor authentication, false by default. Setting this to true means that the app trusts the user verification done by the authenticator is strong enough to be considered an additional factor.
8
9
  webauthn_login_error_flash :: The flash error to show if there is a failure during passwordless login via WebAuthn.
9
10
  webauthn_login_failure_redirect :: Whether to redirect if there is a failure during passwordless login via WebAuthn.
10
11
  webauthn_login_route :: The route to the webauthn login action.
@@ -40,7 +40,7 @@ module Rodauth
40
40
 
41
41
  remove_inactive_sessions
42
42
  ds = active_sessions_ds.
43
- where(active_sessions_session_id_column => compute_hmac(session_id))
43
+ where(active_sessions_session_id_column => compute_hmacs(session_id))
44
44
 
45
45
  if update_current_session?
46
46
  ds.update(active_sessions_update_hash) == 1
@@ -83,7 +83,7 @@ module Rodauth
83
83
 
84
84
  def remove_current_session
85
85
  if session_id = session[session_id_session_key]
86
- remove_active_session(compute_hmac(session_id))
86
+ remove_active_session(compute_hmacs(session_id))
87
87
  end
88
88
  end
89
89
 
@@ -119,7 +119,7 @@ module Rodauth
119
119
  key = generate_active_sessions_key
120
120
  set_session_value(session_id_session_key, key)
121
121
  active_sessions_ds.
122
- where(active_sessions_session_id_column => compute_hmac(prev_key)).
122
+ where(active_sessions_session_id_column => compute_hmacs(prev_key)).
123
123
  update(active_sessions_session_id_column => compute_hmac(key))
124
124
  end
125
125
  end
@@ -150,7 +150,13 @@ module Rodauth
150
150
  end
151
151
 
152
152
  def active_sessions_update_hash
153
- {active_sessions_last_use_column => Sequel::CURRENT_TIMESTAMP}
153
+ h = {active_sessions_last_use_column => Sequel::CURRENT_TIMESTAMP}
154
+
155
+ if hmac_secret_rotation?
156
+ h[active_sessions_session_id_column] = compute_hmac(session[session_id_session_key])
157
+ end
158
+
159
+ h
154
160
  end
155
161
 
156
162
  def session_inactivity_deadline_condition
@@ -12,6 +12,7 @@ module Rodauth
12
12
  Feature.define(:argon2, :Argon2) do
13
13
  depends :login_password_requirements_base
14
14
 
15
+ auth_value_method :argon2_old_secret, nil
15
16
  auth_value_method :argon2_secret, nil
16
17
  auth_value_method :use_argon2?, true
17
18
 
@@ -43,7 +44,7 @@ module Rodauth
43
44
 
44
45
  def password_hash_cost
45
46
  return super unless use_argon2?
46
- argon2_hash_cost
47
+ argon2_hash_cost
47
48
  end
48
49
 
49
50
  def password_hash_match?(hash, password)
@@ -53,28 +54,50 @@ module Rodauth
53
54
 
54
55
  def password_hash_using_salt(password, salt)
55
56
  return super unless argon2_hash_algorithm?(salt)
57
+ argon2_password_hash_using_salt_and_secret(password, salt, argon2_secret)
58
+ end
56
59
 
60
+ def argon2_password_hash_using_salt_and_secret(password, salt, secret)
57
61
  argon2_params = Hash[extract_password_hash_cost(salt)]
58
- argon2_params[argon2_salt_option] = Base64.decode64(salt.split('$').last)
59
- argon2_params[:secret] = argon2_secret
62
+ argon2_params[argon2_salt_option] = salt.split('$').last.unpack("m")[0]
63
+ argon2_params[:secret] = secret
60
64
  ::Argon2::Password.new(argon2_params).create(password)
61
65
  end
62
66
 
63
- def extract_password_hash_cost(hash)
64
- return super unless argon2_hash_algorithm?(hash )
67
+ if Argon2::VERSION >= '2.1'
68
+ def extract_password_hash_cost(hash)
69
+ return super unless argon2_hash_algorithm?(hash)
65
70
 
66
- /\A\$argon2id\$v=\d+\$m=(\d+),t=(\d+)/ =~ hash
67
- { t_cost: $2.to_i, m_cost: Math.log2($1.to_i).to_i }
68
- end
71
+ /\A\$argon2id\$v=\d+\$m=(\d+),t=(\d+),p=(\d+)/ =~ hash
72
+ { t_cost: $2.to_i, m_cost: Math.log2($1.to_i).to_i, p_cost: $3.to_i }
73
+ end
69
74
 
70
- if ENV['RACK_ENV'] == 'test'
71
- def argon2_hash_cost
72
- {t_cost: 1, m_cost: 3}
75
+ if ENV['RACK_ENV'] == 'test'
76
+ def argon2_hash_cost
77
+ { t_cost: 1, m_cost: 5, p_cost: 1 }
78
+ end
79
+ # :nocov:
80
+ else
81
+ def argon2_hash_cost
82
+ { t_cost: 2, m_cost: 16, p_cost: 1 }
83
+ end
73
84
  end
74
- # :nocov:
75
85
  else
76
- def argon2_hash_cost
77
- {t_cost: 2, m_cost: 16}
86
+ def extract_password_hash_cost(hash)
87
+ return super unless argon2_hash_algorithm?(hash )
88
+
89
+ /\A\$argon2id\$v=\d+\$m=(\d+),t=(\d+)/ =~ hash
90
+ { t_cost: $2.to_i, m_cost: Math.log2($1.to_i).to_i }
91
+ end
92
+
93
+ if ENV['RACK_ENV'] == 'test'
94
+ def argon2_hash_cost
95
+ { t_cost: 1, m_cost: 5 }
96
+ end
97
+ else
98
+ def argon2_hash_cost
99
+ { t_cost: 2, m_cost: 16 }
100
+ end
78
101
  end
79
102
  end
80
103
  # :nocov:
@@ -84,7 +107,23 @@ module Rodauth
84
107
  end
85
108
 
86
109
  def argon2_password_hash_match?(hash, password)
87
- ::Argon2::Password.verify_password(password, hash, argon2_secret)
110
+ ret = ::Argon2::Password.verify_password(password, hash, argon2_secret)
111
+
112
+ if ret == false && argon2_old_secret != argon2_secret && (ret = ::Argon2::Password.verify_password(password, hash, argon2_old_secret))
113
+ @update_password_hash = true
114
+ end
115
+
116
+ ret
117
+ end
118
+
119
+ def database_function_password_match?(name, hash_id, password, salt)
120
+ return true if super
121
+
122
+ if use_argon2? && argon2_hash_algorithm?(salt) && argon2_old_secret != argon2_secret && (ret = db.get(Sequel.function(function_name(name), hash_id, argon2_password_hash_using_salt_and_secret(password, salt, argon2_old_secret))))
123
+ @update_password_hash = true
124
+ end
125
+
126
+ !!ret
88
127
  end
89
128
  end
90
129
  end
@@ -27,6 +27,7 @@ module Rodauth
27
27
  auth_value_method :convert_token_id_to_integer?, nil
28
28
  flash_key :flash_error_key, :error
29
29
  flash_key :flash_notice_key, :notice
30
+ auth_value_method :hmac_old_secret, nil
30
31
  auth_value_method :hmac_secret, nil
31
32
  translatable_method :input_field_label_suffix, ''
32
33
  auth_value_method :input_field_error_class, 'error is-invalid'
@@ -242,9 +243,24 @@ module Rodauth
242
243
 
243
244
  # Return urlsafe base64 HMAC for data, assumes hmac_secret is set.
244
245
  def compute_hmac(data)
245
- s = [compute_raw_hmac(data)].pack('m').chomp!("=\n")
246
- s.tr!('+/', '-_')
247
- s
246
+ _process_raw_hmac(compute_raw_hmac(data))
247
+ end
248
+
249
+ # Return urlsafe base64 HMAC for data using hmac_old_secret, assumes hmac_old_secret is set.
250
+ def compute_old_hmac(data)
251
+ _process_raw_hmac(compute_raw_hmac_with_secret(data, hmac_old_secret))
252
+ end
253
+
254
+ # Return array of hmacs. Array has two strings if hmac_old_secret
255
+ # is set, or one string otherwise.
256
+ def compute_hmacs(data)
257
+ hmacs = [compute_hmac(data)]
258
+
259
+ if hmac_old_secret
260
+ hmacs << compute_old_hmac(data)
261
+ end
262
+
263
+ hmacs
248
264
  end
249
265
 
250
266
  def account_id
@@ -515,6 +531,13 @@ module Rodauth
515
531
  yield
516
532
  end
517
533
 
534
+ def _process_raw_hmac(hmac)
535
+ s = [hmac].pack('m')
536
+ s.chomp!("=\n")
537
+ s.tr!('+/', '-_')
538
+ s
539
+ end
540
+
518
541
  def database_function_password_match?(name, hash_id, password, salt)
519
542
  db.get(Sequel.function(function_name(name), hash_id, password_hash_using_salt(password, salt)))
520
543
  end
@@ -711,10 +734,17 @@ module Rodauth
711
734
  ds.first
712
735
  end
713
736
 
737
+ def hmac_secret_rotation?
738
+ hmac_secret && hmac_old_secret && hmac_secret != hmac_old_secret
739
+ end
740
+
714
741
  def compute_raw_hmac(data)
715
742
  raise ArgumentError, "hmac_secret not set" unless hmac_secret
743
+ compute_raw_hmac_with_secret(data, hmac_secret)
744
+ end
716
745
 
717
- OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, hmac_secret, data)
746
+ def compute_raw_hmac_with_secret(data, secret)
747
+ OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, secret, data)
718
748
  end
719
749
 
720
750
  def _field_attributes(field)
@@ -822,6 +852,11 @@ module Rodauth
822
852
  false
823
853
  end
824
854
 
855
+ def require_response(meth)
856
+ send(meth)
857
+ raise RuntimeError, "#{meth.to_s.sub(/\A_/, '')} overridden without returning a response (should use redirect or request.halt). This is a bug in your Rodauth configuration, not a bug in Rodauth itself."
858
+ end
859
+
825
860
  def set_session_value(key, value)
826
861
  session[key] = value
827
862
  end
@@ -14,6 +14,7 @@ module Rodauth
14
14
  additional_form_tags
15
15
  button 'Change Login'
16
16
  redirect
17
+ response
17
18
 
18
19
  auth_value_methods :change_login_requires_password?
19
20
 
@@ -51,9 +52,8 @@ module Rodauth
51
52
  end
52
53
 
53
54
  after_change_login
54
- set_notice_flash change_login_notice_flash
55
- redirect change_login_redirect
56
55
  end
56
+ change_login_response
57
57
  end
58
58
 
59
59
  set_error_flash change_login_error_flash
@@ -13,6 +13,7 @@ module Rodauth
13
13
  additional_form_tags
14
14
  button 'Change Password'
15
15
  redirect
16
+ response
16
17
 
17
18
  translatable_method :new_password_label, 'New Password'
18
19
  auth_value_method :new_password_param, 'new-password'
@@ -56,8 +57,7 @@ module Rodauth
56
57
  set_password(password)
57
58
  after_change_password
58
59
  end
59
- set_notice_flash change_password_notice_flash
60
- redirect change_password_redirect
60
+ change_password_response
61
61
  end
62
62
 
63
63
  set_error_flash change_password_error_flash
@@ -11,6 +11,7 @@ module Rodauth
11
11
  after
12
12
  before
13
13
  redirect
14
+ response
14
15
 
15
16
  auth_value_method :account_closed_status_value, 3
16
17
 
@@ -50,8 +51,7 @@ module Rodauth
50
51
  end
51
52
  clear_session
52
53
 
53
- set_notice_flash close_account_notice_flash
54
- redirect close_account_redirect
54
+ close_account_response
55
55
  end
56
56
 
57
57
  set_error_flash close_account_error_flash
@@ -11,6 +11,7 @@ module Rodauth
11
11
  button 'Confirm Password'
12
12
  before
13
13
  after
14
+ response
14
15
  redirect(:password_authentication_required){confirm_password_path}
15
16
 
16
17
  session_key :confirm_password_redirect_session_key, :confirm_password_redirect
@@ -37,8 +38,7 @@ module Rodauth
37
38
  confirm_password
38
39
  after_confirm_password
39
40
  end
40
- set_notice_flash confirm_password_notice_flash
41
- redirect confirm_password_redirect
41
+ confirm_password_response
42
42
  else
43
43
  set_response_error_reason_status(:invalid_password, invalid_password_error_status)
44
44
  set_field_error(password_param, invalid_password_message)
@@ -13,6 +13,7 @@ module Rodauth
13
13
  button 'Create Account'
14
14
  additional_form_tags
15
15
  redirect
16
+ response
16
17
 
17
18
  auth_value_method :create_account_autologin?, true
18
19
  translatable_method :create_account_link_text, "Create a New Account"
@@ -79,8 +80,7 @@ module Rodauth
79
80
  if create_account_autologin?
80
81
  autologin_session('create_account')
81
82
  end
82
- set_notice_flash create_account_notice_flash
83
- redirect create_account_redirect
83
+ create_account_response
84
84
  end
85
85
  end
86
86
 
@@ -19,6 +19,7 @@ module Rodauth
19
19
  button 'Send Login Link Via Email', 'email_auth_request'
20
20
  redirect(:email_auth_email_sent){default_post_email_redirect}
21
21
  redirect(:email_auth_email_recently_sent){default_post_email_redirect}
22
+ response :email_auth_email_sent
22
23
  email :email_auth, 'Login Link'
23
24
 
24
25
  auth_value_method :email_auth_deadline_column, :deadline
@@ -57,12 +58,11 @@ module Rodauth
57
58
  r.post do
58
59
  if account_from_login(param(login_param)) && open_account?
59
60
  _email_auth_request
60
- else
61
- set_redirect_error_status(no_matching_login_error_status)
62
- set_error_reason :no_matching_login
63
- set_redirect_error_flash email_auth_request_error_flash
64
61
  end
65
62
 
63
+ set_redirect_error_status(no_matching_login_error_status)
64
+ set_error_reason :no_matching_login
65
+ set_redirect_error_flash email_auth_request_error_flash
66
66
  redirect email_auth_email_sent_redirect
67
67
  end
68
68
  end
@@ -150,7 +150,7 @@ module Rodauth
150
150
 
151
151
  def after_login_entered_during_multi_phase_login
152
152
  # If forcing email auth, just send the email link.
153
- _email_auth_request_and_redirect if force_email_auth?
153
+ _email_auth_request if force_email_auth?
154
154
 
155
155
  super
156
156
  end
@@ -169,7 +169,7 @@ module Rodauth
169
169
 
170
170
  def _multi_phase_login_forms
171
171
  forms = super
172
- forms << [30, email_auth_request_form, :_email_auth_request_and_redirect] if valid_login_entered? && allow_email_auth?
172
+ forms << [30, email_auth_request_form, :_email_auth_request] if valid_login_entered? && allow_email_auth?
173
173
  forms
174
174
  end
175
175
 
@@ -177,11 +177,6 @@ module Rodauth
177
177
  (email_last_sent = get_email_auth_email_last_sent) && (Time.now - email_last_sent < email_auth_skip_resend_email_within)
178
178
  end
179
179
 
180
- def _email_auth_request_and_redirect
181
- _email_auth_request
182
- redirect email_auth_email_sent_redirect
183
- end
184
-
185
180
  def _email_auth_request
186
181
  if email_auth_email_recently_sent?
187
182
  set_redirect_error_flash email_auth_email_recently_sent_error_flash
@@ -196,7 +191,7 @@ module Rodauth
196
191
  after_email_auth_request
197
192
  end
198
193
 
199
- set_notice_flash email_auth_email_sent_notice_flash
194
+ email_auth_email_sent_response
200
195
  end
201
196
 
202
197
  attr_reader :email_auth_key_value
@@ -69,12 +69,10 @@ module Rodauth
69
69
 
70
70
  return unless actual = yield(id)
71
71
 
72
- unless timing_safe_eql?(key, convert_email_token_key(actual))
73
- if hmac_secret && allow_raw_email_token?
74
- return unless timing_safe_eql?(key, actual)
75
- else
76
- return
77
- end
72
+ unless (hmac_secret && timing_safe_eql?(key, convert_email_token_key(actual))) ||
73
+ (hmac_secret_rotation? && timing_safe_eql?(key, compute_old_hmac(actual))) ||
74
+ ((!hmac_secret || allow_raw_email_token?) && timing_safe_eql?(key, actual))
75
+ return
78
76
  end
79
77
  ds = account_ds(id)
80
78
  ds = ds.where(account_status_column=>status_id) if status_id && !skip_status_checks?
@@ -50,6 +50,10 @@ module Rodauth
50
50
  @params[k]
51
51
  end
52
52
 
53
+ def clear_session
54
+ @session.clear
55
+ end
56
+
53
57
  def set_error_flash(message)
54
58
  @flash = message
55
59
  _handle_internal_request_error
@@ -81,6 +85,24 @@ module Rodauth
81
85
  _return_from_internal_request(recovery_codes)
82
86
  end
83
87
 
88
+ def webauthn_setup_view
89
+ cred = new_webauthn_credential
90
+ _return_from_internal_request({
91
+ webauthn_setup: cred.as_json,
92
+ webauthn_setup_challenge: cred.challenge,
93
+ webauthn_setup_challenge_hmac: compute_hmac(cred.challenge)
94
+ })
95
+ end
96
+
97
+ def webauthn_auth_view
98
+ cred = webauthn_credential_options_for_get
99
+ _return_from_internal_request({
100
+ webauthn_auth: cred.as_json,
101
+ webauthn_auth_challenge: cred.challenge,
102
+ webauthn_auth_challenge_hmac: compute_hmac(cred.challenge)
103
+ })
104
+ end
105
+
84
106
  def handle_internal_request(meth)
85
107
  catch(:halt) do
86
108
  _around_rodauth do
@@ -153,6 +175,11 @@ module Rodauth
153
175
  _set_login_param_from_account
154
176
  end
155
177
 
178
+ def before_webauthn_login_route
179
+ super
180
+ _set_login_param_from_account
181
+ end
182
+
156
183
  def account_from_key(token, status_id=nil)
157
184
  return super unless session_value
158
185
  return unless yield session_value
@@ -232,6 +259,25 @@ module Rodauth
232
259
  _handle_otp_setup(request)
233
260
  end
234
261
 
262
+ def _handle_webauthn_setup_params(request)
263
+ request.env['REQUEST_METHOD'] = 'GET'
264
+ _handle_webauthn_setup(request)
265
+ end
266
+
267
+ def _handle_webauthn_auth_params(request)
268
+ request.env['REQUEST_METHOD'] = 'GET'
269
+ _handle_webauthn_auth(request)
270
+ end
271
+
272
+ def _handle_webauthn_login_params(request)
273
+ _set_login_param_from_account
274
+ unless webauthn_login_options?
275
+ raise InternalRequestError, "no login provided" unless param_or_nil(login_param)
276
+ raise InternalRequestError, "no account for login"
277
+ end
278
+ webauthn_auth_view
279
+ end
280
+
235
281
  def _predicate_internal_request(meth, request)
236
282
  _return_false_on_error!
237
283
  _set_internal_request_return_value(true)
@@ -302,7 +348,7 @@ module Rodauth
302
348
  session[rodauth.session_key] = account_id
303
349
  unless authenticated_by = opts.delete(:authenticated_by)
304
350
  authenticated_by = case route
305
- when :otp_auth, :sms_request, :sms_auth, :recovery_auth, :valid_otp_auth?, :valid_sms_auth?, :valid_recovery_auth?
351
+ when :otp_auth, :sms_request, :sms_auth, :recovery_auth, :webauthn_auth, :webauthn_auth_params, :valid_otp_auth?, :valid_sms_auth?, :valid_recovery_auth?
306
352
  ['internal1']
307
353
  else
308
354
  ['internal1', 'internal2']
@@ -22,6 +22,7 @@ module Rodauth
22
22
 
23
23
  auth_methods(
24
24
  :json_request?,
25
+ :json_response_error?
25
26
  )
26
27
 
27
28
  auth_private_methods :json_response_body
@@ -65,6 +66,10 @@ module Rodauth
65
66
  return_json_response
66
67
  end
67
68
 
69
+ def json_response_error?
70
+ !!json_response[json_response_error_key]
71
+ end
72
+
68
73
  private
69
74
 
70
75
  def before_two_factor_manage_route
@@ -172,7 +177,7 @@ module Rodauth
172
177
  end
173
178
 
174
179
  def _return_json_response
175
- response.status ||= json_response_error_status if json_response[json_response_error_key]
180
+ response.status ||= json_response_error_status if json_response_error?
176
181
  response['Content-Type'] ||= json_response_content_type
177
182
  return_response _json_response_body(json_response)
178
183
  end
@@ -1,6 +1,7 @@
1
1
  # frozen-string-literal: true
2
2
 
3
3
  require 'jwt'
4
+ require 'jwt/version'
4
5
 
5
6
  module Rodauth
6
7
  Feature.define(:jwt, :Jwt) do
@@ -11,6 +12,7 @@ module Rodauth
11
12
  auth_value_method :jwt_authorization_ignore, /\A(?:Basic|Digest) /
12
13
  auth_value_method :jwt_authorization_remove, /\ABearer:?\s+/
13
14
  auth_value_method :jwt_decode_opts, {}.freeze
15
+ auth_value_method :jwt_old_secret, nil
14
16
  auth_value_method :jwt_session_key, nil
15
17
  auth_value_method :jwt_symbolize_deeply?, false
16
18
 
@@ -111,9 +113,23 @@ module Rodauth
111
113
  jwt_decode_opts
112
114
  end
113
115
 
116
+ if JWT::VERSION::MAJOR > 2 || (JWT::VERSION::MAJOR == 2 && JWT::VERSION::MINOR >= 4)
117
+ def _jwt_decode_secrets
118
+ secrets = [jwt_secret, jwt_old_secret]
119
+ secrets.compact!
120
+ secrets
121
+ end
122
+ # :nocov:
123
+ else
124
+ def _jwt_decode_secrets
125
+ jwt_secret
126
+ end
127
+ # :nocov:
128
+ end
129
+
114
130
  def jwt_payload
115
131
  return @jwt_payload if defined?(@jwt_payload)
116
- @jwt_payload = JWT.decode(jwt_token, jwt_secret, true, _jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
132
+ @jwt_payload = JWT.decode(jwt_token, _jwt_decode_secrets, true, _jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
117
133
  rescue JWT::DecodeError => e
118
134
  rescue_jwt_payload(e)
119
135
  end
@@ -114,7 +114,7 @@ module Rodauth
114
114
  unless key &&
115
115
  (id.to_s == session_value.to_s) &&
116
116
  (actual = get_active_refresh_token(id, token_id)) &&
117
- timing_safe_eql?(key, convert_token_key(actual)) &&
117
+ (timing_safe_eql?(key, convert_token_key(actual)) || (hmac_secret_rotation? && timing_safe_eql?(key, compute_old_hmac(actual)))) &&
118
118
  jwt_refresh_token_match?(key)
119
119
  return
120
120
  end
@@ -150,7 +150,9 @@ module Rodauth
150
150
 
151
151
  # If allowing with expired jwt access token, check the expired session contains
152
152
  # hmac matching submitted and active refresh token.
153
- timing_safe_eql?(compute_hmac(session[jwt_refresh_token_data_session_key].to_s + key), session[jwt_refresh_token_hmac_session_key].to_s)
153
+ s = session[jwt_refresh_token_hmac_session_key].to_s
154
+ h = session[jwt_refresh_token_data_session_key].to_s + key
155
+ timing_safe_eql?(compute_hmac(h), s) || (hmac_secret_rotation? && timing_safe_eql?(compute_old_hmac(h), s))
154
156
  end
155
157
 
156
158
  def get_active_refresh_token(account_id, token_id)
@@ -23,10 +23,12 @@ module Rodauth
23
23
  notice_flash "Your account has been unlocked", 'unlock_account'
24
24
  notice_flash "An email has been sent to you with a link to unlock your account", 'unlock_account_request'
25
25
  redirect :unlock_account
26
+ response :unlock_account
27
+ response :unlock_account_request
26
28
  redirect(:unlock_account_request){default_post_email_redirect}
27
29
  redirect(:unlock_account_email_recently_sent){default_post_email_redirect}
28
30
  email :unlock_account, 'Unlock Account'
29
-
31
+
30
32
  auth_value_method :unlock_account_autologin?, true
31
33
  auth_value_method :max_invalid_logins, 100
32
34
  auth_value_method :account_login_failures_table, :account_login_failures
@@ -82,14 +84,13 @@ module Rodauth
82
84
  after_unlock_account_request
83
85
  end
84
86
 
85
- set_notice_flash unlock_account_request_notice_flash
87
+ unlock_account_request_response
86
88
  else
87
89
  set_redirect_error_status(no_matching_login_error_status)
88
90
  set_error_reason :no_matching_login
89
91
  set_redirect_error_flash no_matching_login_message.to_s.capitalize
92
+ redirect unlock_account_request_redirect
90
93
  end
91
-
92
- redirect unlock_account_request_redirect
93
94
  end
94
95
  end
95
96
 
@@ -134,8 +135,7 @@ module Rodauth
134
135
  end
135
136
 
136
137
  remove_session_value(unlock_account_session_key)
137
- set_notice_flash unlock_account_notice_flash
138
- redirect unlock_account_redirect
138
+ unlock_account_response
139
139
  else
140
140
  set_response_error_reason_status(:invalid_password, invalid_password_error_status)
141
141
  set_field_error(password_param, invalid_password_message)
@@ -24,7 +24,10 @@ module Rodauth
24
24
 
25
25
  auth_value_methods :login_return_to_requested_location_path
26
26
 
27
- auth_private_methods :login_form_footer_links
27
+ auth_private_methods(
28
+ :login_form_footer_links,
29
+ :login_response
30
+ )
28
31
 
29
32
  internal_request_method
30
33
  internal_request_method :valid_login_and_password?
@@ -77,17 +80,18 @@ module Rodauth
77
80
  end
78
81
 
79
82
  attr_reader :login_form_header
83
+ attr_reader :saved_login_redirect
84
+ private :saved_login_redirect
80
85
 
81
86
  def login(auth_type)
82
- saved_login_redirect = remove_session_value(login_redirect_session_key)
87
+ @saved_login_redirect = remove_session_value(login_redirect_session_key)
83
88
  transaction do
84
89
  before_login
85
90
  login_session(auth_type)
86
91
  yield if block_given?
87
92
  after_login
88
93
  end
89
- set_notice_flash login_notice_flash
90
- redirect(saved_login_redirect || login_redirect)
94
+ require_response(:_login_response)
91
95
  end
92
96
 
93
97
  def login_required
@@ -136,6 +140,11 @@ module Rodauth
136
140
 
137
141
  private
138
142
 
143
+ def _login_response
144
+ set_notice_flash login_notice_flash
145
+ redirect(saved_login_redirect || login_redirect)
146
+ end
147
+
139
148
  def _login_form_footer_links
140
149
  []
141
150
  end