rodauth 2.0.0 → 2.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +58 -0
  3. data/README.rdoc +14 -0
  4. data/doc/base.rdoc +2 -0
  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 +11 -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.1.0.txt +31 -0
  32. data/doc/release_notes/2.2.0.txt +39 -0
  33. data/doc/release_notes/2.3.0.txt +37 -0
  34. data/doc/release_notes/2.4.0.txt +22 -0
  35. data/doc/release_notes/2.5.0.txt +20 -0
  36. data/doc/verify_login_change.rdoc +1 -0
  37. data/lib/rodauth.rb +5 -5
  38. data/lib/rodauth/features/active_sessions.rb +5 -7
  39. data/lib/rodauth/features/audit_logging.rb +2 -0
  40. data/lib/rodauth/features/base.rb +21 -2
  41. data/lib/rodauth/features/change_password.rb +1 -1
  42. data/lib/rodauth/features/close_account.rb +8 -6
  43. data/lib/rodauth/features/create_account.rb +1 -0
  44. data/lib/rodauth/features/disallow_password_reuse.rb +4 -2
  45. data/lib/rodauth/features/email_auth.rb +2 -2
  46. data/lib/rodauth/features/http_basic_auth.rb +15 -2
  47. data/lib/rodauth/features/jwt.rb +12 -8
  48. data/lib/rodauth/features/jwt_cors.rb +15 -15
  49. data/lib/rodauth/features/jwt_refresh.rb +39 -10
  50. data/lib/rodauth/features/login.rb +23 -12
  51. data/lib/rodauth/features/login_password_requirements_base.rb +9 -4
  52. data/lib/rodauth/features/otp.rb +5 -1
  53. data/lib/rodauth/features/password_complexity.rb +4 -2
  54. data/lib/rodauth/features/password_pepper.rb +45 -0
  55. data/lib/rodauth/features/remember.rb +2 -0
  56. data/lib/rodauth/features/session_expiration.rb +1 -6
  57. data/lib/rodauth/features/single_session.rb +1 -1
  58. data/lib/rodauth/features/sms_codes.rb +0 -1
  59. data/lib/rodauth/features/two_factor_base.rb +4 -4
  60. data/lib/rodauth/features/verify_account.rb +5 -0
  61. data/lib/rodauth/features/verify_account_grace_period.rb +3 -5
  62. data/lib/rodauth/features/verify_login_change.rb +2 -1
  63. data/lib/rodauth/features/webauthn.rb +1 -3
  64. data/lib/rodauth/features/webauthn_login.rb +1 -1
  65. data/lib/rodauth/migrations.rb +16 -5
  66. data/lib/rodauth/version.rb +1 -1
  67. data/templates/password-field.str +1 -1
  68. metadata +37 -5
