Merge pull request #798 from warpc/796-change_api_defender

[refs #796] add api limit for user
This commit is contained in:
Vladimir Sharshov 2012-12-26 08:53:06 -08:00
commit 7cb53a2930
7 changed files with 169 additions and 15 deletions

View File

@ -87,4 +87,5 @@ group :test do
gem 'factory_girl_rails', '~> 4.0.0' gem 'factory_girl_rails', '~> 4.0.0'
gem 'rr', '~> 1.0.4' gem 'rr', '~> 1.0.4'
gem 'shoulda' gem 'shoulda'
gem 'mock_redis', '0.6.2'
end end

View File

@ -172,6 +172,7 @@ GEM
meta-tags (1.2.6) meta-tags (1.2.6)
actionpack actionpack
mime-types (1.19) mime-types (1.19)
mock_redis (0.6.2)
multi_json (1.3.6) multi_json (1.3.6)
mustache (0.99.4) mustache (0.99.4)
net-scp (1.0.4) net-scp (1.0.4)
@ -386,6 +387,7 @@ DEPENDENCIES
jquery-rails (~> 2.0.2) jquery-rails (~> 2.0.2)
mailcatcher mailcatcher
meta-tags (~> 1.2.5) meta-tags (~> 1.2.5)
mock_redis (= 0.6.2)
newrelic_rpm (~> 3.4.1) newrelic_rpm (~> 3.4.1)
omniauth (~> 1.1.0) omniauth (~> 1.1.0)
omniauth-openid (~> 1.0.1) omniauth-openid (~> 1.0.1)

View File

@ -87,7 +87,9 @@ class User < Avatar
def find_for_database_authentication(warden_conditions) def find_for_database_authentication(warden_conditions)
conditions = warden_conditions.dup conditions = warden_conditions.dup
login = conditions.delete(:login) login = conditions.delete(:login)
where(conditions).where(["lower(uname) = :value OR lower(email) = :value", { :value => login.downcase }]).first where(conditions)
.where(["lower(uname) = :value OR lower(email) = :value OR authentication_token = :orig_value",
{ :value => login.downcase, :orig_value => login }]).first
end end
def new_with_session(params, session) def new_with_session(params, session)
@ -103,6 +105,11 @@ class User < Avatar
end end
end end
end end
def auth_by_token_or_login_pass(user, pass)
u = User.find_for_database_authentication(:login => user)
u if u && !u.access_locked? && (u.authentication_token == user || u.valid_password?(pass))
end
end end
# def update_with_password(params={}) # def update_with_password(params={})

View File

@ -9,15 +9,13 @@ class ApiDefender < Rack::Throttle::Hourly
options = { options = {
:cache => Redis.new(:thread_safe => true), :cache => Redis.new(:thread_safe => true),
:key_prefix => :throttle, :key_prefix => :throttle,
:max => 500 # only 500 request per hour
# only 500 request per hour
:max => 500
} }
@app, @options = app, options @app, @options = app, options
end end
# this method checks if request needs throttling. # this method checks if request needs throttling.
# If so, it increases usage counter and compare it with maximum # If so, it increases usage counter and compare it with maximum
# allowed API calls. Returns true if a request can be handled. # allowed API calls. Returns true if a request can be handled.
def allowed?(request) def allowed?(request)
need_defense?(request) ? cache_incr(request) <= max_per_window : true need_defense?(request) ? cache_incr(request) <= max_per_window : true
@ -30,8 +28,9 @@ class ApiDefender < Rack::Throttle::Hourly
# requests remaining does they have # requests remaining does they have
if need_defense?(request) if need_defense?(request)
heders['X-RateLimit-Limit'] = max_per_window.to_s heders['X-RateLimit-Limit'] = max_per_window.to_s
heders['X-RateLimit-Remaining'] = ([0, max_per_window - (cache_get(cache_key(request)).to_i rescue 1)].max).to_s heders['X-RateLimit-Remaining'] = ([0, max_per_window - (cache_get(choice_key(request)).to_i rescue 1)].max).to_s
end end
@is_authorized = @user = nil
[status, heders, body] [status, heders, body]
end end
@ -40,14 +39,34 @@ class ApiDefender < Rack::Throttle::Hourly
key = cache_key(request) key = cache_key(request)
count = cache.incr(key) count = cache.incr(key)
cache.expire(key, 1.day) if count == 1 cache.expire(key, 1.day) if count == 1
if @user
count = cache.incr(choice_key(request))
cache.expire(key, 1.day) if count == 1
end
count count
end end
protected protected
# only API calls should be throttled # only API calls should be throttled
def need_defense?(request) def need_defense?(request)
request.env['PATH_INFO'] =~ /^\/api\/v1\// request.env['PATH_INFO'] =~ /^\/api\/v1\// && !system_user?(request)
end end
end def authorized?(request)
return @is_authorized if @is_authorized
auth = Rack::Auth::Basic::Request.new(request.env)
@user = User.auth_by_token_or_login_pass(*auth.credentials) if auth.provided? and auth.basic?
@is_authorized = true # cache
end
def choice_key request
return cache_key(request) unless @user
[@options[:key_prefix], @user.uname, Time.now.strftime('%Y-%m-%dT%H')].join(':')
end
def system_user? request
authorized?(request) && %w(rosa_system iso_worker_1).include?(@user.try :uname)
end
end

View File

@ -12,9 +12,7 @@ module Grack
return render_not_found if project.blank? return render_not_found if project.blank?
return ::Rack::Auth::Basic.new(@app) do |u, p| return ::Rack::Auth::Basic.new(@app) do |u, p|
user = (User.where(:authentication_token => u).first || user = User.auth_by_token_or_login_pass(u, p) and
User.find_for_database_authentication(:login => u)) and
!user.access_locked? and (user.authentication_token == u or user.valid_password?(p)) and
ability = ::Ability.new(user) and ability.can?(action, project) # project.members.include?(user) ability = ::Ability.new(user) and ability.can?(action, project) # project.members.include?(user)
end.call(env) unless project.public? and read? # need auth end.call(env) unless project.public? and read? # need auth
end end

View File

@ -0,0 +1,122 @@
require 'spec_helper'
describe ApiDefender do
def get_basic_auth user = @user, by_token = false, by_email = false
u,pass = if by_token
[user.authentication_token, '']
elsif by_email
[user.email, @password]
else
[user.uname, @password]
end
ActionController::HttpAuthentication::Basic.encode_credentials u, pass
end
def get_request auth_user = nil, by_token = false, by_email = false
auth = auth_user ? {'HTTP_AUTHORIZATION' => get_basic_auth(auth_user, by_token, by_email)} : {}
get "/api/v1/users/#{@user.id}.json", {}, auth
end
def get_request2 auth_user = nil, by_token = false, by_email = false
auth_user = FactoryGirl.create(:user) if !auth_user && APP_CONFIG['anonymous_access'] == false
get_request auth_user, by_token, by_email
end
before do
stub_symlink_methods && stub_redis
@redis = Redis.new
@password = '123456'
@rate_limit = 3 # dont forget change in max_per_window
ApiDefender.class_eval("def cache; Redis.new; end; def max_per_window; return #{@rate_limit}; end;")
end
before(:each) do
@user = FactoryGirl.create :user, :password => @password
@system_user = FactoryGirl.create :user, :uname => 'rosa_system'
end
if APP_CONFIG['anonymous_access'] == true
context 'for anonymous user' do
it "should return the total limit" do
get_request
response.headers['X-RateLimit-Limit'].should == @rate_limit.to_s
end
it "should return the correct limit usage for anonymous user" do
get_request
response.headers['X-RateLimit-Remaining'].should == (@rate_limit-1).to_s
end
it "should return the correct limit usage for anonymous user after authenticated access" do
get_request @user
get_request
response.headers['X-RateLimit-Remaining'].should == (@rate_limit-2).to_s
end
it "should forbidden anonymous user after exceeding limit rate" do
(@rate_limit+1).times {get_request}
response.status.should == 403
end
end
else
it "should forbidden anonymous access" do
get_request
response.status.should == 401
end
end
context 'for user' do
it "should return the correct limit usage for auth user" do
get_request @user
response.headers['X-RateLimit-Remaining'].should == (@rate_limit-1).to_s
end
it "should allow auth by uname and password" do
(@rate_limit+1).times {get_request2}
get_request @user
response.headers['X-RateLimit-Remaining'].should == (@rate_limit-1).to_s
end
it "should allow auth by email and password" do
(@rate_limit+1).times {get_request2}
get_request @user, false, true
response.headers['X-RateLimit-Remaining'].should == (@rate_limit-1).to_s
end
it "should allow auth by token" do
(@rate_limit+1).times {get_request2}
get_request @user, true
response.headers['X-RateLimit-Remaining'].should == (@rate_limit-1).to_s
end
it "should return the correct limit usage for auth user after other user" do
get_request2
get_request @user
response.headers['X-RateLimit-Remaining'].should == (@rate_limit-1).to_s
end
it "should forbidden user after exceeding limit rate" do
(@rate_limit+1).times {get_request @user}
response.status.should == 403
end
it "should not forbidden user after exceeding limit rate of the other user" do
(@rate_limit+1).times {get_request2}
get_request @user
response.status.should == 200
end
end
context 'for system user' do
it "should not return the limit usage for system user" do
get_request @system_user, true
response.headers['X-RateLimit-Limit'].should_not == @rate_limit.to_s
end
it "should not forbidden system user" do
(@rate_limit+1).times {get_request @system_user, true}
response.status.should == 200
end
end
end

View File

@ -58,6 +58,11 @@ Resque.inline = true
%x(rm -Rf #{APP_CONFIG['git_path']}) %x(rm -Rf #{APP_CONFIG['git_path']})
%x(mkdir -p #{APP_CONFIG['git_path']}) %x(mkdir -p #{APP_CONFIG['git_path']})
def stub_redis
redis_instance = MockRedis.new
stub(Redis).new { redis_instance }
end
def fill_project project def fill_project project
%x(mkdir -p #{project.path} && cp -Rf #{Rails.root}/spec/tests.git/* #{project.path}) # maybe FIXME ? %x(mkdir -p #{project.path} && cp -Rf #{Rails.root}/spec/tests.git/* #{project.path}) # maybe FIXME ?
end end