rodauth 2.1.0 → 2.6.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +56 -0
  3. data/README.rdoc +14 -0
  4. data/doc/base.rdoc +3 -1
  5. data/doc/guides/admin_activation.rdoc +46 -0
  6. data/doc/guides/already_authenticated.rdoc +10 -0
  7. data/doc/guides/alternative_login.rdoc +46 -0
  8. data/doc/guides/create_account_programmatically.rdoc +38 -0
  9. data/doc/guides/delay_password.rdoc +25 -0
  10. data/doc/guides/email_only.rdoc +16 -0
  11. data/doc/guides/i18n.rdoc +26 -0
  12. data/doc/{internals.rdoc → guides/internals.rdoc} +0 -0
  13. data/doc/guides/links.rdoc +12 -0
  14. data/doc/guides/login_return.rdoc +37 -0
  15. data/doc/guides/password_column.rdoc +25 -0
  16. data/doc/guides/password_confirmation.rdoc +37 -0
  17. data/doc/guides/password_requirements.rdoc +30 -0
  18. data/doc/guides/paths.rdoc +36 -0
  19. data/doc/guides/query_params.rdoc +9 -0
  20. data/doc/guides/redirects.rdoc +17 -0
  21. data/doc/guides/registration_field.rdoc +68 -0
  22. data/doc/guides/require_mfa.rdoc +30 -0
  23. data/doc/guides/reset_password_autologin.rdoc +21 -0
  24. data/doc/guides/status_column.rdoc +28 -0
  25. data/doc/guides/totp_or_recovery.rdoc +16 -0
  26. data/doc/jwt_refresh.rdoc +17 -0
  27. data/doc/login.rdoc +8 -0
  28. data/doc/login_password_requirements_base.rdoc +3 -0
  29. data/doc/otp.rdoc +1 -0
  30. data/doc/password_pepper.rdoc +44 -0
  31. data/doc/release_notes/2.2.0.txt +39 -0
  32. data/doc/release_notes/2.3.0.txt +37 -0
  33. data/doc/release_notes/2.4.0.txt +22 -0
  34. data/doc/release_notes/2.5.0.txt +20 -0
  35. data/doc/release_notes/2.6.0.txt +37 -0
  36. data/doc/verify_login_change.rdoc +1 -0
  37. data/javascript/webauthn_auth.js +9 -9
  38. data/javascript/webauthn_setup.js +9 -6
  39. data/lib/rodauth.rb +13 -9
  40. data/lib/rodauth/features/active_sessions.rb +5 -7
  41. data/lib/rodauth/features/audit_logging.rb +2 -0
  42. data/lib/rodauth/features/base.rb +18 -3
  43. data/lib/rodauth/features/change_password.rb +1 -1
  44. data/lib/rodauth/features/close_account.rb +8 -6
  45. data/lib/rodauth/features/confirm_password.rb +2 -2
  46. data/lib/rodauth/features/disallow_password_reuse.rb +4 -2
  47. data/lib/rodauth/features/email_auth.rb +2 -2
  48. data/lib/rodauth/features/jwt.rb +10 -7
  49. data/lib/rodauth/features/jwt_cors.rb +15 -15
  50. data/lib/rodauth/features/jwt_refresh.rb +76 -10
  51. data/lib/rodauth/features/login.rb +23 -12
  52. data/lib/rodauth/features/login_password_requirements_base.rb +9 -4
  53. data/lib/rodauth/features/otp.rb +5 -1
  54. data/lib/rodauth/features/password_complexity.rb +4 -2
  55. data/lib/rodauth/features/password_pepper.rb +45 -0
  56. data/lib/rodauth/features/remember.rb +2 -0
  57. data/lib/rodauth/features/session_expiration.rb +1 -6
  58. data/lib/rodauth/features/single_session.rb +1 -1
  59. data/lib/rodauth/features/sms_codes.rb +0 -1
  60. data/lib/rodauth/features/two_factor_base.rb +4 -4
  61. data/lib/rodauth/features/verify_account.rb +10 -6
  62. data/lib/rodauth/features/verify_account_grace_period.rb +2 -4
  63. data/lib/rodauth/features/verify_login_change.rb +2 -1
  64. data/lib/rodauth/features/webauthn.rb +1 -3
  65. data/lib/rodauth/features/webauthn_login.rb +1 -1
  66. data/lib/rodauth/migrations.rb +16 -5
  67. data/lib/rodauth/version.rb +1 -1
  68. metadata +37 -5