@@ -0,0 +1,37 @@
1
+ = New Features
2
+
3
+ * Configuration methods have been added for easier validation of
4
+ logins when logins must be valid email addresses (the default):
5
+
6
+ * login_valid_email?(login) can be used for full control of
7
+ determining whether the login is valid.
8
+
9
+ * login_email_regexp can be used to set the regexp used in the
10
+ default login_valid_email? check.
11
+
12
+ * login_not_valid_email_message can be used to set the field
13
+ error message if the login is not a valid email. Previously, this
14
+ value was hardcoded and not translatable.
15
+
16
+ * The {create,drop}_database_authentication_functions now work
17
+ correctly with uuid keys on PostgreSQL. All other parts of
18
+ Rodauth already worked correctly with uuid keys.
19
+
20
+ = Other Improvements
21
+
22
+ * The before_jwt_refresh_route hook is now called before the route
23
+ is taken. Previously, the configuration method had no effect.
24
+
25
+ * rodauth.login can now be used by external code to login the current
26
+ account (the account that rodauth.account returns). This should be
27
+ passed the authentication type string used to login, such as
28
+ password.
29
+
30
+ * The jwt_refresh route now returns an error for requests where a
31
+ valid access token for a logged in session is not provided. You
32
+ can use the jwt_refresh_without_access_token_message and
33
+ jwt_refresh_without_access_token_status configuration methods
34
+ to configure the error response.
35
+
36
+ * The new refresh token is now available to the after_refresh_token
37
+ hook by looking in json_response[jwt_refresh_token_key].
@@ -0,0 +1,22 @@
1
+ = New Features
2
+
3
+ * A password_pepper feature has been added. This allows you to use a
4
+ secret key (called a pepper) to append to passwords before hashing
5
+ and hash checking. Using this approach, if an attacker obtains the
6
+ password hash, it is unusable for cracking unless they can also
7
+ get access to the pepper.
8
+
9
+ The password_pepper feature also supports a list of previous peppers
10
+ that can be used to implement secret rotation and to support
11
+ compatibility with unpeppered passwords.
12
+
13
+ Rodauth by default uses database functions for password hash
14
+ checking on PostgreSQL, MySQL, and Microsoft SQL Server, which in
15
+ general provides more security than a password pepper, but both
16
+ approaches can be used simultaneously.
17
+
18
+ * A session_key_prefix configuration method has been added for
19
+ prefixing the values of all default session keys. This can be
20
+ useful if you are using multiple Rodauth configurations in the same
21
+ application and want to make sure the session keys for the separate
22
+ configurations do not overlap.
@@ -0,0 +1,20 @@
1
+ = New Features
2
+
3
+ * A login_return_to_requested_location_path configuration method has
4
+ been added to the login feature. This controls the path to redirect
5
+ to if using login_return_to_requested_location?. By default, this
6
+ is the same as the fullpath of the request that required login if
7
+ that request was a GET request, and nil if that request was not a
8
+ GET request. Previously, the fullpath of that request was used even
9
+ if it was not a GET request, which caused problems as browsers use a
10
+ GET request for redirects, and it is a bad idea to redirect to a path
11
+ that may not handle GET requests.
12
+
13
+ * A change_login_needs_verification_notice_flash configuration method
14
+ has been added to the verify_login_change feature, for allowing
15
+ translations when using the feature and not using the
16
+ change_login_notice_flash configuration method.
17
+
18
+ = Other Improvements
19
+
20
+ * new_password_label is now translatable.
@@ -14,6 +14,7 @@ control. Depends on the change login and email base features.
14
14
  == Auth Value Methods
15
15
 
16
16
  no_matching_verify_login_change_key_error_flash :: The flash error message to show when an invalid verify login change key is used.
17
+ change_login_needs_verification_notice_flash :: The flash notice to show after changing a login when using this feature, if +change_login_notice_flash+ is not overridden.
17
18
  verify_login_change_additional_form_tags :: HTML fragment containing additional form tags to use on the verify login change form.
18
19
  verify_login_change_autologin? :: Whether to autologin the user after successful login change verification, false by default.
19
20
  verify_login_change_button :: The text to use for the verify login change button.
@@ -119,7 +119,7 @@ module Rodauth
119
119
 
120
120
  define_method(handle_meth) do
121
121
  request.is send(route_meth) do
122
- scope.check_csrf!(check_csrf_opts, &check_csrf_block) if check_csrf?
122
+ check_csrf if check_csrf?
123
123
  before_rodauth
124
124
  send(internal_handle_meth, request)
125
125
  end
@@ -137,7 +137,9 @@ module Rodauth
137
137
  feature.module_eval(&block)
138
138
  configuration.def_configuration_methods(feature)
139
139
 
140
+ # :nocov:
140
141
  if constant
142
+ # :nocov:
141
143
  Rodauth.const_set(constant, feature)
142
144
  Rodauth::FeatureConfiguration.const_set(constant, configuration)
143
145
  end
@@ -336,10 +338,8 @@ module Rodauth
336
338
  end
337
339
 
338
340
  def freeze
