rodauth 2.2.0 → 2.7.0

Sign up to get free protection for your applications and to get access to all the features.
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