rodauth 2.2.0 → 2.7.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +50 -0
  3. data/README.rdoc +14 -0
  4. data/doc/base.rdoc +3 -1
  5. data/doc/jwt_refresh.rdoc +13 -0
  6. data/doc/login.rdoc +8 -0
  7. data/doc/login_password_requirements_base.rdoc +3 -0
  8. data/doc/password_pepper.rdoc +44 -0
  9. data/doc/recovery_codes.rdoc +2 -1
  10. data/doc/release_notes/2.3.0.txt +37 -0
  11. data/doc/release_notes/2.4.0.txt +22 -0
  12. data/doc/release_notes/2.5.0.txt +20 -0
  13. data/doc/release_notes/2.6.0.txt +37 -0
  14. data/doc/release_notes/2.7.0.txt +33 -0
  15. data/doc/remember.rdoc +1 -1
  16. data/doc/verify_login_change.rdoc +1 -0
  17. data/javascript/webauthn_auth.js +9 -9
  18. data/javascript/webauthn_setup.js +9 -6
  19. data/lib/rodauth.rb +14 -6
  20. data/lib/rodauth/features/base.rb +19 -4
  21. data/lib/rodauth/features/change_password.rb +1 -1
  22. data/lib/rodauth/features/close_account.rb +8 -6
  23. data/lib/rodauth/features/confirm_password.rb +2 -2
  24. data/lib/rodauth/features/disallow_password_reuse.rb +4 -2
  25. data/lib/rodauth/features/email_auth.rb +1 -1
  26. data/lib/rodauth/features/jwt.rb +11 -3
  27. data/lib/rodauth/features/jwt_refresh.rb +70 -8
  28. data/lib/rodauth/features/login.rb +23 -12
  29. data/lib/rodauth/features/login_password_requirements_base.rb +9 -4
  30. data/lib/rodauth/features/otp.rb +0 -2
  31. data/lib/rodauth/features/password_pepper.rb +45 -0
  32. data/lib/rodauth/features/recovery_codes.rb +22 -1
  33. data/lib/rodauth/features/remember.rb +6 -1
  34. data/lib/rodauth/features/session_expiration.rb +1 -6
  35. data/lib/rodauth/features/verify_account.rb +6 -7
  36. data/lib/rodauth/features/verify_login_change.rb +2 -1
  37. data/lib/rodauth/features/webauthn_login.rb +1 -1
  38. data/lib/rodauth/migrations.rb +16 -5
  39. data/lib/rodauth/version.rb +1 -1
  40. metadata +16 -3
