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
@@ -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.
@@ -0,0 +1,37 @@
1
+ = New Features
2
+
3
+ * An around_rodauth configuration method has been added, which is
4
+ called around all Rodauth actions. This configuration method
5
+ is passed a block, and is useful for cases where you want to wrap
6
+ Rodauth's handling of the request.
7
+
8
+ For example, if you had a method named time_block in your Roda scope
9
+ that timed block execution and added a response header, you could
10
+ time Rodauth actions using something like:
11
+
12
+ around_rodauth do |&block|
13
+ scope.time_block('Rodauth') do
14
+ super(&block)
15
+ end
16
+ end
17
+
18
+ * The allow_refresh_with_expired_jwt_access_token? configuration has
19
+ been added to the jwt_refresh feature, allowing refreshing with an
20
+ expired but otherwise valid access token. When using this method,
21
+ it is required to have an hmac_secret specified, so that Rodauth
22
+ can make sure the access token matches the refresh token.
23
+
24
+ = Other Improvements
25
+
26
+ * The javascript for setting up a WebAuthn token has been fixed to
27
+ allow it to work correctly if there is already an existing
28
+ WebAuthn token for the account.
29
+
30
+ * The rodauth.setup_account_verification method has been promoted to
31
+ public API. You can use this method for automatically sending
32
+ account verification emails when automatically creating accounts.
33
+
34
+ * Rodauth no longer loads the same feature multiple times into a
35
+ single configuration. This didn't cause any problems before, but
36
+ could result in duplicate entries when looking at the loaded
37
+ features.
@@ -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.
@@ -1,34 +1,34 @@
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-auth-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.allowCredentials.forEach(function(cred) {
10
- cred.id = Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
11
- });
10
+ opts.challenge = unpack(opts.challenge);
11
+ opts.allowCredentials.forEach(function(cred) { cred.id = unpack(cred.id); });
12
12
  //console.log(opts);
13
13
  navigator.credentials.get({publicKey: opts}).
14
14
  then(function(cred){
15
15
  //console.log(cred);
16
16
  //window.cred = cred
17
17
 
18
- var rawId = btoa(String.fromCharCode.apply(null, new Uint8Array(cred.rawId))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
18
+ var rawId = pack(cred.rawId);
19
19
  var authValue = {
20
20
  type: cred.type,
21
21
  id: rawId,
22
22
  rawId: rawId,
23
23
  response: {
24
- authenticatorData: btoa(String.fromCharCode.apply(null, new Uint8Array(cred.response.authenticatorData))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
25
- clientDataJSON: btoa(String.fromCharCode.apply(null, new Uint8Array(cred.response.clientDataJSON))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
26
- signature: btoa(String.fromCharCode.apply(null, new Uint8Array(cred.response.signature))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
24
+ authenticatorData: pack(cred.response.authenticatorData),
25
+ clientDataJSON: pack(cred.response.clientDataJSON),
26
+ signature: pack(cred.response.signature)
27
27
  }
28
28
  };
29
29
 
30
30
  if (cred.response.userHandle) {
31
- authValue.response.userHandle = btoa(String.fromCharCode.apply(null, new Uint8Array(cred.response.userHandle))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
31
+ authValue.response.userHandle = pack(cred.response.userHandle);
32
32
  }
33
33
 
34
34
  document.getElementById('webauthn-auth').value = JSON.stringify(authValue);
@@ -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);
@@ -120,8 +120,10 @@ module Rodauth
120
120
  define_method(handle_meth) do
121
121
  request.is send(route_meth) do
122
122
  check_csrf if check_csrf?
123
- before_rodauth
124
- send(internal_handle_meth, request)
123
+ _around_rodauth do
124
+ before_rodauth
125
+ send(internal_handle_meth, request)
126
+ end
125
127
  end
126
128
  end
127
129
 
@@ -137,7 +139,9 @@ module Rodauth
137
139
  feature.module_eval(&block)
138
140
  configuration.def_configuration_methods(feature)
139
141
 
142
+ # :nocov:
140
143
  if constant
144
+ # :nocov:
141
145
  Rodauth.const_set(constant, feature)
142
146
  Rodauth::FeatureConfiguration.const_set(constant, configuration)
143
147
  end
@@ -286,9 +290,11 @@ module Rodauth
286
290
  end
287
291
 
288
292
  def enable(*features)
289
- new_features = features - @auth.features
290
- new_features.each{|f| load_feature(f)}
291
- @auth.features.concat(new_features)
293
+ features.each do |feature|
294
+ next if @auth.features.include?(feature)
295
+ load_feature(feature)
296
+ @auth.features << feature
297
+ end
292
298
  end
293
299
 
294
300
  private
@@ -336,10 +342,8 @@ module Rodauth
336
342
  end
337
343
 
338
344
  def freeze
339
- if opts[:rodauths]
340
- opts[:rodauths].each_value(&:freeze)
341
- opts[:rodauths].freeze
342
- end
345
+ opts[:rodauths].each_value(&:freeze)
346
+ opts[:rodauths].freeze
343
347
  super
344
348
  end
345
349
  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
@@ -110,7 +111,8 @@ module Rodauth
110
111
  :account_from_session,
111
112
  :field_attributes,
112
113
  :field_error_attributes,
113
- :formatted_field_error
114
+ :formatted_field_error,
115
+ :around_rodauth
114
116
  )
115
117
 
116
118
  configuration_module_eval do
@@ -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
 
@@ -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
@@ -138,6 +138,11 @@ 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
 
143
148
  def check_csrf?
@@ -224,9 +229,13 @@ module Rodauth
224
229
  end
225
230
  end
226
231
 
232
+ def _jwt_decode_opts
233
+ jwt_decode_opts
234
+ end
235
+
227
236
  def jwt_payload
228
237
  return @jwt_payload if defined?(@jwt_payload)
229
- @jwt_payload = JWT.decode(jwt_token, jwt_secret, true, jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
238
+ @jwt_payload = JWT.decode(jwt_token, jwt_secret, true, _jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
230
239
  rescue JWT::DecodeError
231
240
  @jwt_payload = false
232
241
  end
@@ -256,12 +265,6 @@ module Rodauth
256
265
  @json_response ||= {}
257
266
  end
258
267
 
259
- def _view(meth, page)
260
- return super unless use_jwt?
261
- return super if meth == :render
262
- return_json_response
263
- end
264
-
265
268
  def _json_response_body(hash)
266
269
  request.send(:convert_to_json, hash)
267
270
  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'