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
@@ -0,0 +1,33 @@
1
+ = New Features
2
+
3
+ * An auto_remove_recovery_codes? configuration method has been added
4
+ to the recovery_codes feature. This will automatically remove
5
+ recovery codes when the last multifactor authentication type other
6
+ than the recovery codes has been removed.
7
+
8
+ * The jwt_access_expired_status and expired_jwt_access_token_message
9
+ configuration methods have been added to the jwt_refresh feature,
10
+ for supporting custom statuses and messages for expired tokens.
11
+
12
+ = Other Improvements
13
+
14
+ * Rodauth will no longer attempt to require a feature that has
15
+ already been required. Related to this is you can now use a
16
+ a custom Rodauth feature without a rodauth/features/*.rb file
17
+ in the Ruby library path, as long as you load the feature
18
+ manually.
19
+
20
+ * Rodauth now avoids method redefinition warnings in verbose
21
+ warning mode. As Ruby 3 is dropping uninitialized instance
22
+ variable warnings, Rodauth will be verbose warning free in
23
+ Ruby 3.
24
+
25
+ = Backwards Compatibility
26
+
27
+ * The default remember cookie path is now set to '/'. This fixes
28
+ usage in the case where rodauth is loaded under a subpath of the
29
+ application (which is not the default behavior). Unfortunately,
30
+ this change can negatively affect cases where multiple rodauth
31
+ configurations are used in separate paths on the same domain.
32
+ In these cases, you should now use remember_cookie_options and
33
+ include a :path option.
@@ -0,0 +1,20 @@
1
+ = Improvements
2
+
3
+ * HttpOnly is now set by default on the remember cookie, so it is no
4
+ longer accessible from Javascript. This is a more secure approach
5
+ that makes applications using Rodauth's remember feature less
6
+ vulnerable in case they are subject to a separate XSS attack.
7
+
8
+ * When using the jwt feature, rodauth.clear_session now clears the
9
+ JWT session even when the Roda sessions plugin was in use. In most
10
+ cases, the jwt feature is not used with the Roda sessions plugin,
11
+ but in cases where the same application serves as both an JSON API
12
+ and as a HTML site, it is possible the two may be used together.
13
+
14
+ = Backwards Compatibility
15
+
16
+ * As the default remember cookie :httponly setting is now set to true,
17
+ applications using Rodauth that expected to be able to access the
18
+ remember cookie from Javascript will no longer work by default.
19
+ In these cases, you should now use remember_cookie_options and
20
+ include a :httponly=>false option.
@@ -0,0 +1,21 @@
1
+ = New Features
2
+
3
+ * A json feature has been extracted from the existing jwt feature.
4
+ This feature allows for the same JSON API previously supported
5
+ by the JWT feature, but stores the session information in the
6
+ Rack session instead of in a separate JWT. This makes it
7
+ significantly easier to have certain pages use the JSON API,
8
+ and other pages the HTML forms.
9
+
10
+ = Other Improvements
11
+
12
+ * If the remember cookie is created in an SSL request, the Secure
13
+ flag is added by default, so the cookie will not be transmitted
14
+ in non-SSL requests.
15
+
16
+ = Backwards Compatibility
17
+
18
+ * Rodauth configurations that use the remember feature and support
19
+ requests over both http and https and want to have the remember
20
+ cookie transmitted over both should now include :secure=>false in
21
+ remember_cookie_options.
data/doc/remember.rdoc CHANGED
@@ -35,7 +35,7 @@ raw_remember_token_deadline :: A deadline before which to allow a raw remember t
35
35
  remember_additional_form_tags :: HTML fragment containing additional form tags to use on the change remember setting form.
36
36
  remember_button :: The text to use for the change remember settings button.
37
37
  remember_cookie_key :: The cookie name to use for the remember token.
38
- remember_cookie_options :: Any options to set for the remember cookie.
38
+ remember_cookie_options :: Any options to set for the remember cookie. By default, the `:path` cookie option is set to `/` and `:httponly` is set to `true`. Also, `:secure` is set to `true` by default if the current request is an HTTPS request.
39
39
  remember_deadline_column :: The column name in the +remember_table+ storing the deadline after which the token will be ignored.
40
40
  remember_deadline_interval :: The amount of time for which to remember accounts, 14 days by default. Only used if +set_deadline_values?+ is true.
41
41
  remember_disable_label :: The label for disabling remembering.
data/lib/rodauth.rb CHANGED
@@ -39,14 +39,14 @@ module Rodauth
39
39
  else
40
40
  json_opt != :only
41
41
  end
42
- auth_class = (app.opts[:rodauths] ||= {})[opts[:name]] ||= Class.new(Auth)
42
+ auth_class = (app.opts[:rodauths] ||= {})[opts[:name]] ||= opts[:auth_class] || Class.new(Auth)
43
43
  if !auth_class.roda_class
44
44
  auth_class.roda_class = app
45
45
  elsif auth_class.roda_class != app
46
46
  auth_class = app.opts[:rodauths][opts[:name]] = Class.new(auth_class)
47
47
  auth_class.roda_class = app
48
48
  end
49
- auth_class.configure(&block)
49
+ auth_class.configure(&block) if block
50
50
  end
51
51
 
52
52
  FEATURES = {}
@@ -66,6 +66,7 @@ module Rodauth
66
66
  define_method(meth) do |&block|
67
67
  @auth.send(:define_method, meth, &block)
68
68
  @auth.send(:private, meth) if priv
69
+ @auth.send(:alias_method, meth, meth)
69
70
  end
70
71
  end
71
72
 
@@ -74,6 +75,7 @@ module Rodauth
74
75
  define_method(meth) do |&block|
75
76
  @auth.send(:define_method, umeth, &block)
76
77
  @auth.send(:private, umeth)
78
+ @auth.send(:alias_method, umeth, umeth)
77
79
  end
78
80
  end
79
81
 
@@ -82,6 +84,7 @@ module Rodauth
82
84
  block ||= proc{v}
83
85
  @auth.send(:define_method, meth, &block)
84
86
  @auth.send(:private, meth) if priv
87
+ @auth.send(:alias_method, meth, meth)
85
88
  end
86
89
  end
87
90
  end
@@ -240,6 +243,7 @@ module Rodauth
240
243
  instance_variable_set(iv, send(umeth))
241
244
  end
242
245
  end
246
+ alias_method(meth, meth)
243
247
  auth_private_methods(meth)
244
248
  end
245
249
 
@@ -264,11 +268,12 @@ module Rodauth
264
268
  @features = []
265
269
  @routes = []
266
270
  @route_hash = {}
271
+ @configuration = Configuration.new(self)
267
272
  end
268
273
  end
269
274
 
270
275
  def self.configure(&block)
271
- Configuration.new(self, &block)
276
+ @configuration.apply(&block)
272
277
  end
273
278
 
274
279
  def self.freeze
@@ -284,6 +289,14 @@ module Rodauth
284
289
 
285
290
  def initialize(auth, &block)
286
291
  @auth = auth
292
+ # :nocov:
293
+ # Only for backwards compatibility
294
+ # RODAUTH3: Remove
295
+ apply(&block) if block
296
+ # :nocov:
297
+ end
298
+
299
+ def apply(&block)
287
300
  load_feature(:base)
288
301
  instance_exec(&block)
289
302
  auth.allocate.post_configure
@@ -300,7 +313,7 @@ module Rodauth
300
313
  private
301
314
 
302
315
  def load_feature(feature_name)
303
- require "rodauth/features/#{feature_name}"
316
+ require "rodauth/features/#{feature_name}" unless FEATURES[feature_name]
304
317
  feature = FEATURES[feature_name]
305
318
  enable(*feature.dependencies)
306
319
  extend feature.configuration
@@ -0,0 +1,69 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'argon2'
4
+
5
+ # :nocov:
6
+ if !defined?(Argon2::VERSION) || Argon2::VERSION < '2'
7
+ raise LoadError, "argon2 version 1.x not supported as it does not support argon2id hashes"
8
+ end
9
+ # :nocov:
10
+
11
+ module Rodauth
12
+ Feature.define(:argon2, :Argon2) do
13
+ depends :login_password_requirements_base
14
+
15
+ auth_value_method :use_argon2?, true
16
+
17
+ private
18
+
19
+ def password_hash_cost
20
+ return super unless use_argon2?
21
+ argon2_hash_cost
22
+ end
23
+
24
+ def password_hash(password)
25
+ return super unless use_argon2?
26
+ ::Argon2::Password.new(password_hash_cost).create(password)
27
+ end
28
+
29
+ def password_hash_match?(hash, password)
30
+ return super unless argon2_hash_algorithm?(hash)
31
+ argon2_password_hash_match?(hash, password)
32
+ end
33
+
34
+ def password_hash_using_salt(password, salt)
35
+ return super unless argon2_hash_algorithm?(salt)
36
+
37
+ argon2_params = Hash[extract_password_hash_cost(salt)]
38
+ argon2_params[:salt_do_not_supply] = Base64.decode64(salt.split('$').last)
39
+ ::Argon2::Password.new(argon2_params).create(password)
40
+ end
41
+
42
+ def extract_password_hash_cost(hash)
43
+ return super unless argon2_hash_algorithm?(hash )
44
+
45
+ /\A\$argon2id\$v=\d+\$m=(\d+),t=(\d+)/ =~ hash
46
+ { t_cost: $2.to_i, m_cost: Math.log2($1.to_i).to_i }
47
+ end
48
+
49
+ if ENV['RACK_ENV'] == 'test'
50
+ def argon2_hash_cost
51
+ {t_cost: 1, m_cost: 3}
52
+ end
53
+ # :nocov:
54
+ else
55
+ def argon2_hash_cost
56
+ {t_cost: 2, m_cost: 16}
57
+ end
58
+ end
59
+ # :nocov:
60
+
61
+ def argon2_hash_algorithm?(hash)
62
+ hash.start_with?('$argon2id$')
63
+ end
64
+
65
+ def argon2_password_hash_match?(hash, password)
66
+ ::Argon2::Password.verify_password(password, hash)
67
+ end
68
+ end
69
+ end
@@ -102,7 +102,6 @@ module Rodauth
102
102
  :set_redirect_error_flash,
103
103
  :set_title,
104
104
  :translate,
105
- :unverified_account_message,
106
105
  :update_session
107
106
  )
108
107
 
@@ -261,6 +260,7 @@ module Rodauth
261
260
  @password_field_autocomplete_value || 'current-password'
262
261
  end
263
262
 
263
+ alias account_password_hash_column account_password_hash_column
264
264
  # If the account_password_hash_column is set, the password hash is verified in
265
265
  # ruby, it will not use a database function to do so, it will check the password
266
266
  # hash using bcrypt.
@@ -465,7 +465,7 @@ module Rodauth
465
465
  end
466
466
 
467
467
  def database_function_password_match?(name, hash_id, password, salt)
468
- db.get(Sequel.function(function_name(name), hash_id, BCrypt::Engine.hash_secret(password, salt)))
468
+ db.get(Sequel.function(function_name(name), hash_id, password_hash_using_salt(password, salt)))
469
469
  end
470
470
 
471
471
  def password_hash_match?(hash, password)
@@ -593,6 +593,10 @@ module Rodauth
593
593
  @has_password = !!get_password_hash
594
594
  end
595
595
 
596
+ def password_hash_using_salt(password, salt)
597
+ BCrypt::Engine.hash_secret(password, salt)
598
+ end
599
+
596
600
  # Get the password hash for the user. When using database authentication functions,
597
601
  # note that only the salt is returned.
598
602
  def get_password_hash
@@ -6,6 +6,7 @@ module Rodauth
6
6
 
7
7
  notice_flash 'Your login has been changed'
8
8
  error_flash 'There was an error changing your login'
9
+ translatable_method :same_as_current_login_message, 'same as current login'
9
10
  loaded_templates %w'change-login login-field login-confirm-field password-field'
10
11
  view 'change-login', 'Change Login'
11
12
  after
@@ -64,7 +65,7 @@ module Rodauth
64
65
 
65
66
  def change_login(login)
66
67
  if account_ds.get(login_column).downcase == login.downcase
67
- @login_requirement_message = 'same as current login'
68
+ @login_requirement_message = same_as_current_login_message
68
69
  return false
69
70
  end
70
71
 
@@ -24,13 +24,16 @@ module Rodauth
24
24
 
25
25
  def add_previous_password_hash(hash)
26
26
  ds = previous_password_ds
27
- keep_before = ds.reverse(previous_password_id_column).
28
- limit(nil, previous_passwords_to_check).
29
- get(previous_password_id_column)
30
27
 
31
- if keep_before
32
- ds.where(Sequel.expr(previous_password_id_column) <= keep_before).
33
- delete
28
+ unless @dont_check_previous_password
29
+ keep_before = ds.reverse(previous_password_id_column).
30
+ limit(nil, previous_passwords_to_check).
31
+ get(previous_password_id_column)
32
+
33
+ if keep_before
34
+ ds.where(Sequel.expr(previous_password_id_column) <= keep_before).
35
+ delete
36
+ end
34
37
  end
35
38
 
36
39
  # This should never raise uniqueness violations, as it uses a serial primary key
@@ -39,7 +42,7 @@ module Rodauth
39
42
 
40
43
  def password_meets_requirements?(password)
41
44
  super &&
42
- password_doesnt_match_previous_password?(password)
45
+ (@dont_check_previous_password || password_doesnt_match_previous_password?(password))
43
46
  end
44
47
 
45
48
  private
@@ -71,6 +74,16 @@ module Rodauth
71
74
  previous_password_ds.delete
72
75
  end
73
76
 
77
+ def before_create_account_route
78
+ super if defined?(super)
79
+ @dont_check_previous_password = true
80
+ end
81
+
82
+ def before_verify_account_route
83
+ super if defined?(super)
84
+ @dont_check_previous_password = true
85
+ end
86
+
74
87
  def after_create_account
75
88
  if account_password_hash_column && !(respond_to?(:verify_account_set_password?) && verify_account_set_password?)
76
89
  add_previous_password_hash(password_hash(param(password_param)))
@@ -51,7 +51,11 @@ module Rodauth
51
51
  end
52
52
 
53
53
  def token_link(route, param, key)
54
- route_url(route, param => "#{account_id}#{token_separator}#{convert_email_token_key(key)}")
54
+ route_url(route, param => token_param_value(key))
55
+ end
56
+
57
+ def token_param_value(key)
58
+ "#{account_id}#{token_separator}#{convert_email_token_key(key)}"
55
59
  end
56
60
 
57
61
  def convert_email_token_key(key)
@@ -71,7 +75,6 @@ module Rodauth
71
75
  return
72
76
  end
73
77
  end
74
-
75
78
  ds = account_ds(id)
76
79
  ds = ds.where(account_status_column=>status_id) if status_id && !skip_status_checks?
77
80
  ds.first
@@ -0,0 +1,189 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ Feature.define(:json, :Json) do
5
+ translatable_method :json_not_accepted_error_message, 'Unsupported Accept header. Must accept "application/json" or compatible content type'
6
+ translatable_method :json_non_post_error_message, 'non-POST method used in JSON API'
7
+ auth_value_method :json_accept_regexp, /(?:(?:\*|\bapplication)\/\*|\bapplication\/(?:vnd\.api\+)?json\b)/i
8
+ auth_value_method :json_check_accept?, true
9
+ auth_value_method :json_request_content_type_regexp, /\bapplication\/(?:vnd\.api\+)?json\b/i
10
+ auth_value_method :json_response_content_type, 'application/json'
11
+ auth_value_method :json_response_custom_error_status?, true
12
+ auth_value_method :json_response_error_status, 400
13
+ auth_value_method :json_response_error_key, "error"
14
+ auth_value_method :json_response_field_error_key, "field-error"
15
+ auth_value_method :json_response_success_key, "success"
16
+ translatable_method :non_json_request_error_message, 'Only JSON format requests are allowed'
17
+
18
+ auth_value_methods(
19
+ :only_json?,
20
+ :use_json?,
21
+ )
22
+
23
+ auth_methods(
24
+ :json_request?,
25
+ )
26
+
27
+ auth_private_methods :json_response_body
28
+
29
+ def set_field_error(field, message)
30
+ return super unless use_json?
31
+ json_response[json_response_field_error_key] = [field, message]
32
+ end
33
+
34
+ def set_error_flash(message)
35
+ return super unless use_json?
36
+ json_response[json_response_error_key] = message
37
+ end
38
+
39
+ def set_redirect_error_flash(message)
40
+ return super unless use_json?
41
+ json_response[json_response_error_key] = message
42
+ end
43
+
44
+ def set_notice_flash(message)
45
+ return super unless use_json?
46
+ json_response[json_response_success_key] = message if include_success_messages?
47
+ end
48
+
49
+ def set_notice_now_flash(message)
50
+ return super unless use_json?
51
+ json_response[json_response_success_key] = message if include_success_messages?
52
+ end
53
+
54
+ def json_request?
55
+ return @json_request if defined?(@json_request)
56
+ @json_request = request.content_type =~ json_request_content_type_regexp
57
+ end
58
+
59
+ def use_json?
60
+ json_request? || only_json?
61
+ end
62
+
63
+ def view(page, title)
64
+ return super unless use_json?
65
+ return_json_response
66
+ end
67
+
68
+ private
69
+
70
+ def before_view_recovery_codes
71
+ super if defined?(super)
72
+ if use_json?
73
+ json_response[:codes] = recovery_codes
74
+ json_response[json_response_success_key] ||= "" if include_success_messages?
75
+ end
76
+ end
77
+
78
+ def before_webauthn_setup_route
79
+ super if defined?(super)
80
+ if use_json? && !param_or_nil(webauthn_setup_param)
81
+ cred = new_webauthn_credential
82
+ json_response[webauthn_setup_param] = cred.as_json
83
+ json_response[webauthn_setup_challenge_param] = cred.challenge
84
+ json_response[webauthn_setup_challenge_hmac_param] = compute_hmac(cred.challenge)
85
+ end
86
+ end
87
+
88
+ def before_webauthn_auth_route
89
+ super if defined?(super)
90
+ if use_json? && !param_or_nil(webauthn_auth_param)
91
+ cred = webauth_credential_options_for_get
92
+ json_response[webauthn_auth_param] = cred.as_json
93
+ json_response[webauthn_auth_challenge_param] = cred.challenge
94
+ json_response[webauthn_auth_challenge_hmac_param] = compute_hmac(cred.challenge)
95
+ end
96
+ end
97
+
98
+ def before_webauthn_login_route
99
+ super if defined?(super)
100
+ if use_json? && !param_or_nil(webauthn_auth_param) && account_from_login(param(login_param))
101
+ cred = webauth_credential_options_for_get
102
+ json_response[webauthn_auth_param] = cred.as_json
103
+ json_response[webauthn_auth_challenge_param] = cred.challenge
104
+ json_response[webauthn_auth_challenge_hmac_param] = compute_hmac(cred.challenge)
105
+ end
106
+ end
107
+
108
+ def before_webauthn_remove_route
109
+ super if defined?(super)
110
+ if use_json? && !param_or_nil(webauthn_remove_param)
111
+ json_response[webauthn_remove_param] = account_webauthn_usage
112
+ end
113
+ end
114
+
115
+ def before_otp_setup_route
116
+ super if defined?(super)
117
+ if use_json? && otp_keys_use_hmac? && !param_or_nil(otp_setup_raw_param)
118
+ _otp_tmp_key(otp_new_secret)
119
+ json_response[otp_setup_param] = otp_user_key
120
+ json_response[otp_setup_raw_param] = otp_key
121
+ end
122
+ end
123
+
124
+ def before_rodauth
125
+ if json_request?
126
+ if json_check_accept? && (accept = request.env['HTTP_ACCEPT']) && accept !~ json_accept_regexp
127
+ response.status = 406
128
+ json_response[json_response_error_key] = json_not_accepted_error_message
129
+ _return_json_response
130
+ end
131
+
132
+ unless request.post?
133
+ response.status = 405
134
+ response.headers['Allow'] = 'POST'
135
+ json_response[json_response_error_key] = json_non_post_error_message
136
+ return_json_response
137
+ end
138
+ elsif only_json?
139
+ response.status = json_response_error_status
140
+ response.write non_json_request_error_message
141
+ request.halt
142
+ end
143
+
144
+ super
145
+ end
146
+
147
+ def redirect(_)
148
+ return super unless use_json?
149
+ return_json_response
150
+ end
151
+
152
+ def return_json_response
153
+ _return_json_response
154
+ end
155
+
156
+ def _return_json_response
157
+ response.status ||= json_response_error_status if json_response[json_response_error_key]
158
+ response['Content-Type'] ||= json_response_content_type
159
+ response.write(_json_response_body(json_response))
160
+ request.halt
161
+ end
162
+
163
+ def include_success_messages?
164
+ !json_response_success_key.nil?
165
+ end
166
+
167
+ def _json_response_body(hash)
168
+ request.send(:convert_to_json, hash)
169
+ end
170
+
171
+ def json_response
172
+ @json_response ||= {}
173
+ end
174
+
175
+ def set_redirect_error_status(status)
176
+ if use_json? && json_response_custom_error_status?
177
+ response.status = status
178
+ end
179
+ end
180
+
181
+ def set_response_error_status(status)
182
+ if use_json? && !json_response_custom_error_status?
183
+ status = json_response_error_status
184
+ end
185
+
186
+ super
187
+ end
188
+ end
189
+ end