339
- if opts[:rodauths]
340
- opts[:rodauths].each_value(&:freeze)
341
- opts[:rodauths].freeze
342
- end
341
+ opts[:rodauths].each_value(&:freeze)
342
+ opts[:rodauths].freeze
343
343
  super
344
344
  end
345
345
  end
@@ -118,14 +118,12 @@ module Rodauth
118
118
  end
119
119
 
120
120
  def before_logout
121
- if request.post?
122
- if param_or_nil(global_logout_param)
123
- remove_all_active_sessions
124
- else
125
- remove_current_session
126
- end
121
+ if param_or_nil(global_logout_param)
122
+ remove_all_active_sessions
123
+ else
124
+ remove_current_session
127
125
  end
128
- super if defined?(super)
126
+ super
129
127
  end
130
128
 
131
129
  def session_inactivity_deadline_condition
@@ -82,7 +82,9 @@ module Rodauth
82
82
 
83
83
  def audit_log_ds
84
84
  ds = db[audit_logging_table]
85
+ # :nocov:
85
86
  if db.database_type == :postgres
87
+ # :nocov:
86
88
  # For PostgreSQL, use RETURNING NULL. This allows the feature
87
89
  # to be used with INSERT but not SELECT permissions on the
88
90
  # table, useful for audit logging where the database user
@@ -47,6 +47,7 @@ module Rodauth
47
47
  session_key :authenticated_by_session_key, :authenticated_by
48
48
  session_key :autologin_type_session_key, :autologin_type
49
49
  auth_value_method :prefix, ''
50
+ auth_value_method :session_key_prefix, nil
50
51
  auth_value_method :require_bcrypt?, true
51
52
  auth_value_method :mark_input_fields_as_required?, true
52
53
  auth_value_method :mark_input_fields_with_autocomplete?, true
@@ -82,6 +83,7 @@ module Rodauth
82
83
  :already_logged_in,
83
84
  :authenticated?,
84
85
  :autocomplete_for_field?,
86
+ :check_csrf,
85
87
  :clear_session,
86
88
  :csrf_tag,
87
89
  :function_name,
@@ -254,6 +256,10 @@ module Rodauth
254
256
  Sequel::DATABASES.first
255
257
  end
256
258
 
259
+ def password_field_autocomplete_value
260
+ @password_field_autocomplete_value || 'current-password'
261
+ end
262
+
257
263
  # If the account_password_hash_column is set, the password hash is verified in
258
264
  # ruby, it will not use a database function to do so, it will check the password
259
265
  # hash using bcrypt.
@@ -333,6 +339,10 @@ module Rodauth
333
339
  @account = _account_from_session
334
340
  end
335
341
 
342
+ def check_csrf
343
+ scope.check_csrf!(check_csrf_opts, &check_csrf_block)
344
+ end
345
+
336
346
  def csrf_tag(path=request.path)
337
347
  return unless scope.respond_to?(:csrf_tag)
338
348
 
@@ -384,9 +394,9 @@ module Rodauth
384
394
  def password_match?(password)
385
395
  if hash = get_password_hash
386
396
  if account_password_hash_column || !use_database_authentication_functions?
387
- BCrypt::Password.new(hash) == password
397
+ password_hash_match?(hash, password)
388
398
  else
389
- db.get(Sequel.function(function_name(:rodauth_valid_password_hash), account_id, BCrypt::Engine.hash_secret(password, hash)))
399
+ database_function_password_match?(:rodauth_valid_password_hash, account_id, password, hash)
390
400
  end
391
401
  end
392
402
  end
@@ -449,6 +459,14 @@ module Rodauth
449
459
 
450
460
  private
451
461
 
462
+ def database_function_password_match?(name, hash_id, password, salt)
463
+ db.get(Sequel.function(function_name(name), hash_id, BCrypt::Engine.hash_secret(password, salt)))
464
+ end
465
+
466
+ def password_hash_match?(hash, password)
467
+ BCrypt::Password.new(hash) == password
468
+ end
469
+
452
470
  def convert_token_key(key)
