rodauth 2.14.0 → 2.18.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,48 @@
1
+ = New Features
2
+
3
+ * An internal_request feature has been added. This feature allows
4
+ for interacting with Rodauth by calling methods, instead of having
5
+ to use a website or JSON API. This feature is designed primarily
6
+ for administrative use, so that administrators can create accounts,
7
+ change passwords or logins for accounts, and handle similar actions
8
+ without the user of the account being involved.
9
+
10
+ For example, assuming you've loaded the change_password and
11
+ internal_request features, and that your Roda class that
12
+ is loading Rodauth is named App, you can change the password
13
+ for the account with id 1 using:
14
+
15
+ App.rodauth.change_password(account_id: 1, password: 'foobar')
16
+
17
+ The internal request methods are implemented as class methods
18
+ on the Rodauth::Auth subclass (the object returned by App.rodauth).
19
+ These methods call methods on a subclass of that class specific
20
+ to internal requests.
21
+
22
+ The reason the feature is named internal_request is that these
23
+ methods are implemented by submitting a request internally, that is
24
+ processed almost exactly the same way as Rodauth would process a
25
+ web request.
26
+
27
+ See the internal_request feature documentation for details on which
28
+ internal request methods are available and the options they take.
29
+
30
+ * A path_class_methods feature has been added, that allows for calling
31
+ *_path and *_url as class methods. If you would like to call the
32
+ *_url methods as class methods, make sure to use the base_url
33
+ configuration method to set the base URL so that it does not require
34
+ request-specific information.
35
+
36
+ * Rodauth::Auth classes now have a configuration_name method that
37
+ returns the configuration name associated with the class. They also
38
+ have a configuration method that returns the configuration
39
+ associated with the class.
40
+
41
+ * Rodauth::Feature now supports an internal_request_method method for
42
+ specifying which methods are supported as internal request methods.
43
+
44
+ = Other Improvements
45
+
46
+ * The default base_url configuration method will now use the domain
47
+ method to get the domain to use, instead of getting the domain
48
+ information directly from the request environment.
@@ -0,0 +1,20 @@
1
+ = New Features
2
+
3
+ * Rodauth.lib has been added for using Rodauth purely as a library,
4
+ useful in non-web applications:
5
+
6
+ require 'rodauth'
7
+ rodauth = Rodauth.lib do
8
+ enable :create_account, :change_password
9
+ end
10
+ rodauth.create_account(login: 'foo@example.com', password: '...')
11
+ rodauth.change_password(account_id: 24601, password: '...')
12
+
13
+ This is built on top of the internal_request feature, and works by
14
+ creating a Roda application with the rodauth plugin, and returning
15
+ the related Rodauth::Auth class.
16
+
17
+ = Other Improvements
18
+
19
+ * The internal_request feature now works correctly for configurations
20
+ where only_json? is set to true.
@@ -0,0 +1,10 @@
1
+ = Improvements
2
+
3
+ * The jwt_refresh feature now works for unverified accounts when using
4
+ the verify_account_grace_period feature.
5
+
6
+ * When trying to create an account that already exists but is
7
+ unverified, Rodauth now returns a 4xx response.
8
+
9
+ * When trying to login to an unverified account, Rodauth now returns a
10
+ 4xx response.
@@ -0,0 +1,27 @@
1
+ = New Features
2
+
3
+ * When using the json and multifactor auth features, the JSON API can
4
+ now access the multifactor-manage route to get lists of endpoints
5
+ for setting up and disabling supported multifactor authentication
6
+ methods. The JSON API can now also access the multifactor-auth
7
+ route to get a list of endpoints for multifactor authentication for
8
+ the currently logged in account.
9
+
10
+ = Other Improvements
11
+
12
+ * In the otp feature, the viewbox: true rqrcode option is now used
13
+ when creating the QR code. This results in a QR code that is
14
+ displayed better and is easier to style. This option only has
15
+ an effect when using rqrcode 2+.
16
+
17
+ * When using the :auth_class option when loading the rodauth plugin,
18
+ the configuration name is set in the provided auth class, unless the
19
+ auth class already has a configuration name set.
20
+
21
+ * The example migration now recommends using a partial index on the
22
+ email column in cases where the database supports partial indexes.
23
+ Previously, it only recommended it on PostgreSQL.
24
+
25
+ * The argon2 feature now works with argon2 2.1.0. Older versions of
26
+ Rodauth work with both earlier and later versions of argon2, but
27
+ not 2.1.0.
@@ -16,6 +16,18 @@ module Rodauth
16
16
 