@@ -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
@@ -19,23 +22,31 @@ module Rodauth
19
22
  auth_value_method :jwt_refresh_token_key_column, :key
20
23
  auth_value_method :jwt_refresh_token_key_param, 'refresh_token'
21
24
  auth_value_method :jwt_refresh_token_table, :account_jwt_refresh_keys
25
+ translatable_method :jwt_refresh_without_access_token_message, 'no JWT access token provided during refresh'
26
+ auth_value_method :jwt_refresh_without_access_token_status, 401
22
27
 
23
28
  auth_private_methods(
24
29
  :account_from_refresh_token
25
30
  )
26
31
 
27
32
  route do |r|
33
+ @jwt_refresh_route = true
34
+ before_jwt_refresh_route
35
+
28
36
  r.post do
29
- if (refresh_token = param_or_nil(jwt_refresh_token_key_param)) && account_from_refresh_token(refresh_token)
30
- formatted_token = nil
37
+ if !session_value
38
+ response.status ||= jwt_refresh_without_access_token_status
39
+ json_response[json_response_error_key] = jwt_refresh_without_access_token_message
40
+ elsif (refresh_token = param_or_nil(jwt_refresh_token_key_param)) && account_from_refresh_token(refresh_token)
31
41
  transaction do
32
42
  before_refresh_token
33
43
  formatted_token = generate_refresh_token
34
44
  remove_jwt_refresh_token_key(refresh_token)
45
+ set_jwt_refresh_token_hmac_session_key(formatted_token)
46
+ json_response[jwt_refresh_token_key] = formatted_token
47
+ json_response[jwt_access_token_key] = session_jwt
35
48
  after_refresh_token
36
49
  end
37
- json_response[jwt_refresh_token_key] = formatted_token
38
- json_response[jwt_access_token_key] = session_jwt
39
50
  else
40
51
  json_response[json_response_error_key] = jwt_refresh_invalid_token_message
41
52
  response.status ||= json_response_error_status
@@ -52,7 +63,9 @@ module Rodauth
52
63
  # JWT login puts the access token in the header.
53
64
  # We put the refresh token in the body.
54
65
  # Note, do not put the access_token in the body here, as the access token content is not yet finalised.
55
- json_response['refresh_token'] = generate_refresh_token
66
+ token = json_response['refresh_token'] = generate_refresh_token
67
+
68
+ set_jwt_refresh_token_hmac_session_key(token)
56
69
  end
57
70
 
58
71
  def set_jwt_token(token)
@@ -80,19 +93,46 @@ module Rodauth
80
93
  private
81
94
 
82
95
  def _account_from_refresh_token(token)
96
+ id, token_id, key = _account_refresh_token_split(token)
97
+
98
+ unless key &&
99
+ (id == session_value.to_s) &&
100
+ (actual = get_active_refresh_token(id, token_id)) &&
101
+ timing_safe_eql?(key, convert_token_key(actual)) &&
102
+ jwt_refresh_token_match?(key)
103
+ return
104
+ end
105
+
106
+ ds = account_ds(id)
107
+ ds = ds.where(account_status_column=>account_open_status_value) unless skip_status_checks?
108
+ ds.first
109
+ end
110
+
111
+ def _account_refresh_token_split(token)
83
112
  id, token = split_token(token)
84
113
  return unless id && token
85
114
 
86
115
  token_id, key = split_token(token)
87
116
  return unless token_id && key
88
117
 
89
- return unless actual = get_active_refresh_token(id, token_id)
118
+ [id, token_id, key]
119
+ end
90
120
 
91
- return unless timing_safe_eql?(key, convert_token_key(actual))
121
+ def _jwt_decode_opts
122
+ if allow_refresh_with_expired_jwt_access_token? && @jwt_refresh_route
123
+ Hash[super].merge!(:verify_expiration=>false)
124
+ else
125
+ super
126
+ end
127
+ end
92
128
 
93
- ds = account_ds(id)
94
- ds = ds.where(account_status_column=>account_open_status_value) unless skip_status_checks?
95
- ds.first
129
+ def jwt_refresh_token_match?(key)
130
+ # We don't need to match tokens if we are requiring a valid current access token
131
+ return true unless allow_refresh_with_expired_jwt_access_token?
132
+
133
+ # If allowing with expired jwt access token, check the expired session contains
134
+ # hmac matching submitted and active refresh token.
135
+ timing_safe_eql?(compute_hmac(session[jwt_refresh_token_data_session_key].to_s + key), session[jwt_refresh_token_hmac_session_key].to_s)
96
136
  end
