rodauth 2.1.0 → 2.6.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 +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'