17
17
  private
18
18
 
19
+ if Argon2::VERSION != '2.1.0'
20
+ def argon2_salt_option
21
+ :salt_do_not_supply
22
+ end
23
+ # :nocov:
24
+ else
25
+ def argon2_salt_option
26
+ :salt_for_testing_purposes_only
27
+ end
28
+ # :nocov:
29
+ end
30
+
19
31
  def password_hash_cost
20
32
  return super unless use_argon2?
21
33
  argon2_hash_cost
@@ -35,7 +47,7 @@ module Rodauth
35
47
  return super unless argon2_hash_algorithm?(salt)
36
48
 
37
49
  argon2_params = Hash[extract_password_hash_cost(salt)]
38
- argon2_params[:salt_do_not_supply] = Base64.decode64(salt.split('$').last)
50
+ argon2_params[argon2_salt_option] = Base64.decode64(salt.split('$').last)
39
51
  ::Argon2::Password.new(argon2_params).create(password)
40
52
  end
41
53
 
@@ -115,6 +115,10 @@ module Rodauth
115
115
  :around_rodauth
116
116
  )
117
117
 
118
+ internal_request_method :account_exists?
119
+ internal_request_method :account_id_for_login
120
+ internal_request_method :internal_request_eval
121
+
118
122
  configuration_module_eval do
119
123
  def auth_class_eval(&block)
120
124
  auth.class_eval(&block)
@@ -445,7 +449,9 @@ module Rodauth
445
449
  end
446
450
 
447
451
  def base_url
448
- request.base_url
452
+ url = String.new("#{request.scheme}://#{domain}")
453
+ url << ":#{request.port}" if request.port != Rack::Request::DEFAULT_PORTS[request.scheme]
454
+ url
449
455
  end
450
456
 
451
457
  def domain
@@ -734,6 +740,10 @@ module Rodauth
734
740
  end
735
741
  end
736
742
 
743
+ def internal_request?
744
+ false
745
+ end
746
+
737
747
  def set_session_value(key, value)
738
748
  session[key] = value
739
749
  end
@@ -19,6 +19,8 @@ module Rodauth
19
19
 
20
20
  auth_methods :change_login
21
21
 
22
+ internal_request_method
23
+
22
24
  route do |r|
23
25
  require_account
24
26
  before_change_login_route
@@ -22,6 +22,8 @@ module Rodauth
22
22
  :invalid_previous_password_message
23
23
  )
24
24
 
25
+ internal_request_method
26
+
25
27
  route do |r|
26
28
  require_account
27
29
  before_change_password_route
@@ -24,6 +24,8 @@ module Rodauth
24
24
  :delete_account
25
25
  )
26
26
 
27
+ internal_request_method
28
+
27
29
  route do |r|
28
30
  require_account
29
31
  before_close_account_route
@@ -27,6 +27,8 @@ module Rodauth
27
27
  :new_account
28
28
  )
29
29
 
30
+ internal_request_method
31
+
30
32
  route do |r|
31
33
  check_already_logged_in
32
34
  before_create_account_route
@@ -49,6 +49,10 @@ module Rodauth
49
49
 
50
50
  auth_private_methods :account_from_email_auth_key
51
51
 
52
+ internal_request_method
53
+ internal_request_method :email_auth_request
54
+ internal_request_method :valid_email_auth?
55
+
52
56
  route(:email_auth_request) do |r|
53
57
  check_already_logged_in
54
58
  before_email_auth_request_route