97
137
 
98
138
  def get_active_refresh_token(account_id, token_id)
@@ -134,6 +174,32 @@ module Rodauth
134
174
  hash
135
175
  end
136
176
 
177
+ def set_jwt_refresh_token_hmac_session_key(token)
178
+ if allow_refresh_with_expired_jwt_access_token?
179
+ key = _account_refresh_token_split(token).last
180
+ data = random_key
181
+ set_session_value(jwt_refresh_token_data_session_key, data)
182
+ set_session_value(jwt_refresh_token_hmac_session_key, compute_hmac(data + key))
183
+ end
184
+ end
185
+
186
+ def before_logout
187
+ if token = param_or_nil(jwt_refresh_token_key_param)
188
+ if token == 'all'
189
+ jwt_refresh_token_account_ds(session_value).delete
190
+ else
191
+ id, token_id, key = _account_refresh_token_split(token)
192
+
193
+ 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
197
+ end
198
+ end
199
+ end
200
+ super if defined?(super)
201
+ end
202
+
137
203
  def after_close_account
138
204
  jwt_refresh_token_account_ds(account_id).delete
139
205
  super if defined?(super)
@@ -23,6 +23,8 @@ module Rodauth
23
23
  auth_cached_method :login_form_footer_links
24
24
  auth_cached_method :login_form_footer
25
25
 
26
+ auth_value_methods :login_return_to_requested_location_path
27
+
26
28
  route do |r|
27
29
  check_already_logged_in
28
30
  before_login_route
@@ -62,7 +64,7 @@ module Rodauth
62
64
  throw_error_status(login_error_status, password_param, invalid_password_message)
63
65
  end
64
66
 
65
- _login('password')
67
+ login('password')
66
68
  end
67
69
 
68
70
  set_error_flash login_error_flash unless skip_error_flash
@@ -72,13 +74,29 @@ module Rodauth
72
74
 
73
75
  attr_reader :login_form_header
74
76
 
77
+ def login(auth_type)
78
+ saved_login_redirect = remove_session_value(login_redirect_session_key)
79
+ transaction do
80
+ before_login
81
+ login_session(auth_type)
82
+ yield if block_given?
83
+ after_login
84
+ end
85
+ set_notice_flash login_notice_flash
86
+ redirect(saved_login_redirect || login_redirect)
87
+ end
88
+
75
89
  def login_required
76
- if login_return_to_requested_location?
77
- set_session_value(login_redirect_session_key, request.fullpath)
90
+ if login_return_to_requested_location? && (path = login_return_to_requested_location_path)
91
+ set_session_value(login_redirect_session_key, path)
78
92
  end
79
93
  super
80
94
  end
81
95
 
96
+ def login_return_to_requested_location_path
97
+ request.fullpath if request.get?
98
+ end
99
+
82
100
  def after_login_entered_during_multi_phase_login
83
101
  set_notice_now_flash need_password_notice_flash
84
102
  if multi_phase_login_forms.length == 1 && (meth = multi_phase_login_forms[0][2])
@@ -126,15 +144,8 @@ module Rodauth
126
144
  end
127
145
 
128
146
  def _login(auth_type)
129
- saved_login_redirect = remove_session_value(login_redirect_session_key)
130
- transaction do
131
- before_login
132
- login_session(auth_type)
133
- yield if block_given?
134
- after_login
135
- end
136
- set_notice_flash login_notice_flash
137
- redirect(saved_login_redirect || login_redirect)
147
+ warn("Deprecated #_login method called, use #login instead.")
148
+ login(auth_type)
138
149
  end
139
150
  end
140
151
  end
@@ -4,8 +4,10 @@ module Rodauth
4
4
  Feature.define(:login_password_requirements_base, :LoginPasswordRequirementsBase) do
5
5
  translatable_method :already_an_account_with_this_login_message, 'already an account with this login'
6
6
  auth_value_method :login_confirm_param, 'login-confirm'
