Skip to content

Commit

Permalink
Merge pull request #102 from MITLibraries/anon
Browse files Browse the repository at this point in the history
Anon
  • Loading branch information
JPrevost authored May 13, 2019
2 parents ed7b3ca + 108e20d commit 873ad81
Show file tree
Hide file tree
Showing 15 changed files with 230 additions and 17 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ gem 'jwt'
gem 'lograge'
gem 'mitlibraries-theme'
gem 'puma'
gem 'rack-attack'
gem 'rack-cors'
gem 'rails', '~> 5.2'
gem 'redis'
gem 'sass-rails'
gem 'uglifier'

Expand Down
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ GEM
public_suffix (3.0.3)
puma (3.12.1)
rack (2.0.7)
rack-attack (6.0.0)
rack (>= 1.0, < 3)
rack-cors (1.0.3)
rack-test (1.1.0)
rack (>= 1.0, < 3)
Expand Down Expand Up @@ -206,6 +208,7 @@ GEM
rb-fsevent (0.10.3)
rb-inotify (0.10.0)
ffi (~> 1.0)
redis (4.1.1)
ref (2.0.0)
regexp_parser (1.4.0)
request_store (1.4.1)
Expand Down Expand Up @@ -308,8 +311,10 @@ DEPENDENCIES
mitlibraries-theme
pg
puma
rack-attack
rack-cors
rails (~> 5.2)
redis
rubocop
sass-rails
selenium-webdriver
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ additional records with a standardized template.
- `PREFERRED_DOMAIN` - set this to the domain you would like to to use. Any
other requests that come to the app will redirect to the root of this domain.
This is useful to prevent access to herokuapp.com domains.
- `REQUESTS_PER_PERIOD` - requests allowed before throttling. Default is 100.
- `REQUEST_PERIOD` - number of minutes for the period in `REQUESTS_PER_PERIOD`.
Default is 1.
3 changes: 2 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@
}
},
"addons": [
"heroku-postgresql"
"heroku-postgresql",
"heroku-redis"
],
"buildpacks": [
{
Expand Down
1 change: 0 additions & 1 deletion app/controllers/api/v1/search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ module V1
class SearchController < ApplicationController
respond_to :json
before_action :ensure_json!
before_action :authenticate_user!, except: 'ping'

SIZE = 20
MAX_PAGE = 200
Expand Down
5 changes: 5 additions & 0 deletions app/views/api/v1/search/_throttle.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
if request.env['rack.attack.throttle_data']
json.request_limit request.env['rack.attack.throttle_data']['req/ip'][:limit]
json.request_count request.env['rack.attack.throttle_data']['req/ip'][:count]
json.limit_info 'Register for a free account and provide your JWT token to remove all request limitations.'
end
1 change: 1 addition & 0 deletions app/views/api/v1/search/record.json.jbuilder
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
result = @results['_source']
json.partial! partial: 'throttle'
json.partial! partial: 'base', locals: { result: result }
json.partial! partial: 'extended', locals: { result: result }
1 change: 1 addition & 0 deletions app/views/api/v1/search/search.json.jbuilder
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
json.hits @results['hits']['total']
json.partial! partial: 'throttle'

if @results['hits']['total'].positive? && @results['hits']['hits'].count.zero?
json.error 'Invalid page parameter: requested page past last result'
Expand Down
2 changes: 2 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 5.2

config.middleware.use Rack::Attack

config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
Expand Down
2 changes: 1 addition & 1 deletion config/environments/production.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
config.log_tags = [ :request_id ]

# Use a different cache store in production.
# config.cache_store = :mem_cache_store
config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }

# Use a real queuing backend for Active Job (and separate queues per environment)
# config.active_job.queue_adapter = :resque
Expand Down
114 changes: 114 additions & 0 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
class Rack::Attack

### Configure Cache ###

# If you don't want to use Rails.cache (Rack::Attack's default), then
# configure it here.
#
# Note: The store is only used for throttling (not blacklisting and
# whitelisting). It must implement .increment and .write like
# ActiveSupport::Cache::Store

# Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new

### Throttle Spammy Clients ###

# If any single client IP is making tons of requests, then they're
# probably malicious or a poorly-configured scraper. Either way, they
# don't deserve to hog all of the app server's CPU. Cut them off!

# Throttle all requests by IP
#
# Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
throttle('req/ip',
limit: (ENV.fetch('REQUESTS_PER_PERIOD') { 100 }).to_i,
period: (ENV.fetch('REQUEST_PERIOD') { 1 }).to_i.minutes) do |req|
req.ip unless req.path.start_with?('/assets')
end

### Prevent Brute-Force Login Attacks ###

# The most common brute-force login attack is a brute-force password
# attack where an attacker simply tries a large number of emails and
# passwords to see if any credentials match.
#
# Another common method of attack is to use a swarm of computers with
# different IPs to try brute-forcing a password for a specific account.
# We aren't currently handling this second case.

# Throttle POST requests to /login by IP address
#
# Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}"
throttle('logins/ip', limit: 5, period: 20.seconds) do |req|
if req.path == '/api/v1/auth'
req.ip
end
end

# If the reqeust includes a valid JWT token, ignore all other throttle
# conditions. Any truthy response results in the request being safelisted.
Rack::Attack.safelist("mark any authenticated access safe") do |request|
if request.has_header?('HTTP_AUTHORIZATION')

strategy, token = request.fetch_header('HTTP_AUTHORIZATION').split(' ')

# No token or not even close to valid
if (strategy || '').downcase != 'bearer'
false
else

# This rescue catches expired tokens and will result in
# a non-truthy return in the next condition.
claims = JWTWrapper.decode(token) rescue nil

# Expired token from above or somehow it doesn't have a user_id in it
if !claims || !claims.has_key?('user_id')
false

# Returns truthy and safelists if the user is valid
else
User.find_by_id claims['user_id']
end
end

end
end

# Provide userful information when throttle is triggered so users know what
# happened. Rack-attack provides very little by default becaust it assumes
# bad intent. We are assuming good intent and thus provide users with info
# about how many requests are allowed in a time period and how to remove
# those restrictions by registering.
Rack::Attack.throttled_response = lambda do |env|
match_data = env['rack.attack.match_data']
now = match_data[:epoch_time]

headers = {
'Content-Type' => 'application/json',
'RateLimit-Limit' => match_data[:limit].to_s,
'RateLimit-Remaining' => '0',
'RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s
}

body = {
:error => 'Throttled. Register for an account for unlimited access.',
:request_limit => match_data[:limit].to_s,
:request_count => match_data[:count].to_s,
:limit_info => 'Register for a free account and provide your JWT token to remove all request limitations.',
:token_provided => 'If you provided a JWT token, it was either invalid or expired. Please revisit documentation and send us an email if you need assistance with these limits.'
}

[ 429, headers, [body.to_json]]
end

# Log when throttles are triggered
ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, request_id, payload|
@@rack_logger ||= ActiveSupport::TaggedLogging.new(Logger.new(STDOUT))
@@rack_logger.info{[
"[#{payload[:request].env['rack.attack.match_type']}]",
"[#{payload[:request].env['rack.attack.matched']}]",
"[#{payload[:request].env['rack.attack.match_discriminator']}]",
"[#{payload[:request].env['rack.attack.throttle_data']}]",
].join(' ') }
end
end
10 changes: 6 additions & 4 deletions openapi.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"openapi": "3.0.2",
"info": {
"version": "0.0.7",
"version": "0.0.8",
"title": "MIT Libraries Discovery API",
"description": "MIT Libraries Discovery API. Register for an account at [https://timdex.mit.edu](https://timdex.mit.edu)",
"description": "MIT Libraries Discovery API. Register for an optional account at [https://timdex.mit.edu](https://timdex.mit.edu)\n\nAnonymous access is rate limited. Registering and using JWT tokens removes the rate limit. If you run into issues with tokens, we're happy to help!\n",
"contact": {
"name": "MIT Libraries Developers",
"email": "timdex@mit.edu",
Expand Down Expand Up @@ -82,7 +82,8 @@
"tags": [
"Search"
],
"description": "Search endpoint",
"summary": "Search Endpoint",
"description": "Non authenticated access is throttled. Use JWT auth for unrestricted access.",
"operationId": "search",
"responses": {
"200": {
Expand Down Expand Up @@ -201,7 +202,8 @@
"tags": [
"Retrieve"
],
"description": "Retrieve a single record endpoint",
"summary": "Retrieve a single record endpoint",
"description": "Non authenticated access is throttled. Use JWT auth for unrestricted access.",
"operationId": "getByRecordID",
"responses": {
"200": {
Expand Down
26 changes: 16 additions & 10 deletions test/controllers/search_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,28 @@ class SearchControllerTest < ActionDispatch::IntegrationTest
end
end

test 'invalid token' do
test 'invalid token succeeds and includes throttle information' do
token = JWTWrapper.encode(user_id: 'fakeid')
get '/api/v1/search?q=super+cool+search',
headers: { 'Authorization': "Bearer #{token}" }
assert_equal(401, response.status)
assert_equal('{"error" : "invalid credentials"}', response.body)
VCR.use_cassette('invalid token') do
get '/api/v1/search?q=super+cool+search',
headers: { 'Authorization': "Bearer #{token}" }
assert_equal(200, response.status)
json = JSON.parse(response.body)
assert_equal(100, json['request_limit'])
end
end

test 'expired token' do
test 'expired token succeeds and includes throttle information' do
token = Timecop.freeze(Time.zone.today - 1) do
JWTWrapper.encode(user_id: users(:yo).id)
end
get '/api/v1/search?q=super+cool+search',
headers: { 'Authorization': "Bearer #{token}" }
assert_equal(401, response.status)
assert_equal('{"error" : "invalid credentials"}', response.body)
VCR.use_cassette('expired token') do
get '/api/v1/search?q=super+cool+search',
headers: { 'Authorization': "Bearer #{token}" }
assert_equal(200, response.status)
json = JSON.parse(response.body)
assert_equal(100, json['request_limit'])
end
end

test 'ping with no token' do
Expand Down
36 changes: 36 additions & 0 deletions test/vcr_cassettes/expired_token.yml

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions test/vcr_cassettes/invalid_token.yml

Large diffs are not rendered by default.

0 comments on commit 873ad81

Please sign in to comment.