@@ -0,0 +1,371 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module Rodauth
6
+ INVALID_DOMAIN = "invalidurl @@.com"
7
+
8
+ class InternalRequestError < StandardError
9
+ attr_accessor :flash
10
+ attr_accessor :reason
11
+ attr_accessor :field_errors
12
+
13
+ def initialize(attrs)
14
+ return super if attrs.is_a?(String)
15
+
16
+ @flash = attrs[:flash]
17
+ @reason = attrs[:reason]
18
+ @field_errors = attrs[:field_errors] || {}
19
+
20
+ super(build_message)
21
+ end
22
+
23
+ private
24
+
25
+ def build_message
26
+ extras = []
27
+ extras << reason if reason
28
+ extras << field_errors unless field_errors.empty?
29
+ extras = (" (#{extras.join(", ")})" unless extras.empty?)
30
+
31
+ "#{flash}#{extras}"
32
+ end
33
+ end
34
+
35
+ module InternalRequestMethods
36
+ attr_accessor :session
37
+ attr_accessor :params
38
+ attr_reader :flash
39
+ attr_accessor :internal_request_block
40
+
41
+ def domain
42
+ d = super
43
+ if d == INVALID_DOMAIN
44
+ raise InternalRequestError, "must set domain in configuration, as it cannot be determined from internal request"
45
+ end
46
+ d
47
+ end
48
+
49
+ def raw_param(k)
50
+ @params[k]
51
+ end
52
+
53
+ def set_error_flash(message)
54
+ @flash = message
55
+ _handle_internal_request_error
56
+ end
57
+ alias set_redirect_error_flash set_error_flash
58
+
59
+ def set_notice_flash(message)
60
+ @flash = message
61
+ end
62
+ alias set_notice_now_flash set_notice_flash
63
+
64
+ def modifications_require_password?
65
+ false
66
+ end
67
+ alias require_login_confirmation? modifications_require_password?
68
+ alias require_password_confirmation? modifications_require_password?
69
+ alias change_login_requires_password? modifications_require_password?
70
+ alias change_password_requires_password? modifications_require_password?
71
+ alias close_account_requires_password? modifications_require_password?
72
+ alias two_factor_modifications_require_password? modifications_require_password?
73
+
74
+ def otp_setup_view
75
+ hash = {:otp_setup=>otp_user_key}
76
+ hash[:otp_setup_raw] = otp_key if hmac_secret
77
+ _return_from_internal_request(hash)
78
+ end
79
+
80
+ def add_recovery_codes_view
81
+ _return_from_internal_request(recovery_codes)
82
+ end
83
+
84
+ def handle_internal_request(meth)
85
+ catch(:halt) do
86
+ _around_rodauth do
87
+ before_rodauth
88
+ send(meth, request)
89
+ end
90
+ end
91
+
92
+ @internal_request_return_value
93
+ end
94
+
95
+ def only_json?
96
+ false
97
+ end
98
+
99
+ private
100
+
101
+ def internal_request?
102
+ true
103
+ end
104
+
105
+ def set_error_reason(reason)
106
+ @error_reason = reason
107
+ end
108
+
109
+ def after_login
110
+ super
111
+ _set_internal_request_return_value(account_id) unless @return_false_on_error
112
+ end
113
+
114
+ def after_remember
115
+ super
116
+ if params[remember_param] == remember_remember_param_value
117
+ _set_internal_request_return_value("#{account_id}_#{convert_token_key(remember_key_value)}")
118
+ end
119
+ end
120
+
121
+ def after_load_memory
122
+ super
123
+ _return_from_internal_request(session_value)
124
+ end
125
+
126
+ def before_change_password_route
127
+ super
128
+ params[new_password_param] ||= params[password_param]
129
+ end
130
+
131
+ def before_email_auth_request_route
132
+ super
133
+ _set_login_param_from_account
134
+ end
135
+
136
+ def before_login_route
137
+ super
138
+ _set_login_param_from_account
139
+ end
140
+
141
+ def before_unlock_account_request_route
142
+ super
143
+ _set_login_param_from_account
144
+ end
145
+
146
+ def before_reset_password_request_route
147
+ super
148
+ _set_login_param_from_account
149
+ end
150
+
151
+ def before_verify_account_resend_route
152
+ super
153
+ _set_login_param_from_account
154
+ end
155
+
156
+ def account_from_key(token, status_id=nil)
157
+ return super unless session_value
158
+ return unless yield session_value
159
+ ds = account_ds(session_value)
160
+ ds = ds.where(account_status_column=>status_id) if status_id && !skip_status_checks?
161
+ ds.first
162
+ end
163
+
164
+ def _set_internal_request_return_value(value)
165
+ @internal_request_return_value = value
166
+ end
167
+
168
+ def _return_from_internal_request(value)
169
+ _set_internal_request_return_value(value)
170
+ throw(:halt)
171
+ end
172
+
173
+ def _handle_internal_request_error
174
+ if @return_false_on_error
175
+ _return_from_internal_request(false)
176
+ else
177
+ raise InternalRequestError.new(flash: @flash, reason: @error_reason, field_errors: @field_errors)
178
+ end
179
+ end
180
+
181
+ def _return_false_on_error!
182
+ @return_false_on_error = true
183
+ end
184
+
185
+ def _set_login_param_from_account
186
+ if session_value && !params[login_param] && (account = account_ds(session_value).first)
187
+ params[login_param] = account[login_column]
188
+ end
189
+ end
190
+
191
+ def _get_remember_cookie
192
+ params[remember_param]
193
+ end
194
+
195
+ def _handle_internal_request_eval(_)
196
+ v = instance_eval(&internal_request_block)
197
+ _set_internal_request_return_value(v) unless defined?(@internal_request_return_value)
198
+ end
199
+
200
+ def _handle_account_id_for_login(_)
201
+ raise InternalRequestError, "no login provided" unless login = param_or_nil(login_param)
202
+ raise InternalRequestError, "no account for login" unless account = account_from_login(login)
203
+ _return_from_internal_request(account[account_id_column])
204
+ end
205
+
206
+ def _handle_account_exists?(_)
207
+ raise InternalRequestError, "no login provided" unless login = param_or_nil(login_param)
208
+ _return_from_internal_request(!!account_from_login(login))
209
+ end
210
+
211
+ def _handle_lock_account(_)
212
+ raised_uniqueness_violation{account_lockouts_ds(session_value).insert(_setup_account_lockouts_hash(session_value, generate_unlock_account_key))}
213
+ end
214
+
215
+ def _handle_remember_setup(request)
216
+ params[remember_param] = remember_remember_param_value
217
+ _handle_remember(request)
218
+ end
219
+
220
+ def _handle_remember_disable(request)
221
+ params[remember_param] = remember_disable_param_value
222
+ _handle_remember(request)
223
+ end
224
+
225
+ def _handle_account_id_for_remember_key(request)
226
+ load_memory
227
+ raise InternalRequestError, "invalid remember key"
228
+ end
229
+
230
+ def _handle_otp_setup_params(request)
231
+ request.env['REQUEST_METHOD'] = 'GET'
232
+ _handle_otp_setup(request)
233
+ end
234
+
235
+ def _predicate_internal_request(meth, request)
236
+ _return_false_on_error!
237
+ _set_internal_request_return_value(true)
238
+ send(meth, request)
239
+ end
240
+
241
+ def _handle_valid_login_and_password?(request)
242
+ _predicate_internal_request(:_handle_login, request)
243
+ end
244
+
245
+ def _handle_valid_email_auth?(request)
246
+ _predicate_internal_request(:_handle_email_auth, request)
247
+ end
248
+
249
+ def _handle_valid_otp_auth?(request)
250
+ _predicate_internal_request(:_handle_otp_auth, request)
251
+ end
252
+
253
+ def _handle_valid_recovery_auth?(request)
254
+ _predicate_internal_request(:_handle_recovery_auth, request)
255
+ end
256
+
257
+ def _handle_valid_sms_auth?(request)
258
+ _predicate_internal_request(:_handle_sms_auth, request)
259
+ end
260
+ end
261
+
262
+ module InternalRequestClassMethods
263
+ def internal_request(route, opts={}, &block)
264
+ opts = opts.dup
265
+
266
+ env = {
267
+ 'REQUEST_METHOD'=>'POST',
268
+ 'PATH_INFO'=>'/',
269
+ "SCRIPT_NAME" => "",
270
+ "HTTP_HOST" => INVALID_DOMAIN,
271
+ "SERVER_NAME" => INVALID_DOMAIN,
272
+ "SERVER_PORT" => 443,
273
+ "CONTENT_TYPE" => "application/x-www-form-urlencoded",
274
+ "rack.input"=>StringIO.new(''),
275
+ "rack.url_scheme"=>"https"
276
+ }
277
+ env.merge!(opts.delete(:env)) if opts[:env]
278
+
279
+ session = {}
280
+ session.merge!(opts.delete(:session)) if opts[:session]
281
+
282
+ params = {}
283
+ params.merge!(opts.delete(:params)) if opts[:params]
284
+
285
+ scope = roda_class.new(env)
286
+ rodauth = new(scope)
287
+ rodauth.session = session
288
+ rodauth.params = params
289
+ rodauth.internal_request_block = block
290
+
291
+ unless account_id = opts.delete(:account_id)
292
+ if (account_login = opts.delete(:account_login))
293
+ if (account = rodauth.send(:_account_from_login, account_login))
294
+ account_id = account[rodauth.account_id_column]
295
+ else
296
+ raise InternalRequestError, "no account for login: #{account_login.inspect}"
297
+ end
298
+ end
299
+ end
300
+
301
+ if account_id
302
+ session[rodauth.session_key] = account_id
303
+ unless authenticated_by = opts.delete(:authenticated_by)
304
+ authenticated_by = case route
305
+ when :otp_auth, :sms_request, :sms_auth, :recovery_auth, :valid_otp_auth?, :valid_sms_auth?, :valid_recovery_auth?
306
+ ['internal1']
307
+ else
308
+ ['internal1', 'internal2']
309
+ end
310
+ end
311
+ session[rodauth.authenticated_by_session_key] = authenticated_by
312
+ end
313
+
314
+ opts.keys.each do |k|
315
+ meth = :"#{k}_param"
316
+ params[rodauth.public_send(meth).to_s] = opts.delete(k) if rodauth.respond_to?(meth)
317
+ end
318
+
319
+ unless opts.empty?
320
+ warn "unhandled options passed to #{route}: #{opts.inspect}"
321
+ end
322
+
323
+ rodauth.handle_internal_request(:"_handle_#{route}")
324
+ end
325
+ end
326
+
327
+ Feature.define(:internal_request, :InternalRequest) do
328
+ configuration_module_eval do
329
+ def internal_request_configuration(&block)
330
+ @auth.instance_exec do
331
+ (@internal_request_configuration_blocks ||= []) << block
332
+ end
333
+ end
334
+ end
335
+
336
+ def post_configure
337
+ super
338
+
339
+ return if is_a?(InternalRequestMethods)
340
+
341
+ klass = self.class
342
+ internal_class = Class.new(klass) do
343
+ @roda_class = klass.roda_class
344
+ @features = klass.features.clone
345
+ @routes = klass.routes.clone
346
+ @route_hash = klass.route_hash.clone
347
+ @configuration = klass.configuration.clone
348
+ @configuration.instance_variable_set(:@auth, self)
349
+ end
350
+
351
+ if blocks = klass.instance_variable_get(:@internal_request_configuration_blocks)
352
+ configuration = internal_class.configuration
353
+ blocks.each do |block|
354
+ configuration.instance_exec(&block)
355
+ end
356
+ end
357
+ internal_class.send(:extend, InternalRequestClassMethods)
358
+ internal_class.send(:include, InternalRequestMethods)
359
+ internal_class.allocate.post_configure
360
+
361
+ ([:base] + internal_class.features).each do |feature_name|
362
+ feature = FEATURES[feature_name]
363
+ if meths = feature.internal_request_methods
364
+ meths.each do |name|
365
+ klass.define_singleton_method(name){|opts={}, &block| internal_class.internal_request(name, opts, &block)}
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end
371
+ end
@@ -67,6 +67,25 @@ module Rodauth
67
67
 
