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