merb_threshold 0.1.4
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.
- data/LICENSE +20 -0
- data/README +225 -0
- data/Rakefile +62 -0
- data/TODO +4 -0
- data/lib/merb_threshold.rb +35 -0
- data/lib/merb_threshold/controller/merb_controller.rb +452 -0
- data/lib/merb_threshold/frequency.rb +165 -0
- data/lib/merb_threshold/helpers/recaptcha_helper.rb +70 -0
- data/lib/merb_threshold/helpers/wait_helper.rb +56 -0
- data/lib/merb_threshold/merbtasks.rb +40 -0
- data/lib/merb_threshold/per.rb +13 -0
- data/lib/merb_threshold/recaptcha_client.rb +47 -0
- data/lib/merb_threshold/templates/_recaptcha_partial.html.erb +16 -0
- data/lib/merb_threshold/templates/_wait_partial.html.erb +3 -0
- data/spec/controller/merb_controller_spec.rb +271 -0
- data/spec/frequency_spec.rb +136 -0
- data/spec/helpers/recaptcha_helper_spec.rb +29 -0
- data/spec/helpers/wait_helper_spec.rb +32 -0
- data/spec/per_spec.rb +9 -0
- data/spec/recaptcha_client_spec.rb +35 -0
- data/spec/spec_helper.rb +56 -0
- metadata +90 -0
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Cory ODaniel
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README
ADDED
@@ -0,0 +1,225 @@
|
|
1
|
+
merb_threshold
|
2
|
+
==============
|
3
|
+
|
4
|
+
Merb Threshold provides an easy way to apply control the number of times something is done in a merb
|
5
|
+
app, this includes dispatching to actions, rendering partials, etc.
|
6
|
+
|
7
|
+
Thresholds can be applied to multiple partials per page and can even span controllers and actions.
|
8
|
+
|
9
|
+
Also all of the methods are pretty well documented, so it might be pretty helpful to look at the method documentation.
|
10
|
+
|
11
|
+
When a threshold is exceeded it can be relaxed either by captcha or waiting, depending on the partial that is rendered.
|
12
|
+
|
13
|
+
==== Set up
|
14
|
+
|
15
|
+
# Add to init.rb
|
16
|
+
require 'merb_threshold'
|
17
|
+
|
18
|
+
Merb::Plugins.config[:merb_threshold] = {
|
19
|
+
:public_key => nil, # Recaptcha Public Key
|
20
|
+
:private_key => nil, # Recaptcha Private Key
|
21
|
+
:recaptcha => true, # Enable Recaptcha
|
22
|
+
|
23
|
+
# Only needed if using Merb::Threshold::Helpers
|
24
|
+
# both take :partial as an option to specify an alternate partial
|
25
|
+
# wait()
|
26
|
+
# wait(:partial => "shared/other_wait_partial")
|
27
|
+
#
|
28
|
+
:wait_partial => 'shared/wait_partial', #Path to default partial
|
29
|
+
:captcha_partial => 'shared/recaptcha_partial' #Path to default partial
|
30
|
+
}
|
31
|
+
|
32
|
+
|
33
|
+
# Two helpers are included: captcha() and wait()
|
34
|
+
# The helpers aren't needed (see API) below, and are just provided as two examples
|
35
|
+
# of how to use the API to render wait and captch partials
|
36
|
+
#
|
37
|
+
class Merb::Controller
|
38
|
+
include Merb::Threshold::Helpers
|
39
|
+
end
|
40
|
+
|
41
|
+
|
42
|
+
==== Public API
|
43
|
+
Thresholds are shared across controllers, which makes it possible to do things like
|
44
|
+
display a shared login partial in your Home controller while your Authentication controller
|
45
|
+
manages the access.
|
46
|
+
|
47
|
+
Instance Methods
|
48
|
+
* Merb::Controller#permit_access?
|
49
|
+
- is access permitted to the resources
|
50
|
+
- Takes a threshold name
|
51
|
+
* Merb::Controller#will_permit_another?
|
52
|
+
- will the threshold permit another request.
|
53
|
+
- Takes threshold name
|
54
|
+
* Merb::Controller#is_currently_exceeded?
|
55
|
+
- is the threshold currently exceeded (may have been exceeded by a previous request)
|
56
|
+
- Take threshold name
|
57
|
+
|
58
|
+
Class Methods
|
59
|
+
* Merb::Controller.register_threshold - registers a threshold, all thresholds must be 'registered'
|
60
|
+
* Merb::Controller.threshold_actions - This is a helper method. It registers the thresholds automatically
|
61
|
+
and creates before filters to check permit_access?. If no actions are listed thresholds, are maintained
|
62
|
+
on the controller itself (shared between actions). Optionally a list of action names can be given and an
|
63
|
+
threshold will be created for each one.
|
64
|
+
|
65
|
+
If you are not keen of the names generated by
|
66
|
+
|
67
|
+
|
68
|
+
==== Thresholding at the Controller / Action level
|
69
|
+
|
70
|
+
Action based thresholding allows for controlling access to an action as a whole. The class level 'threshold' method
|
71
|
+
is actually just a wrapper around a before filter that calls the instances level threshold. Providing the
|
72
|
+
wrapper does allow for the threshold to halt the filter chain.
|
73
|
+
|
74
|
+
Example:
|
75
|
+
class MyController < Application
|
76
|
+
threshold_actions :index, :create, :limit => [5, 30.seconds]
|
77
|
+
|
78
|
+
#equivalent to:
|
79
|
+
register_threshold :"my_controller/index", :limit => [5, 30.seconds]
|
80
|
+
before(nil,{:only => [:index]}) do
|
81
|
+
permit_access? :"my_controller/index"
|
82
|
+
end
|
83
|
+
|
84
|
+
register_threshold :"my_controller/create", :limit => [5, 30.seconds]
|
85
|
+
before(nil,{:only => [:create]}) do
|
86
|
+
permit_access? :"my_controller/create"
|
87
|
+
end
|
88
|
+
|
89
|
+
#create a controller level threshold
|
90
|
+
class MyController < Application
|
91
|
+
threshold_actions :limit => [5000, 1.day]
|
92
|
+
|
93
|
+
#equivalent to:
|
94
|
+
register_threshold :my_controller, :limit => [5000, 1.day]
|
95
|
+
before(nil,{}) do
|
96
|
+
permit_access? :my_controller
|
97
|
+
end
|
98
|
+
|
99
|
+
#create 1 action level threshold with :unless statement and halt
|
100
|
+
class MyController < Application
|
101
|
+
threshold_actions :search, :limit => [10, 5.minutes],
|
102
|
+
:unless => :is_admin?,
|
103
|
+
:halt_with => "Too many searches"
|
104
|
+
|
105
|
+
#equivalent to:
|
106
|
+
register_threshold :"my_controller/search", :limit => [10, 5.minutes]
|
107
|
+
before(nil,{:only => [:search], :unless => :is_admin?}) do
|
108
|
+
if !permit_access?(:"my_controller/search")
|
109
|
+
throw(:halt, "Too many searches")
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
==== Partial / View based thresholding
|
114
|
+
|
115
|
+
Partial / View based thresholding can take advantage of action based thresholds or named thresholds.
|
116
|
+
class Whatever < Application
|
117
|
+
register_threshold :stuff_to_protect, :limit => 1.per(3.minutes)
|
118
|
+
# --- apps/views/whatever/index.html.erb
|
119
|
+
<!--
|
120
|
+
Cool html stuff
|
121
|
+
-->
|
122
|
+
<% if permit_access? :stuff_to_protect %>
|
123
|
+
<!-- Show your stuff, access wasn't thresholded -->
|
124
|
+
<div> Coolness?! </div>
|
125
|
+
<% else %>
|
126
|
+
<%= wait(:stuff_to_protect) %>
|
127
|
+
<!-- your users gets a wait message -->
|
128
|
+
<% end %>
|
129
|
+
|
130
|
+
==== Cross Controller/Action thresholding
|
131
|
+
|
132
|
+
Using named thresholds it is possible to control a threshold across several controllers, actions, and partials.
|
133
|
+
|
134
|
+
Thresholds are shared across all controllers that extend from Merb::Controller, so a threshold can be accessed
|
135
|
+
by any controller, and more importantly partials can be rendered for thresholds in other controllers
|
136
|
+
|
137
|
+
Just like in 'Partial / View based thresholding' theses three methods can be used to control the flow of what
|
138
|
+
is rendered.
|
139
|
+
|
140
|
+
Methods:
|
141
|
+
* permit_access? - Returns True|False, was the request OVER the threshold
|
142
|
+
* will_permit_another? - Will the threshold permit another request
|
143
|
+
* is_currently_exceeded? - Is the current request over the threshold
|
144
|
+
|
145
|
+
Helpers (Override at your leisure in Merb::Threshold::Helpers)
|
146
|
+
* wait(threshold_name) - displays the wait message
|
147
|
+
* captcha(threshold_name) - displays the captcha
|
148
|
+
|
149
|
+
|
150
|
+
=== Threshold Names vs Threshold Keys
|
151
|
+
|
152
|
+
* A threshold name is used as the identifier so the controller can keep track of registered options
|
153
|
+
* A threshold key is how to look up a particular threshold's data in the user's session
|
154
|
+
|
155
|
+
Threshold keys should be used whenever accessing any of the data stored in the users' session.
|
156
|
+
|
157
|
+
=== Clearing / Destroying sessions
|
158
|
+
|
159
|
+
Since all merb_threshold data is stored in the session clearing or destroying the session will remove
|
160
|
+
any data stored for that session. This becomes important if you clear session on logout, because it
|
161
|
+
essentially resets the access history for a user. To get around this simply copy out the merb_threshold
|
162
|
+
data before clearing/destroy and then put it back in the new 'anonymous' session afterwards. This applies
|
163
|
+
to logins if they clear/destroy the anonymous session before presenting the authenticated one.
|
164
|
+
|
165
|
+
You could do something like this or whatev.
|
166
|
+
def logout
|
167
|
+
clear_keys = session.keys - [
|
168
|
+
'merb_threshold_history',
|
169
|
+
'merb_threshold_exceeded_thresholds',
|
170
|
+
'merb_threshold_waiting_period']
|
171
|
+
|
172
|
+
session.regenerate
|
173
|
+
clear_keys.each { |k| session.delete(k) }
|
174
|
+
end
|
175
|
+
|
176
|
+
|
177
|
+
=== will_permit_another? vs is_currently_exceeded?
|
178
|
+
|
179
|
+
* will_permit_another?
|
180
|
+
* Note: takes a threshold_name
|
181
|
+
* determines if an immediate subsequent request would exceed the threshold
|
182
|
+
* Suggested Use: when one request 'protects' another
|
183
|
+
* Example: A GET request that retrieved a form could protect the subsequent POST request
|
184
|
+
|
185
|
+
* is_currently_exceeded?
|
186
|
+
* Note: takes a threshold_name
|
187
|
+
* determines if the current request is in over the threshold
|
188
|
+
* Suggested Use: when throttling the amount of requests a resource gets
|
189
|
+
* Example: An API that allows X amount of free accesses before display a 'please pay me' page
|
190
|
+
|
191
|
+
==== A case for will_permit_another?
|
192
|
+
|
193
|
+
* A sign up page has a limit of 5 signups per minute.
|
194
|
+
* A user signs up 5 accounts in a row (w/ no captcha | wait)
|
195
|
+
* On the sixth GET of the form the request's call to captcha() determines another request will exceeded the threshold, so a captcha is presented
|
196
|
+
|
197
|
+
==== A case for will_permit_another?
|
198
|
+
|
199
|
+
* A user is promised access to a free API 5000 times per day
|
200
|
+
* The user's app makes 5000 requests
|
201
|
+
* On the 5001 request is_currently_exceeded? could be used to render an 'over the limit' partial
|
202
|
+
|
203
|
+
|
204
|
+
=== Misc. Notes
|
205
|
+
|
206
|
+
* Thresholds can be named whatever you want, so they can be programmatically created. Also
|
207
|
+
the option :params => [:blog_id,:etc] is available that will use param values as part of the key
|
208
|
+
|
209
|
+
* merb_threshold currently stores everything in the session (may have support for)
|
210
|
+
additional stores in the future. On that note, it is not recommended to be used
|
211
|
+
with cookie base sessions because it could be easy for a user to go over 4k worth
|
212
|
+
of data if the site is composed of many controllers, actions, and partials
|
213
|
+
|
214
|
+
* A threshold is EXCEEDED when it goes beyond its limit
|
215
|
+
Given:
|
216
|
+
register_threshold :index, :limit => [3,30.seconds]
|
217
|
+
The threshold would be considered EXCEEDED on its 4th request.
|
218
|
+
|
219
|
+
* Time.now is used for all times since access times are relative
|
220
|
+
The frequency class could be a lot more useful if it didn't explicitly use Time.now and you could
|
221
|
+
look forward and backward over time. Fortunately that complexity isn't needed for actions because
|
222
|
+
you are accessing them 'now' and the plugin is concerned with 'when' they were last accessed.
|
223
|
+
|
224
|
+
* If you dont like the way units are cast using :limit => [1,30.minutes]
|
225
|
+
You can override Frequency#cast_units OR specify :limt => [1,30,:minutes]
|
data/Rakefile
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake/gempackagetask'
|
3
|
+
|
4
|
+
require 'spec/rake/spectask'
|
5
|
+
require "rake/testtask"
|
6
|
+
|
7
|
+
require "spec"
|
8
|
+
require "spec/rake/spectask"
|
9
|
+
|
10
|
+
require 'merb-core'
|
11
|
+
require 'merb-core/tasks/merb'
|
12
|
+
|
13
|
+
GEM_NAME = "merb_threshold"
|
14
|
+
GEM_VERSION = "0.1.4"
|
15
|
+
AUTHOR = "Cory O'Daniel"
|
16
|
+
EMAIL = "contact@coryodaniel.com"
|
17
|
+
HOMEPAGE = "http://github.com/coryodaniel"
|
18
|
+
SUMMARY = "Merb plugin that provides resource access rate limits and captcha'ing"
|
19
|
+
|
20
|
+
Dir['tasks/**/*.rb'].each do |f|
|
21
|
+
puts f
|
22
|
+
require f
|
23
|
+
end
|
24
|
+
|
25
|
+
spec = Gem::Specification.new do |s|
|
26
|
+
s.rubyforge_project = 'merb'
|
27
|
+
s.name = GEM_NAME
|
28
|
+
s.version = GEM_VERSION
|
29
|
+
s.platform = Gem::Platform::RUBY
|
30
|
+
s.has_rdoc = true
|
31
|
+
s.extra_rdoc_files = ["README", "LICENSE", 'TODO']
|
32
|
+
s.summary = SUMMARY
|
33
|
+
s.description = s.summary
|
34
|
+
s.author = AUTHOR
|
35
|
+
s.email = EMAIL
|
36
|
+
s.homepage = HOMEPAGE
|
37
|
+
s.add_dependency('merb', '>= 1.0.7.1')
|
38
|
+
s.require_path = 'lib'
|
39
|
+
s.files = %w(LICENSE README Rakefile TODO) + Dir.glob("{lib,spec}/**/*")
|
40
|
+
|
41
|
+
end
|
42
|
+
|
43
|
+
Rake::GemPackageTask.new(spec) do |pkg|
|
44
|
+
pkg.gem_spec = spec
|
45
|
+
end
|
46
|
+
|
47
|
+
desc "install the plugin as a gem"
|
48
|
+
task :install do
|
49
|
+
Merb::RakeHelper.install(GEM_NAME, :version => GEM_VERSION)
|
50
|
+
end
|
51
|
+
|
52
|
+
desc "Uninstall the gem"
|
53
|
+
task :uninstall do
|
54
|
+
Merb::RakeHelper.uninstall(GEM_NAME, :version => GEM_VERSION)
|
55
|
+
end
|
56
|
+
|
57
|
+
desc "Create a gemspec file"
|
58
|
+
task :gemspec do
|
59
|
+
File.open("#{GEM_NAME}.gemspec", "w") do |file|
|
60
|
+
file.puts spec.to_ruby
|
61
|
+
end
|
62
|
+
end
|
data/TODO
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
if defined?(Merb::Plugins)
|
2
|
+
Merb::Plugins.config[:merb_threshold] = {
|
3
|
+
:public_key => nil,
|
4
|
+
:private_key => nil,
|
5
|
+
:recaptcha => true,
|
6
|
+
:wait_partial => 'shared/wait_partial',
|
7
|
+
:captcha_partial => 'shared/recaptcha_partial'
|
8
|
+
}
|
9
|
+
|
10
|
+
module Merb
|
11
|
+
module Threshold
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
require 'merb-helpers/time_dsl'
|
16
|
+
|
17
|
+
require "merb_threshold/frequency"
|
18
|
+
require "merb_threshold/per"
|
19
|
+
require "merb_threshold/controller/merb_controller"
|
20
|
+
require "merb_threshold/helpers/wait_helper"
|
21
|
+
|
22
|
+
Numeric.send :include, Merb::Threshold::Per
|
23
|
+
include Merb::Threshold
|
24
|
+
|
25
|
+
Merb::Plugins.add_rakefiles "merb_threshold/merbtasks"
|
26
|
+
|
27
|
+
Merb::BootLoader.before_app_loads do
|
28
|
+
if Merb::Plugins.config[:merb_threshold][:recaptcha]
|
29
|
+
require 'merb_threshold/recaptcha_client'
|
30
|
+
require 'merb_threshold/helpers/recaptcha_helper'
|
31
|
+
RecaptchaClient.public_key = Merb::Plugins.config[:merb_threshold][:public_key]
|
32
|
+
RecaptchaClient.private_key = Merb::Plugins.config[:merb_threshold][:private_key]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,452 @@
|
|
1
|
+
module Merb
|
2
|
+
class Controller
|
3
|
+
THRESHOLD_OPTIONS = [:limit, :params]
|
4
|
+
THRESHOLD_DEFAULTS = {
|
5
|
+
:limit => [0,0.seconds], #[Access, PerSecond]
|
6
|
+
:params => nil #[:list, :of, :params, :to, :use, :in, :key]
|
7
|
+
}
|
8
|
+
|
9
|
+
#Use to keep an index of thresholds for looking up information
|
10
|
+
# by name
|
11
|
+
@@_threshold_map = Mash.new
|
12
|
+
|
13
|
+
class << self
|
14
|
+
##
|
15
|
+
# Registers a threshold for tracking
|
16
|
+
#
|
17
|
+
# @param threshold_name [~Symbol] name of threshold
|
18
|
+
#
|
19
|
+
# @param opts [Hash] Options on how to enforce threshold
|
20
|
+
# * :params [Array[~Symbol]] Parameters to include in the threshold key
|
21
|
+
# :params => [:blog_id]
|
22
|
+
#
|
23
|
+
# * :limit [Array[Fixnum,Fixnum,Symbol]] number of access per time interval before
|
24
|
+
# threshold constraints are applied
|
25
|
+
# Default [0,0.seconds] #Always
|
26
|
+
# :limit => [2,5,:minutes] #=> Frequency(2,5,:minutes) 2 per 5 minutes
|
27
|
+
# :limit => [1, 5.minutes] #=> Frequency(1,5.minutes) 1 per 5 minutes
|
28
|
+
#
|
29
|
+
# @raises ArgumentError
|
30
|
+
#
|
31
|
+
# @api public
|
32
|
+
#
|
33
|
+
# @return [Array[~Symbol,Hash]]
|
34
|
+
# The name, opts it was registered as
|
35
|
+
def register_threshold(threshold_name,opts={})
|
36
|
+
if threshold_name.is_a?(Hash)
|
37
|
+
raise ArgumentError, "Thresolds must be named!"
|
38
|
+
end
|
39
|
+
|
40
|
+
opts = THRESHOLD_DEFAULTS.merge(opts)
|
41
|
+
|
42
|
+
opts.each_key do |key|
|
43
|
+
raise(ArgumentError,
|
44
|
+
"You can only specify known threshold options (#{THRESHOLD_OPTIONS.join(', ')}). #{key} is invalid."
|
45
|
+
) unless THRESHOLD_OPTIONS.include?(key)
|
46
|
+
end
|
47
|
+
|
48
|
+
#register it
|
49
|
+
@@_threshold_map[threshold_name] = opts
|
50
|
+
end
|
51
|
+
|
52
|
+
##
|
53
|
+
# A succinct wrapper to bulk register thresholds on actions and check access to those thresholds
|
54
|
+
# in before filters. This method will register the threshold and create the before filters.
|
55
|
+
#
|
56
|
+
# The threshold names will be "#{controlLer_name}/#{action_name}" when actions are given.
|
57
|
+
#
|
58
|
+
# If not actions are specified the threshold will be named for the controller.
|
59
|
+
#
|
60
|
+
# @param *args [~Array]
|
61
|
+
# args array for handling array of action names and threshold options
|
62
|
+
# Threshold queues are keyed with the controller & action name, so each
|
63
|
+
# action will have its own queue
|
64
|
+
#
|
65
|
+
# @param opts [Hash]
|
66
|
+
# * :limit [Array[Fixnum,Fixnum,Symbol]] number of access per time interval before
|
67
|
+
# threshold constraints are applied
|
68
|
+
# Default [0,0.seconds] #Always
|
69
|
+
# :limit => [2,5,:minutes] #=> Frequency(2,5,:minutes) 2 per 5 minutes
|
70
|
+
# :limit => [1, 5.minutes] #=> Frequency(1,5.minutes) 1 per 5 minutes
|
71
|
+
#
|
72
|
+
# * :halt_with [String,Symbol,Proc] Halts the filter chain instead if the
|
73
|
+
# threshold is in effect
|
74
|
+
# takes same params as before filter's throw :halt
|
75
|
+
# not specifying :halt_with when the mode is :halt
|
76
|
+
# will result in: throw(:halt)
|
77
|
+
#
|
78
|
+
# * :params [Array[~Symbol]] Parameters to include in the threshold key
|
79
|
+
# :params => [:blog_id]
|
80
|
+
#
|
81
|
+
# * :if / :unless - Passed to :if / :unless on before filter
|
82
|
+
#
|
83
|
+
# @note
|
84
|
+
# using the class method threshold_actions registers the threshold
|
85
|
+
# (no need for a register_threshold statement) and creates a before filter
|
86
|
+
# for the given actions where the actual threshold check will take place
|
87
|
+
#
|
88
|
+
# @example
|
89
|
+
# Using threshold and the before filter it creates:
|
90
|
+
#
|
91
|
+
# #Create two action level thresholds
|
92
|
+
# class MyController < Application
|
93
|
+
# threshold_actions :index, :create, :limit => [5, 30.seconds]
|
94
|
+
#
|
95
|
+
# #equivalent to:
|
96
|
+
# register_threshold :"my_controller/index", :limit => [5, 30.seconds]
|
97
|
+
# before(nil,{:only => [:index]}) do
|
98
|
+
# permit_access? :"my_controller/index"
|
99
|
+
# end
|
100
|
+
# register_threshold :"my_controller/create", :limit => [5, 30.seconds]
|
101
|
+
# before(nil,{:only => [:create]}) do
|
102
|
+
# permit_access? :"my_controller/create"
|
103
|
+
# end
|
104
|
+
#
|
105
|
+
# #create a controller level threshold
|
106
|
+
# class MyController < Application
|
107
|
+
# threshold_actions :limit => [5000, 1.day]
|
108
|
+
#
|
109
|
+
# #equivalent to:
|
110
|
+
# register_threshold :my_controller, :limit => [5000, 1.day]
|
111
|
+
# before(nil,{}) do
|
112
|
+
# permit_access? :my_controller
|
113
|
+
# end
|
114
|
+
#
|
115
|
+
# #create 1 action level threshold with :unless statement and halt
|
116
|
+
# class MyController < Application
|
117
|
+
# threshold_actions :search, :limit => [10, 5.minutes],
|
118
|
+
# :unless => :is_admin?,
|
119
|
+
# :halt_with => "Too many searches"
|
120
|
+
#
|
121
|
+
# #equivalent to:
|
122
|
+
# register_threshold :"my_controller/search", :limit => [10, 5.minutes]
|
123
|
+
# before(nil,{:only => [:search], :unless => :is_admin?}) do
|
124
|
+
# if !permit_access?(:"my_controller/search")
|
125
|
+
# throw(:halt, "Too many searches")
|
126
|
+
# end
|
127
|
+
# end
|
128
|
+
#
|
129
|
+
# @api public
|
130
|
+
#
|
131
|
+
def threshold_actions(*args)
|
132
|
+
opts = args.last.is_a?(Hash) ? args.pop : {}
|
133
|
+
thresholds_to_register = args
|
134
|
+
|
135
|
+
#exctract :limit, :params
|
136
|
+
threshold_opts = {}
|
137
|
+
threshold_opts[:limit] = opts.delete(:limit) || [0, 0.seconds] #Always
|
138
|
+
threshold_opts[:params] = opts.delete(:params)
|
139
|
+
|
140
|
+
halt_with = opts.delete(:halt_with)
|
141
|
+
|
142
|
+
#get threshold supported before filter options
|
143
|
+
filter_opts = {}
|
144
|
+
filter_opts[:if] = opts.delete(:if) if opts[:if]
|
145
|
+
filter_opts[:unless] = opts.delete(:unless) if opts[:unless]
|
146
|
+
|
147
|
+
if thresholds_to_register.empty?
|
148
|
+
# Register a controller level threshold
|
149
|
+
self.register_threshold(controller_name,threshold_opts)
|
150
|
+
|
151
|
+
self.before(nil,filter_opts) do
|
152
|
+
if !permit_access?(controller_name) && halt_with
|
153
|
+
throw(:halt, halt_with)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
else
|
157
|
+
#register a threshold for each action given
|
158
|
+
thresholds_to_register.each do |action_to_register|
|
159
|
+
tmp_threshold_name = "#{controller_name}/#{action_to_register}".to_sym
|
160
|
+
|
161
|
+
self.register_threshold(tmp_threshold_name,threshold_opts)
|
162
|
+
|
163
|
+
self.before(nil, filter_opts.merge({:only => [action_to_register]})) do
|
164
|
+
if !permit_access?(tmp_threshold_name) && halt_with
|
165
|
+
throw(:halt,halt_with)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
end #end class << self
|
173
|
+
|
174
|
+
|
175
|
+
##
|
176
|
+
# Used for determining if a subsequent request would exceed the threshold
|
177
|
+
#
|
178
|
+
# Good for protecting a post with a form or display captcha/wait before
|
179
|
+
# the threshold is exceeded
|
180
|
+
#
|
181
|
+
# @note See READEME: will_permit_another? vs is_currently_exceeded?
|
182
|
+
#
|
183
|
+
# @param threshold_name [~Symbol] The threshold to look up
|
184
|
+
#
|
185
|
+
# @api public
|
186
|
+
#
|
187
|
+
# @param [Boolean]
|
188
|
+
def will_permit_another?(threshold_name=nil)
|
189
|
+
threshold_name ||= action_name
|
190
|
+
opts = @@_threshold_map[threshold_name]
|
191
|
+
curr_threshold_key = threshold_key(threshold_name)
|
192
|
+
|
193
|
+
# if opts[:limit] is not set that means the threshold hasn't been registered yet
|
194
|
+
# so permit access, the threshold will be registered once threshold() is called
|
195
|
+
# which is usually behind the post request this would be submitted to.
|
196
|
+
if opts[:limit]
|
197
|
+
frequency = if opts[:limit].is_a?(Array)
|
198
|
+
Frequency.new(*opts[:limit])
|
199
|
+
else
|
200
|
+
opts[:limit].clone
|
201
|
+
end
|
202
|
+
frequency.load! access_history(curr_threshold_key)
|
203
|
+
|
204
|
+
frequency.permit?
|
205
|
+
else
|
206
|
+
true
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
##
|
211
|
+
# Is the threshold currenlty exceeded either by this request or a previous one
|
212
|
+
#
|
213
|
+
# Good for redirecting access during the current request
|
214
|
+
#
|
215
|
+
# @note See READEME: will_permit_another? vs is_currently_exceeded?
|
216
|
+
#
|
217
|
+
# @param threshold_name [~Symbol]
|
218
|
+
# current threshold key to lookup
|
219
|
+
#
|
220
|
+
# @api public
|
221
|
+
#
|
222
|
+
# @return [Boolean]
|
223
|
+
def is_currently_exceeded?(threshold_name=nil)
|
224
|
+
threshold_name ||= action_name
|
225
|
+
curr_threshold_key = threshold_key(threshold_name)
|
226
|
+
exceeded_thresholds.member? curr_threshold_key
|
227
|
+
end
|
228
|
+
|
229
|
+
|
230
|
+
##
|
231
|
+
# Shortcut to session[:merb_threshold_waiting_period]
|
232
|
+
#
|
233
|
+
# @note
|
234
|
+
# values stored in here are keyed with #threshold_key
|
235
|
+
# waiting_period[your_threshold_name] is not guaranteed to work instead use
|
236
|
+
# waiting_period[threshold_key(your_threshold_name)]
|
237
|
+
#
|
238
|
+
# @see #threshold_key
|
239
|
+
#
|
240
|
+
# @api semi-public
|
241
|
+
#
|
242
|
+
# @return [Hash]
|
243
|
+
def waiting_period
|
244
|
+
session[:merb_threshold_waiting_period] ||= {}
|
245
|
+
end
|
246
|
+
|
247
|
+
##
|
248
|
+
# get the key representation of the threshold name. Used to store data
|
249
|
+
# in session. This should be used whenever accessing data stored in the session
|
250
|
+
# hash.
|
251
|
+
#
|
252
|
+
# @note
|
253
|
+
# This is needed to support Params values as a part of the threshold name
|
254
|
+
#
|
255
|
+
# @param threshold_name [~Symbol] name of the threshold to get key for
|
256
|
+
#
|
257
|
+
# @api semi-public
|
258
|
+
#
|
259
|
+
# @return [~Symbol]
|
260
|
+
def threshold_key(threshold_name)
|
261
|
+
curr_threshold_key = threshold_name.to_s
|
262
|
+
|
263
|
+
# create key to lookup threshold data from users session
|
264
|
+
opts = @@_threshold_map[threshold_name]
|
265
|
+
if opts[:params]
|
266
|
+
opts[:params].each{ |param_key| curr_threshold_key += "/#{params[param_key]}" }
|
267
|
+
end
|
268
|
+
|
269
|
+
curr_threshold_key.to_sym
|
270
|
+
end
|
271
|
+
|
272
|
+
##
|
273
|
+
# Is access permitted to the threshold protected resource.
|
274
|
+
#
|
275
|
+
# @param threshold_name [~Symbol] Name of threshold to monitor
|
276
|
+
#
|
277
|
+
# @api public
|
278
|
+
#
|
279
|
+
# @returns [Boolean] was the access permitted?
|
280
|
+
#
|
281
|
+
def permit_access?(threshold_name=nil)
|
282
|
+
threshold_name ||= "#{controller_name}/#{action_name}".to_sym
|
283
|
+
|
284
|
+
curr_threshold_key = threshold_key(threshold_name)
|
285
|
+
opts = @@_threshold_map[threshold_name]
|
286
|
+
|
287
|
+
if opts.nil?
|
288
|
+
raise Exception, "Threshold (#{threshold_name}) was not registered"
|
289
|
+
end
|
290
|
+
|
291
|
+
# keep track of thresholds access and if they were relaxed
|
292
|
+
@relaxed_thresholds ||= {}
|
293
|
+
@relaxed_thresholds[curr_threshold_key] = false
|
294
|
+
|
295
|
+
# may or may not be exceeded, but threshold was not relaxed
|
296
|
+
frequency = if opts[:limit].is_a?(Array)
|
297
|
+
Frequency.new(*opts[:limit])
|
298
|
+
else
|
299
|
+
opts[:limit].clone
|
300
|
+
end
|
301
|
+
|
302
|
+
frequency.load! access_history(curr_threshold_key)
|
303
|
+
|
304
|
+
# Is this request permitted?
|
305
|
+
if frequency.permit? && !is_currently_exceeded?(threshold_name)
|
306
|
+
# if it is also in the exceeded list
|
307
|
+
access_history(curr_threshold_key) << Time.now.to_i
|
308
|
+
@relaxed_thresholds[curr_threshold_key] = true
|
309
|
+
else
|
310
|
+
# if request wasn't permitted and isn't already marked exceeded, mark it
|
311
|
+
exceeded_thresholds << curr_threshold_key unless is_currently_exceeded?(threshold_name)
|
312
|
+
|
313
|
+
#set the time until the treshold expires
|
314
|
+
waiting_period[curr_threshold_key] = frequency.wait
|
315
|
+
|
316
|
+
# try to relax threshold via captcha if enabled, then via waiting
|
317
|
+
if Merb::Plugins.config[:merb_threshold][:recaptcha]
|
318
|
+
@relaxed_thresholds[curr_threshold_key] = relax_via_captcha!(curr_threshold_key)
|
319
|
+
end
|
320
|
+
@relaxed_thresholds[curr_threshold_key] ||= relax_via_waiting! curr_threshold_key
|
321
|
+
end
|
322
|
+
|
323
|
+
#Only keep the last n number of access where n is frequency.occurence
|
324
|
+
access_history(curr_threshold_key).replace frequency.current_events
|
325
|
+
|
326
|
+
return @relaxed_thresholds[curr_threshold_key]
|
327
|
+
end
|
328
|
+
|
329
|
+
##
|
330
|
+
# Looks up the users access history
|
331
|
+
#
|
332
|
+
# @param curr_threshold_key [~Symbol]
|
333
|
+
# current threshold key to lookup
|
334
|
+
#
|
335
|
+
# @note
|
336
|
+
# this is a shortcut to the session hash, thus it needs the threshold_key not the
|
337
|
+
# threshold_name
|
338
|
+
#
|
339
|
+
# @see #threshold_key
|
340
|
+
#
|
341
|
+
# @api private
|
342
|
+
#
|
343
|
+
# @return [Array[Fixnum]]
|
344
|
+
def access_history(curr_threshold_key)
|
345
|
+
session[:merb_threshold_history] ||= {}
|
346
|
+
session[:merb_threshold_history][curr_threshold_key] ||= []
|
347
|
+
session[:merb_threshold_history][curr_threshold_key]
|
348
|
+
end
|
349
|
+
|
350
|
+
##
|
351
|
+
# Gets a list of exceeded thresholds
|
352
|
+
#
|
353
|
+
# @note
|
354
|
+
# this is a shortcut to the session hash, thus it needs the threshold_key not the
|
355
|
+
# threshold_name
|
356
|
+
#
|
357
|
+
# @see #threshold_key
|
358
|
+
#
|
359
|
+
# @api private
|
360
|
+
#
|
361
|
+
# @return [Array[~Symbol]]
|
362
|
+
#
|
363
|
+
def exceeded_thresholds
|
364
|
+
session[:merb_threshold_exceeded_thresholds] ||= []
|
365
|
+
session[:merb_threshold_exceeded_thresholds]
|
366
|
+
end
|
367
|
+
|
368
|
+
protected
|
369
|
+
|
370
|
+
##
|
371
|
+
# Determines if the user's request solved the captcha
|
372
|
+
#
|
373
|
+
# If the threshold was relaxed, this method resets exceeded threshold and access history
|
374
|
+
# for this key
|
375
|
+
#
|
376
|
+
# @param curr_threshold_key [~Symbol]
|
377
|
+
# current threshold key to lookup
|
378
|
+
#
|
379
|
+
# @note
|
380
|
+
# this deals primarily with the session hash, thus it needs the threshold_key not the
|
381
|
+
# threshold_name
|
382
|
+
#
|
383
|
+
# @see #threshold_key
|
384
|
+
#
|
385
|
+
# @api private
|
386
|
+
#
|
387
|
+
# @return [Boolean]
|
388
|
+
def relax_via_captcha!(curr_threshold_key)
|
389
|
+
if params[:recaptcha_challenge_field] && params[:recaptcha_response_field]
|
390
|
+
did_solve, captcha_error = ::RecaptchaClient.solve(request.remote_ip,
|
391
|
+
params[:recaptcha_challenge_field],
|
392
|
+
params[:recaptcha_response_field]
|
393
|
+
)
|
394
|
+
|
395
|
+
if did_solve
|
396
|
+
relax_threshold(curr_threshold_key)
|
397
|
+
else
|
398
|
+
@captcha_error = captcha_error
|
399
|
+
end
|
400
|
+
|
401
|
+
did_solve
|
402
|
+
else #no captcha data provided
|
403
|
+
false
|
404
|
+
end
|
405
|
+
end
|
406
|
+
|
407
|
+
##
|
408
|
+
# Determines if the threshold was relaxed by the user waiting
|
409
|
+
#
|
410
|
+
# If the threshold was relaxed, this method resets exceeded threshold, wait time and access history
|
411
|
+
# for this key
|
412
|
+
#
|
413
|
+
# @param curr_threshold_key [~Symbol]
|
414
|
+
# current threshold key to lookup
|
415
|
+
#
|
416
|
+
# @note
|
417
|
+
# this deals primarily with the session hash, thus it needs the threshold_key not the
|
418
|
+
# threshold_name
|
419
|
+
#
|
420
|
+
# @see #threshold_key
|
421
|
+
#
|
422
|
+
# @api private
|
423
|
+
#
|
424
|
+
# @return [Boolean]
|
425
|
+
#
|
426
|
+
def relax_via_waiting!(curr_threshold_key)
|
427
|
+
last_access = access_history(curr_threshold_key).last
|
428
|
+
time_to_wait = (waiting_period[curr_threshold_key] || 0)
|
429
|
+
|
430
|
+
#if there was no previous acces, this didn't relax from waiting
|
431
|
+
return false if last_access.nil?
|
432
|
+
|
433
|
+
did_relax = (Time.now.to_i > (last_access + time_to_wait))
|
434
|
+
|
435
|
+
relax_threshold(curr_threshold_key) if did_relax
|
436
|
+
|
437
|
+
did_relax
|
438
|
+
end
|
439
|
+
|
440
|
+
##
|
441
|
+
# Resets all tracked attributes on a threshold
|
442
|
+
#
|
443
|
+
# @param curr_threshold_key [~Symbol] they key to clear
|
444
|
+
#
|
445
|
+
def relax_threshold(curr_threshold_key)
|
446
|
+
exceeded_thresholds.delete curr_threshold_key
|
447
|
+
access_history(curr_threshold_key).clear
|
448
|
+
waiting_period.delete(curr_threshold_key)
|
449
|
+
end
|
450
|
+
|
451
|
+
end # end Merb::Controller
|
452
|
+
end # end Merb
|