453
471
  if key && hmac_secret
454
472
  compute_hmac(key)
@@ -484,6 +502,7 @@ module Rodauth
484
502
  end
485
503
 
486
504
  def convert_session_key(key)
505
+ key = "#{session_key_prefix}#{key}".to_sym if session_key_prefix
487
506
  scope.opts[:sessions_convert_symbols] ? key.to_s : key
488
507
  end
489
508
 
@@ -14,7 +14,7 @@ module Rodauth
14
14
  button 'Change Password'
15
15
  redirect
16
16
 
17
- auth_value_method :new_password_label, 'New Password'
17
+ translatable_method :new_password_label, 'New Password'
18
18
  auth_value_method :new_password_param, 'new-password'
19
19
 
20
20
  auth_value_methods(
@@ -33,7 +33,11 @@ module Rodauth
33
33
  end
34
34
 
35
35
  r.post do
36
- if !close_account_requires_password? || password_match?(param(password_param))
36
+ catch_error do
37
+ if close_account_requires_password? && !password_match?(param(password_param))
38
+ throw_error_status(invalid_password_error_status, password_param, invalid_password_message)
39
+ end
40
+
37
41
  transaction do
38
42
  before_close_account
39
43
  close_account
@@ -46,12 +50,10 @@ module Rodauth
46
50
 
47
51
  set_notice_flash close_account_notice_flash
48
52
  redirect close_account_redirect
49
- else
50
- set_response_error_status(invalid_password_error_status)
51
- set_field_error(password_param, invalid_password_message)
52
- set_error_flash close_account_error_flash
53
- close_account_view
54
53
  end
54
+
55
+ set_error_flash close_account_error_flash
56
+ close_account_view
55
57
  end
56
58
  end
57
59
 
@@ -30,6 +30,7 @@ module Rodauth
30
30
  route do |r|
31
31
  check_already_logged_in
32
32
  before_create_account_route
33
+ @password_field_autocomplete_value = 'new-password'
33
34
 
34
35
  r.get do
35
36
  create_account_view
@@ -51,11 +51,13 @@ module Rodauth
51
51
  return true if salts.empty?
52
52
 
53
53
  salts.any? do |hash_id, salt|
54
- db.get(Sequel.function(function_name(:rodauth_previous_password_hash_match), hash_id, BCrypt::Engine.hash_secret(password, salt)))
54
+ database_function_password_match?(:rodauth_previous_password_hash_match, hash_id, password, salt)
55
55
  end
56
56
  else
57
57
  # :nocov:
58
- previous_password_ds.select_map(previous_password_hash_column).any?{|hash| BCrypt::Password.new(hash) == password}
58
+ previous_password_ds.select_map(previous_password_hash_column).any? do |hash|
59
+ password_hash_match?(hash, password)
60
+ end
59
61
  # :nocov:
60
62
  end
61
63
 
@@ -94,7 +94,7 @@ module Rodauth
94
94
  redirect email_auth_email_sent_redirect
95
95
  end
96
96
 
97
- _login('email_auth')
97
+ login('email_auth')
98
98
  end
99
99
  end
100
100
 
@@ -215,7 +215,7 @@ module Rodauth
215
215
  # that allows login access to the account becomes a
216
216
  # security liability, and it is best to remove it.
217
217
  remove_email_auth_key
218
- super if defined?(super)
218
+ super
219
219
  end
220
220
 
221
221
  def after_close_account
@@ -5,11 +5,20 @@ module Rodauth
5
5
  auth_value_method :http_basic_auth_realm, "protected"
6
6
  auth_value_method :require_http_basic_auth?, false
7
7
 
8
+ def logged_in?
9
+ ret = super
10
+
11
+ if !ret && !defined?(@checked_http_basic_auth)
12
+ http_basic_auth
13
+ ret = super
14
+ end
15
+
16
+ ret
17
+ end
18
+
8
19
  def require_login
9
20
  if require_http_basic_auth?
10
21
  require_http_basic_auth
11
- elsif !logged_in?
12
- http_basic_auth
13
22
  end
14
23
 
15
24
  super
@@ -23,6 +32,9 @@ module Rodauth
23
32
  end
24
33
 
25
34
  def http_basic_auth
35
+ return @checked_http_basic_auth if defined?(@checked_http_basic_auth)
36
+
37
+ @checked_http_basic_auth = nil
26
38
  return unless token = ((v = request.env['HTTP_AUTHORIZATION']) && v[/\A *Basic (.*)\Z/, 1])
27
39
 
28
40
  username, password = token.unpack("m*").first.split(/:/, 2)
@@ -50,6 +62,7 @@ module Rodauth
50
62
  after_login
51
63
  end
52
64
 
65
+ @checked_http_basic_auth = true
53
66
  return true
54
67
  end
55
68
 
@@ -50,7 +50,7 @@ module Rodauth
50
50
  json_response[json_response_error_key] = invalid_jwt_format_error_message
51
51
  response.status ||= json_response_error_status
52
52
  response['Content-Type'] ||= json_response_content_type
53
- response.write(request.send(:convert_to_json, json_response))
53
+ response.write(_json_response_body(json_response))
54
54
  request.halt
55
55
  end
56
56
 
@@ -138,15 +138,25 @@ module Rodauth
138
138
  !!(jwt_token && jwt_payload)
139
139
  end
140
140
 
141
+ def view(page, title)
142
+ return super unless use_jwt?
143
+ return_json_response
144
+ end
145
+
141
146
  private
142
147
 
148
+ def check_csrf?
149
+ return false if use_jwt?
150
+ super
151
+ end
152
+
143
153
  def before_rodauth
144
154
  if json_request?
145
155
  if jwt_check_accept? && (accept = request.env['HTTP_ACCEPT']) && accept !~ json_accept_regexp
146
156
  response.status = 406
147
157
  json_response[json_response_error_key] = json_not_accepted_error_message
148
158
  response['Content-Type'] ||= json_response_content_type
149
- response.write(request.send(:convert_to_json, json_response))
159
+ response.write(_json_response_body(json_response))
150
160
  request.halt
151
161
  end
152
162
 
@@ -251,12 +261,6 @@ module Rodauth
251
261
  @json_response ||= {}
252
262
  end
253
263
 
254
- def _view(meth, page)
255
- return super unless use_jwt?
256
- return super if meth == :render
257
- return_json_response
258
- end
259
-
260
264
  def _json_response_body(hash)
261
265
  request.send(:convert_to_json, hash)
262
266
  end
@@ -13,27 +13,27 @@ module Rodauth
13
13
  auth_methods(:jwt_cors_allow?)
14
14
 
15
15
  def jwt_cors_allow?
16
- if origin = request.env['HTTP_ORIGIN']
17
- case allowed = jwt_cors_allow_origin
18
- when String
19
- timing_safe_eql?(origin, allowed)
20
- when Array
21
- allowed.any?{|s| timing_safe_eql?(origin, s)}
22
- when Regexp
23
- allowed =~ origin
24
- when true
25
- true
26
- else
27
- false
28
- end
16
+ return false unless origin = request.env['HTTP_ORIGIN']
17
+
18
+ case allowed = jwt_cors_allow_origin
19
+ when String
20
+ timing_safe_eql?(origin, allowed)
21
+ when Array
22
+ allowed.any?{|s| timing_safe_eql?(origin, s)}
23
+ when Regexp
24
+ allowed =~ origin
25
+ when true
26
+ true
27
+ else
28
+ false
29
29
  end
30
30
  end
31
31
 
32
32
  private
33
33
 
34
34
  def before_rodauth
35
- if (origin = request.env['HTTP_ORIGIN']) && jwt_cors_allow?
36
- response['Access-Control-Allow-Origin'] = origin
35
+ if jwt_cors_allow?
36
+ response['Access-Control-Allow-Origin'] = request.env['HTTP_ORIGIN']
37
37
 
38
38
  # Handle CORS preflight request
39
39
  if request.request_method == 'OPTIONS'
@@ -19,23 +19,29 @@ module Rodauth
19
19
  auth_value_method :jwt_refresh_token_key_column, :key
20
20
  auth_value_method :jwt_refresh_token_key_param, 'refresh_token'
21
21
  auth_value_method :jwt_refresh_token_table, :account_jwt_refresh_keys
22
+ translatable_method :jwt_refresh_without_access_token_message, 'no JWT access token provided during refresh'
23
+ auth_value_method :jwt_refresh_without_access_token_status, 401
22
24
 
23
25
  auth_private_methods(
24
26
  :account_from_refresh_token
25
27
  )
26
28
 
27
29
  route do |r|
30
+ before_jwt_refresh_route
31
+
28
32
  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
33
+ if !session_value
34
+ response.status ||= jwt_refresh_without_access_token_status
35
+ json_response[json_response_error_key] = jwt_refresh_without_access_token_message
36
+ elsif (refresh_token = param_or_nil(jwt_refresh_token_key_param)) && account_from_refresh_token(refresh_token)
31
37
  transaction do
32
38
  before_refresh_token
33
39
  formatted_token = generate_refresh_token
34
40
  remove_jwt_refresh_token_key(refresh_token)
41
+ json_response[jwt_refresh_token_key] = formatted_token
42
+ json_response[jwt_access_token_key] = session_jwt
35
43
  after_refresh_token
36
44
  end
37
- json_response[jwt_refresh_token_key] = formatted_token
38
- json_response[jwt_access_token_key] = session_jwt
39
45
  else
40
46
  json_response[json_response_error_key] = jwt_refresh_invalid_token_message
41
47
  response.status ||= json_response_error_status
@@ -80,14 +86,10 @@ module Rodauth
80
86
  private
81
87
 
82
88
  def _account_from_refresh_token(token)
83
- id, token = split_token(token)
84
- return unless id && token
85
-
86
- token_id, key = split_token(token)
87
- return unless token_id && key
89
+ id, token_id, key = _account_refresh_token_split(token)
88
90
 
91
+ return unless key
89
92
  return unless actual = get_active_refresh_token(id, token_id)
90
-
91
93
  return unless timing_safe_eql?(key, convert_token_key(actual))
92
94
 
93
95
  ds = account_ds(id)
@@ -95,6 +97,16 @@ module Rodauth
95
97
  ds.first
96
98
  end
97
99
 
100
+ def _account_refresh_token_split(token)
101
+ id, token = split_token(token)
102
+ return unless id && token
103
+
104
+ token_id, key = split_token(token)
105
+ return unless token_id && key
106
+
107
+ [id, token_id, key]
108
+ end
109
+
98
110
  def get_active_refresh_token(account_id, token_id)
99
111
  jwt_refresh_token_account_ds(account_id).
100
112
  where(Sequel::CURRENT_TIMESTAMP > jwt_refresh_token_deadline_column).
@@ -134,6 +146,23 @@ module Rodauth
134
146
  hash
135
147
  end
136
148
 
149
+ def before_logout
150
+ if token = param_or_nil(jwt_refresh_token_key_param)
151
+ if token == 'all'
152
+ jwt_refresh_token_account_ds(session_value).delete
153
+ else
154
+ id, token_id, key = _account_refresh_token_split(token)
155
+
156
+ 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
160
+ end
161
+ end
162
+ end
163
+ super if defined?(super)
164
+ end
165
+
137
166
  def after_close_account
138
167
  jwt_refresh_token_account_ds(account_id).delete
139
168
  super if defined?(super)