@@ -1,26 +1,29 @@
1
1
  (function() {
2
+ var pack = function(v) { return btoa(String.fromCharCode.apply(null, new Uint8Array(v))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); };
3
+ var unpack = function(v) { return Uint8Array.from(atob(v.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); };
2
4
  var element = document.getElementById('webauthn-setup-form');
3
5
  var f = function(e) {
4
6
  //console.log(e);
5
7
  e.preventDefault();
6
8
  if (navigator.credentials) {
7
9
  var opts = JSON.parse(element.getAttribute("data-credential-options"));
8
- opts.challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
9
- opts.user.id = Uint8Array.from(atob(opts.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
10
+ opts.challenge = unpack(opts.challenge);
11
+ opts.user.id = unpack(opts.user.id);
12
+ opts.excludeCredentials.forEach(function(cred) { cred.id = unpack(cred.id); });
10
13
  //console.log(opts);
11
14
  navigator.credentials.create({publicKey: opts}).
12
15
  then(function(cred){
13
16
  //console.log(cred);
14
17
  //window.cred = cred
15
-
16
- var rawId = btoa(String.fromCharCode.apply(null, new Uint8Array(cred.rawId))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
18
+
19
+ var rawId = pack(cred.rawId);
17
20
  document.getElementById('webauthn-setup').value = JSON.stringify({
18
21
  type: cred.type,
19
22
  id: rawId,
20
23
  rawId: rawId,
21
24
  response: {
22
- attestationObject: btoa(String.fromCharCode.apply(null, new Uint8Array(cred.response.attestationObject))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
23
- clientDataJSON: btoa(String.fromCharCode.apply(null, new Uint8Array(cred.response.clientDataJSON))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
25
+ attestationObject: pack(cred.response.attestationObject),
26
+ clientDataJSON: pack(cred.response.clientDataJSON)
24
27
  }
25
28
  });
26
29
  element.removeEventListener("submit", f);
@@ -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
@@ -120,8 +123,10 @@ module Rodauth
120
123
  define_method(handle_meth) do
121
124
  request.is send(route_meth) do
122
125
  check_csrf if check_csrf?
123
- before_rodauth
124
- send(internal_handle_meth, request)
126
+ _around_rodauth do
127
+ before_rodauth
128
+ send(internal_handle_meth, request)
129
+ end
125
130
  end
126
131
  end
127
132
 
@@ -238,6 +243,7 @@ module Rodauth
238
243
  instance_variable_set(iv, send(umeth))
239
244
  end
240
245
  end
246
+ alias_method(meth, meth)
241
247
  auth_private_methods(meth)
242
248
  end
243
249
 
@@ -288,15 +294,17 @@ module Rodauth
288
294
  end
289
295
 
290
296
  def enable(*features)
291
- new_features = features - @auth.features
292
- new_features.each{|f| load_feature(f)}
293
- @auth.features.concat(new_features)
297
+ features.each do |feature|
298
+ next if @auth.features.include?(feature)
299
+ load_feature(feature)
300
+ @auth.features << feature
301
+ end
294
302
  end
295
303
 
296
304
  private
297
305
 
298
306
  def load_feature(feature_name)
299
- require "rodauth/features/#{feature_name}"
307
+ require "rodauth/features/#{feature_name}" unless FEATURES[feature_name]
300
308
  feature = FEATURES[feature_name]
301
309
  enable(*feature.dependencies)
302
310
  extend feature.configuration
@@ -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
@@ -101,7 +102,6 @@ module Rodauth
101
102
  :set_redirect_error_flash,
102
103
  :set_title,
103
104
  :translate,
104
- :unverified_account_message,
105
105
  :update_session
106
106
  )
107
107
 
@@ -110,7 +110,8 @@ module Rodauth
110
110
  :account_from_session,
111
111
  :field_attributes,
112
112
  :field_error_attributes,
113
- :formatted_field_error
113
+ :formatted_field_error,
114
+ :around_rodauth
114
115
  )
115
116
 
116
117
  configuration_module_eval do
@@ -259,6 +260,7 @@ module Rodauth
259
260
  @password_field_autocomplete_value || 'current-password'
260
261
  end
261
262
 
263
+ alias account_password_hash_column account_password_hash_column
262
264
  # If the account_password_hash_column is set, the password hash is verified in
263
265
  # ruby, it will not use a database function to do so, it will check the password
264
266
  # hash using bcrypt.
@@ -393,9 +395,9 @@ module Rodauth
393
395
  def password_match?(password)
394
396
  if hash = get_password_hash
395
397
  if account_password_hash_column || !use_database_authentication_functions?
396
- BCrypt::Password.new(hash) == password
398
+ password_hash_match?(hash, password)
397
399
  else
398
- db.get(Sequel.function(function_name(:rodauth_valid_password_hash), account_id, BCrypt::Engine.hash_secret(password, hash)))
400
+ database_function_password_match?(:rodauth_valid_password_hash, account_id, password, hash)
399
401
  end
400
402
  end
401
403
  end
@@ -458,6 +460,18 @@ module Rodauth
458
460
 
459
461
  private
460
462
 
463
+ def _around_rodauth
464
+ yield
465
+ end
466
+
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)))
469
+ end
470
+
471
+ def password_hash_match?(hash, password)
472
+ BCrypt::Password.new(hash) == password
473
+ end
474
+
461
475
  def convert_token_key(key)
462
476
  if key && hmac_secret
463
477
  compute_hmac(key)
@@ -493,6 +507,7 @@ module Rodauth
493
507
  end
494
508
 
495
509
  def convert_session_key(key)
510
+ key = "#{session_key_prefix}#{key}".to_sym if session_key_prefix
496
511
  scope.opts[:sessions_convert_symbols] ? key.to_s : key
497
512
  end
498
513
 
@@ -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
 
@@ -26,11 +26,11 @@ module Rodauth
26
26
  require_account_session
27
27
  before_confirm_password_route
28
28
 
29
- request.get do
29
+ r.get do
30
30
  confirm_password_view
31
31
  end
32
32
 
33
- request.post do
33
+ r.post do
34
34
  if password_match?(param(password_param))
35
35
  transaction do
36
36
  before_confirm_password
@@ -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
 
@@ -47,7 +47,7 @@ module Rodauth
47
47
  s = {}
48
48
  if jwt_token
49
49
  unless session_data = jwt_payload
50
- json_response[json_response_error_key] = invalid_jwt_format_error_message
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
53
  response.write(_json_response_body(json_response))
@@ -229,10 +229,18 @@ module Rodauth
229
229
  end
230
230
  end
231
231
 
232
+ def _jwt_decode_opts
233
+ jwt_decode_opts
234
+ end
235
+
232
236
  def jwt_payload
233
237
  return @jwt_payload if defined?(@jwt_payload)
234
- @jwt_payload = JWT.decode(jwt_token, jwt_secret, true, jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
235
- rescue JWT::DecodeError
238
+ @jwt_payload = JWT.decode(jwt_token, jwt_secret, true, _jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
239
+ rescue JWT::DecodeError => e
240
+ rescue_jwt_payload(e)
241
+ end
242
+
243
+ def rescue_jwt_payload(_)
236
244
  @jwt_payload = false
237
245
  end
238
246
 
@@ -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,33 @@ 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
27
+ translatable_method :expired_jwt_access_token_message, "expired JWT access token"
28
+ auth_value_method :expired_jwt_access_token_status, 400
22
29
 
23
30
  auth_private_methods(
24
31
  :account_from_refresh_token
25
32
  )
26
33
 
27
34
  route do |r|
35
+ @jwt_refresh_route = true
36
+ before_jwt_refresh_route
37
+
28
38
  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
39
+ if !session_value
40
+ response.status ||= jwt_refresh_without_access_token_status
41
+ json_response[json_response_error_key] = jwt_refresh_without_access_token_message
42
+ elsif (refresh_token = param_or_nil(jwt_refresh_token_key_param)) && account_from_refresh_token(refresh_token)
31
43
  transaction do
32
44
  before_refresh_token
33
45
  formatted_token = generate_refresh_token
34
46
  remove_jwt_refresh_token_key(refresh_token)
47
+ set_jwt_refresh_token_hmac_session_key(formatted_token)
48
+ json_response[jwt_refresh_token_key] = formatted_token
49
+ json_response[jwt_access_token_key] = session_jwt
35
50
  after_refresh_token
36
51
  end
37
- json_response[jwt_refresh_token_key] = formatted_token
38
- json_response[jwt_access_token_key] = session_jwt
39
52
  else
40
53
  json_response[json_response_error_key] = jwt_refresh_invalid_token_message
41
54
  response.status ||= json_response_error_status
@@ -52,7 +65,9 @@ module Rodauth
52
65
  # JWT login puts the access token in the header.
53
66
  # We put the refresh token in the body.
54
67
  # 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
68
+ token = json_response['refresh_token'] = generate_refresh_token
69
+
70
+ set_jwt_refresh_token_hmac_session_key(token)
56
71
  end
57
72
 
58
73
  def set_jwt_token(token)
@@ -79,12 +94,33 @@ module Rodauth
79
94
 
80
95
  private
81
96
 
97
+ def rescue_jwt_payload(e)
98
+ if e.instance_of?(JWT::ExpiredSignature)
99
+ begin
100
+ # Some versions of jwt will raise JWT::ExpiredSignature even when the
101
+ # JWT is invalid for other reasons. Make sure the expiration is the
102
+ # only reason the JWT isn't valid before treating this as an expired token.
103
+ JWT.decode(jwt_token, jwt_secret, true, Hash[jwt_decode_opts].merge!(:verify_expiration=>false, :algorithm=>jwt_algorithm))[0]
104
+ rescue => e
105
+ else
106
+ json_response[json_response_error_key] = expired_jwt_access_token_message
107
+ response.status ||= expired_jwt_access_token_status
108
+ end
109
+ end
110
+
111
+ super
112
+ end
113
+
82
114
  def _account_from_refresh_token(token)
83
115
  id, token_id, key = _account_refresh_token_split(token)
84
116
 
85
- return unless key
86
- return unless actual = get_active_refresh_token(id, token_id)
87
- return unless timing_safe_eql?(key, convert_token_key(actual))
117
+ unless key &&
118
+ (id == session_value.to_s) &&
119
+ (actual = get_active_refresh_token(id, token_id)) &&
120
+ timing_safe_eql?(key, convert_token_key(actual)) &&
121
+ jwt_refresh_token_match?(key)
122
+ return
123
+ end
88
124
 
89
125
  ds = account_ds(id)
90
126
  ds = ds.where(account_status_column=>account_open_status_value) unless skip_status_checks?
@@ -101,6 +137,23 @@ module Rodauth
101
137
  [id, token_id, key]
102
138
  end
103
139
 
140
+ def _jwt_decode_opts
141
+ if allow_refresh_with_expired_jwt_access_token? && @jwt_refresh_route
142
+ Hash[super].merge!(:verify_expiration=>false)
143
+ else
144
+ super
145
+ end
146
+ end
147
+
148
+ def jwt_refresh_token_match?(key)
149
+ # We don't need to match tokens if we are requiring a valid current access token
150
+ return true unless allow_refresh_with_expired_jwt_access_token?
151
+
152
+ # If allowing with expired jwt access token, check the expired session contains
153
+ # hmac matching submitted and active refresh token.
154
+ timing_safe_eql?(compute_hmac(session[jwt_refresh_token_data_session_key].to_s + key), session[jwt_refresh_token_hmac_session_key].to_s)
155
+ end
156
+
104
157
  def get_active_refresh_token(account_id, token_id)
105
158
  jwt_refresh_token_account_ds(account_id).
106
159
  where(Sequel::CURRENT_TIMESTAMP > jwt_refresh_token_deadline_column).
@@ -140,6 +193,15 @@ module Rodauth
140
193
  hash
141
194
  end
142
195
 
196
+ def set_jwt_refresh_token_hmac_session_key(token)
197
+ if allow_refresh_with_expired_jwt_access_token?
198
+ key = _account_refresh_token_split(token).last
199
+ data = random_key
200
+ set_session_value(jwt_refresh_token_data_session_key, data)
201
+ set_session_value(jwt_refresh_token_hmac_session_key, compute_hmac(data + key))
202
+ end
203
+ end
204
+
143
205
  def before_logout
144
206
  if token = param_or_nil(jwt_refresh_token_key_param)
145
207
  if token == 'all'
@@ -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