68
68
  private
69
69
 
70
+ def before_two_factor_manage_route
71
+ super if defined?(super)
72
+ if use_json?
73
+ json_response[:setup_links] = two_factor_setup_links.sort.map{|_,link| link}
74
+ json_response[:remove_links] = two_factor_remove_links.sort.map{|_,link| link}
75
+ json_response[json_response_success_key] ||= "" if include_success_messages?
76
+ return_json_response
77
+ end
78
+ end
79
+
80
+ def before_two_factor_auth_route
81
+ super if defined?(super)
82
+ if use_json?
83
+ json_response[:auth_links] = two_factor_auth_links.sort.map{|_,link| link}
84
+ json_response[json_response_success_key] ||= "" if include_success_messages?
85
+ return_json_response
86
+ end
87
+ end
88
+
70
89
  def before_view_recovery_codes
71
90
  super if defined?(super)
72
91
  if use_json?
@@ -98,7 +98,7 @@ module Rodauth
98
98
  # JWT is invalid for other reasons. Make sure the expiration is the
99
99
  # only reason the JWT isn't valid before treating this as an expired token.
100
100
  JWT.decode(jwt_token, jwt_secret, true, Hash[jwt_decode_opts].merge!(:verify_expiration=>false, :algorithm=>jwt_algorithm))[0]