7
+ auth_value_method :login_email_regexp, /\A[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+\z/
7
8
  auth_value_method :login_minimum_length, 3
8
9
  auth_value_method :login_maximum_length, 255
10
+ translatable_method :login_not_valid_email_message, 'not a valid email address'
9
11
  translatable_method :logins_do_not_match_message, 'logins do not match'
10
12
  auth_value_method :password_confirm_param, 'password-confirm'
11
13
  auth_value_method :password_minimum_length, 6
@@ -28,6 +30,7 @@ module Rodauth
28
30
 
29
31
  auth_methods(
30
32
  :login_meets_requirements?,
33
+ :login_valid_email?,
31
34
  :password_hash,
32
35
  :password_meets_requirements?,
33
36
  :set_password
@@ -104,13 +107,15 @@ module Rodauth
104
107
 
105
108
  def login_meets_email_requirements?(login)
106
109
  return true unless require_email_address_logins?
107
- if login =~ /\A[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+\z/
108
- return true
109
- end
110
- @login_requirement_message = 'not a valid email address'
110
+ return true if login_valid_email?(login)
111
+ @login_requirement_message = login_not_valid_email_message
111
112
  return false
112
113
  end
113
114
 
115
+ def login_valid_email?(login)
116
+ login =~ login_email_regexp
117
+ end
118
+
114
119
  def password_meets_length_requirements?(password)
115
120
  return true if password_minimum_length <= password.length
116
121
  @password_requirement_message = password_too_short_message
@@ -79,6 +79,7 @@ module Rodauth
79
79
  :otp,
80
80
  :otp_exists?,
81
81
  :otp_key,
82
+ :otp_last_use,
82
83
  :otp_locked_out?,
83
84
  :otp_new_secret,
84
85
  :otp_provisioning_name,
@@ -255,7 +256,6 @@ module Rodauth
255
256
  def otp_remove
256
257
  otp_key_ds.delete
257
258
  @otp_key = nil
258
- super if defined?(super)
259
259
  end
260
260
 
261
261
  def otp_add_key
@@ -269,6 +269,10 @@ module Rodauth
269
269
  update(otp_keys_last_use_column=>Sequel::CURRENT_TIMESTAMP) == 1
270
270
  end
271
271
 
272
+ def otp_last_use
273
+ convert_timestamp(otp_key_ds.get(otp_keys_last_use_column))
274
+ end
275
+
272
276
  def otp_record_authentication_failure
273
277
  otp_key_ds.update(otp_keys_failures_column=>Sequel.identifier(otp_keys_failures_column) + 1)
274
278
  end
@@ -26,14 +26,16 @@ module Rodauth
26
26
 
27
27
  def post_configure
28
28
  super
29
- return if singleton_methods.map(&:to_sym).include?(:password_dictionary)
29
+ return if method(:password_dictionary).owner != Rodauth::PasswordComplexity
30
30
 
31
31
  case password_dictionary_file
32
32
  when false
33
- return
33
+ # nothing
34
34
  when nil
35
35
  default_dictionary_file = '/usr/share/dict/words'
36
+ # :nocov:
36
37
  if File.file?(default_dictionary_file)
38
+ # :nocov:
37
39
  words = File.read(default_dictionary_file)
38
40
  end
39
41
  else
@@ -0,0 +1,45 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ Feature.define(:password_pepper, :PasswordPepper) do
5
+ depends :login_password_requirements_base
6
+
7
+ auth_value_method :password_pepper, nil
8
+ auth_value_method :previous_password_peppers, [""]
9
+ auth_value_method :password_pepper_update?, true
10
+
11
+ def password_match?(password)
12
+ if (result = super) && @previous_pepper_matched && password_pepper_update?
13
+ set_password(password)
14
+ end
15
+
16
+ result
17
+ end
18
+
19
+ private
20
+
21
+ def password_hash(password)
22
+ super(password + password_pepper.to_s)
23
+ end
24
+
25
+ def password_hash_match?(hash, password)
26
+ return super if password_pepper.nil?
27
+
28
+ return true if super(hash, password + password_pepper)
29
+
30
+ @previous_pepper_matched = previous_password_peppers.any? do |pepper|
31
+ super(hash, password + pepper)
32
+ end
33
+ end
34
+
35
+ def database_function_password_match?(name, hash_id, password, salt)
36
+ return super if password_pepper.nil?
37
+
38
+ return true if super(name, hash_id, password + password_pepper, salt)
39
+
40
+ @previous_pepper_matched = previous_password_peppers.any? do |pepper|
41
+ super(name, hash_id, password + pepper, salt)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -58,7 +58,9 @@ module Rodauth
58
58
  if [remember_remember_param_value, remember_forget_param_value, remember_disable_param_value].include?(remember)
59
59
  transaction do
60
60
  before_remember
61
+ # :nocov:
61
62
  case remember
63
+ # :nocov:
62
64
  when remember_remember_param_value
63
65
  remember_login
64
66
  when remember_forget_param_value
@@ -3,6 +3,7 @@
3
3
  module Rodauth
4
4
  Feature.define(:session_expiration, :SessionExpiration) do
5
5
  error_flash "This session has expired, please login again"
6
+ redirect{require_login_redirect}
6
7
 
7
8
  auth_value_method :max_session_lifetime, 86400
8
9
  session_key :session_created_session_key, :session_created_at
@@ -11,8 +12,6 @@ module Rodauth
11
12
  auth_value_method :session_inactivity_timeout, 1800
12
13
  session_key :session_last_activity_session_key, :last_session_activity_at
13
14
 
14
- auth_value_methods :session_expiration_redirect
15
-
16
15
  def check_session_expiration
17
16
  return unless logged_in?
18
17
 
@@ -43,10 +42,6 @@ module Rodauth
43
42
  redirect session_expiration_redirect
44
43
  end
45
44
 
46
- def session_expiration_redirect
47
- require_login_redirect
48
- end
49
-
50
45
  def update_session
51
46
  super
52
47
  t = Time.now.to_i
@@ -85,7 +85,7 @@ module Rodauth
85
85
  end
86
86
 
87
87
  def before_logout
88
- reset_single_session_key if request.post?
88
+ reset_single_session_key
89
89
  super if defined?(super)
90
90
  end
91
91
 
@@ -337,7 +337,6 @@ module Rodauth
337
337
  def sms_disable
338
338
  sms_ds.delete
339
339
  @sms = nil
340
- super if defined?(super)
341
340
  end
342
341
 
343
342
  def sms_confirm_failure
@@ -125,7 +125,7 @@ module Rodauth
125
125
  return true if two_factor_authenticated?
126
126
 
127
127
  # True if authenticated via single factor and 2nd factor not setup
128
- !two_factor_authentication_setup?
128
+ !uses_two_factor_authentication?
129
129
  end
130
130
 
131
131
  def require_authentication
@@ -134,7 +134,7 @@ module Rodauth
134
134
  # Avoid database query if already authenticated via 2nd factor
135
135
  return if two_factor_authenticated?
136
136
 
137
- require_two_factor_authenticated if two_factor_authentication_setup?
137
+ require_two_factor_authenticated if uses_two_factor_authentication?
138
138
  end
139
139
 
140
140
  def require_two_factor_setup
@@ -208,11 +208,11 @@ module Rodauth
208
208
  end
209
209
 
210
210
  def _two_factor_setup_links
211
- (super if defined?(super)) || []
211
+ []
212
212
  end
213
213
 
214
214
  def _two_factor_remove_links
215
- (super if defined?(super)) || []
215
+ []
216
216
  end
217
217
 
218
218
  def _two_factor_remove_all_from_session
@@ -70,6 +70,7 @@ module Rodauth
70
70
  end
71
71
 
72
72
  r.post do
73
+ verified = false
73
74
  if account_from_login(param(login_param)) && allow_resending_verify_account_email?
74
75
  if verify_account_email_recently_sent?
75
76
  set_redirect_error_flash verify_account_email_recently_sent_error_flash
@@ -79,8 +80,11 @@ module Rodauth
79
80
  before_verify_account_email_resend
80
81
  if verify_account_email_resend
81
82
  after_verify_account_email_resend
83
+ verified = true
82
84
  end
85
+ end
83
86
 
87
+ if verified
84
88
  set_notice_flash verify_account_email_sent_notice_flash
85
89
  else
86
90
  set_redirect_error_status(no_matching_login_error_status)
@@ -241,6 +245,12 @@ module Rodauth
241
245
  end
242
246
  end
243
247
 
248
+ def setup_account_verification
249
+ generate_verify_account_key_value
250
+ create_verify_account_key
251
+ send_verify_account_email
252
+ end
253
+
244
254
  private
245
255
 
246
256
  def _login_form_footer_links
@@ -272,12 +282,6 @@ module Rodauth
272
282
  super
273
283
  end
274
284
 
275
- def setup_account_verification
276
- generate_verify_account_key_value
277
- create_verify_account_key
278
- send_verify_account_email
279
- end
280
-
281
285
  def verify_account_check_already_logged_in
282
286
  check_already_logged_in
283
287
  end