101
- rescue => e
101
+ rescue
102
102
  else
103
103
  json_response[json_response_error_key] = expired_jwt_access_token_message
104
104
  response.status ||= expired_jwt_access_token_status
@@ -120,7 +120,7 @@ module Rodauth
120
120
  end
121
121
 
122
122
  ds = account_ds(id)
123
- ds = ds.where(account_status_column=>account_open_status_value) unless skip_status_checks?
123
+ ds = ds.where(account_session_status_filter) unless skip_status_checks?
124
124
  ds.first
125
125
  end
126
126
 
@@ -62,6 +62,10 @@ module Rodauth
62
62
  )
63
63
  auth_private_methods :account_from_unlock_key
64
64
 
65
+ internal_request_method(:lock_account)
66
+ internal_request_method(:unlock_account_request)
67
+ internal_request_method(:unlock_account)
68
+
65
69
  route(:unlock_account_request) do |r|
66
70
  check_already_logged_in
67
71
  before_unlock_account_request_route
@@ -167,6 +171,12 @@ module Rodauth
167
171
  unlock_account
168
172
  end
169
173
 
174
+ def _setup_account_lockouts_hash(account_id, key)
175
+ hash = {account_lockouts_id_column=>account_id, account_lockouts_key_column=>key}
176
+ set_deadline_value(hash, account_lockouts_deadline_column, account_lockouts_deadline_interval)
177
+ hash
178
+ end
179
+
170
180
  def invalid_login_attempted
171
181
  ds = account_login_failures_ds.
172
182
  where(account_login_failures_id_column=>account_id)
@@ -192,8 +202,7 @@ module Rodauth
192
202
 
193
203
  if number >= max_invalid_logins
194
204
  @unlock_account_key_value = generate_unlock_account_key
195
- hash = {account_lockouts_id_column=>account_id, account_lockouts_key_column=>unlock_account_key_value}
196
- set_deadline_value(hash, account_lockouts_deadline_column, account_lockouts_deadline_interval)
205
+ hash = _setup_account_lockouts_hash(account_id, unlock_account_key_value)
197
206
 
198
207
  if e = raised_uniqueness_violation{account_lockouts_ds.insert(hash)}
199
208
  # If inserting into the lockout table raises a violation, we should just be able to pull the already inserted
@@ -25,6 +25,9 @@ module Rodauth
25
25
 
26
26
  auth_value_methods :login_return_to_requested_location_path
27
27
 
28
+ internal_request_method
29
+ internal_request_method :valid_login_and_password?
30
+
28
31
  route do |r|
29
32
  check_already_logged_in
30
33
  before_login_route