feat(server): Add uid_record and checkAuthenticated endpoint (#121) r=vladikoff,rfk

This commit is contained in:
Larissa Gaulia 2016-08-05 11:20:29 -03:00 коммит произвёл Vlad Filippov
Родитель 383412c036
Коммит 3a254c414f
9 изменённых файлов: 382 добавлений и 4 удалений

Просмотреть файл

@ -26,6 +26,7 @@ content-type of "application/json".
* [POST /blockEmail](#post-blockemail) * [POST /blockEmail](#post-blockemail)
* [POST /blockIp](#post-blockip) * [POST /blockIp](#post-blockip)
* [POST /check](#post-check) * [POST /check](#post-check)
* [POST /checkAuthenticated](#post-checkauthenticated)
* [POST /failedLoginAttempt](#post-failedloginattempt) * [POST /failedLoginAttempt](#post-failedloginattempt)
* [POST /passwordReset](#post-passwordreset) * [POST /passwordReset](#post-passwordreset)
@ -142,6 +143,52 @@ Failing requests may be due to the following errors:
* status code 400, code MissingParameters: email, ip and action are all required * status code 400, code MissingParameters: email, ip and action are all required
## POST /checkAuthenticated
Called by the auth server before performing an authenticated action to
check whether or not the action should be blocked.
___Parameters___
* action - the name of the action under consideration
* ip - the IP address where the request originates
* uid - account identifier
### Request
```sh
curl -v \
-H "Content-Type: application/json" \
"http://127.0.0.1:7000/checkAuthenticated" \
-d '{
"action": "devicesNotify"
"ip": "192.0.2.1",
"uid": "0b65dd742b5a415487f2108cca597044",
}'
```
### Response
Successful requests will produce a "200 OK" response with the blocking
advice in the JSON body:
```json
{
"block": true,
"retryAfter": 86396
}
```
`block` indicates whether or not the action should be blocked and
`retryAfter` tells the client how long it should wait (in seconds)
before attempting this action again.
Failing requests may be due to the following errors:
* status code 400, code MissingParameters: action, ip and uid are all required
## POST /failedLoginAttempt ## POST /failedLoginAttempt
Called by the auth server to signal to the customs server that a Called by the auth server to signal to the customs server that a

Просмотреть файл

@ -94,6 +94,26 @@ module.exports = function (fs, path, url, convict) {
format: 'nat', format: 'nat',
env: 'IP_RATE_LIMIT_BAN_DURATION_SECONDS' env: 'IP_RATE_LIMIT_BAN_DURATION_SECONDS'
}, },
uidRateLimit: {
limitIntervalSeconds: {
doc: 'Duration of automatic throttling for uids',
default: 60 * 15,
format: 'nat',
env: 'UID_RATE_LIMIT_INTERVAL_SECONDS'
},
banDurationSeconds: {
doc: 'Duration of automatic ban',
default: 60 * 15,
format: 'nat',
env: 'UID_RATE_LIMIT_BAN_DURATION_SECONDS'
},
maxChecks: {
doc: 'Number of checks within uidRateLimitBanDurationSeconds before blocking',
default: 100,
format: 'nat',
env: 'UID_RATE_LIMIT'
}
},
maxAccountStatusCheck: { maxAccountStatusCheck: {
doc: 'Number of account status checks within rateLimitIntervalSeconds before throttling', doc: 'Number of account status checks within rateLimitIntervalSeconds before throttling',
default: 5, default: 5,

Просмотреть файл

@ -27,6 +27,9 @@ module.exports = function (config, mc, log) {
this.ipRateLimitBanDurationMs = settings.ipRateLimitBanDurationSeconds * 1000 this.ipRateLimitBanDurationMs = settings.ipRateLimitBanDurationSeconds * 1000
this.maxAccountStatusCheck = settings.maxAccountStatusCheck this.maxAccountStatusCheck = settings.maxAccountStatusCheck
this.badLoginErrnoWeights = settings.badLoginErrnoWeights || {} this.badLoginErrnoWeights = settings.badLoginErrnoWeights || {}
this.maxChecksPerUid = settings.uidRateLimit.maxChecks
this.uidRateLimitBanDurationMs = settings.uidRateLimit.banDurationSeconds * 1000
this.uidRateLimitIntervalMs = settings.uidRateLimit.limitIntervalSeconds * 1000
return this return this
} }

Просмотреть файл

@ -44,6 +44,7 @@ module.exports = function createServer(config, log) {
var IpEmailRecord = require('./ip_email_record')(limits) var IpEmailRecord = require('./ip_email_record')(limits)
var EmailRecord = require('./email_record')(limits) var EmailRecord = require('./email_record')(limits)
var IpRecord = require('./ip_record')(limits) var IpRecord = require('./ip_record')(limits)
var UidRecord = require('./uid_record')(limits)
var handleBan = P.promisify(require('./bans/handler')(config.memcache.recordLifetimeSeconds, mc, EmailRecord, IpRecord, log)) var handleBan = P.promisify(require('./bans/handler')(config.memcache.recordLifetimeSeconds, mc, EmailRecord, IpRecord, log))
@ -163,6 +164,55 @@ module.exports = function createServer(config, log) {
} }
) )
api.post(
'/checkAuthenticated',
function (req, res, next) {
var action = req.body.action
var ip = req.body.ip
var uid = req.body.uid
if(!action || !ip || !uid){
var err = {code: 'MissingParameters', message: 'action, ip and uid are all required'}
log.error({op:'request.checkAuthenticated', action: action, ip: ip, uid: uid, err: err})
res.send(400, err)
return next()
}
mc.getAsync(uid)
.then(UidRecord.parse, UidRecord.parse)
.then(
function (uidRecord) {
var retryAfter = uidRecord.addCount(action, uid)
return setRecord(uid, uidRecord)
.then(
function () {
return {
block: retryAfter > 0,
retryAfter: retryAfter
}
}
)
}
)
.then(
function (result) {
log.info({ op: 'request.checkAuthenticated', block: result.block })
res.send(result)
},
function (err) {
log.error({ op: 'request.checkAuthenticated', err: err })
// Default is to block request on any server based error
res.send({
block: true,
retryAfter: limits.blockIntervalMs
})
}
)
.done(next, next)
}
)
api.post( api.post(
'/failedLoginAttempt', '/failedLoginAttempt',
function (req, res, next) { function (req, res, next) {

95
lib/uid_record.js Normal file
Просмотреть файл

@ -0,0 +1,95 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* This module keeps track of events related to a specific uid
*/
module.exports = function (limits, now) {
now = now || Date.now
function UidRecord() {
this.timestampsByAction = {}
this.rateLimitedTimestamp = undefined
}
UidRecord.parse = function (object) {
var rec = new UidRecord()
object = object || {}
rec.rateLimitedTimestamp = object.rateLimitedTimestamp // timestamp when the account was rateLimited
rec.timestampsByAction = object.timestampsByAction || {} // timestamps on each kind of action
return rec
}
UidRecord.prototype.getMinLifetimeMS = function () {
return Math.max(
limits.uidRateLimitIntervalMs,
limits.uidRateLimitBanDurationMs
)
}
/*
* stores check timestamps if account is not blocked.
* returns remaining time until uid unblick
*/
UidRecord.prototype.addCount = function (action) {
var timestamp = now()
this.trimCounts(action, timestamp)
if(!this.isRateLimited()){
this.timestampsByAction[action].push(timestamp)
}
return this.getPossibleRetryDelay(action)
}
UidRecord.prototype.trimCounts = function (action, now) {
this.timestampsByAction[action] = this._trim(now, this.timestampsByAction[action], limits.maxChecksPerUid)
}
UidRecord.prototype._trim = function (now, items, max) {
if (!items || items.length === 0) { return [] }
// the list is naturally ordered from oldest to newest,
// and we only need to keep data for up to max+ 1.
var i = items.length - 1
var n = 0
var item = items[i]
while (item > (now - limits.uidRateLimitIntervalMs) && n < max) {
item = items[--i]
n++
if (i === -1) {
break
}
}
return items.slice(i + 1)
}
UidRecord.prototype.rateLimit = function () {
this.rateLimitedTimestamp = now()
}
UidRecord.prototype.getRetryAfter = function() {
return this.rateLimitedTimestamp + limits.uidRateLimitBanDurationMs - now()
}
UidRecord.prototype.isRateLimited = function() {
//uid is rateLimited if there's a valid rateLimited timestamp and
//waiting time interval wasn't reached
return (this.rateLimitedTimestamp && (this.rateLimitedTimestamp + limits.uidRateLimitBanDurationMs > now()))
}
UidRecord.prototype.getPossibleRetryDelay = function(action) {
if(this.timestampsByAction[action].length < limits.maxChecksPerUid){
this.rateLimitedTimestamp = undefined
return 0
} else {
if(!this.isRateLimited()){
// we have more attempts than allowed. rateLimit it
this.rateLimit()
}
return this.getRetryAfter()
}
}
return UidRecord
}

Просмотреть файл

@ -19,7 +19,12 @@ var config = {
maxBadLogins: 2, maxBadLogins: 2,
maxBadLoginsPerIp: Number(process.env.MAX_BAD_LOGINS_PER_IP) || 3, maxBadLoginsPerIp: Number(process.env.MAX_BAD_LOGINS_PER_IP) || 3,
ipRateLimitIntervalSeconds: Number(process.env.IP_RATE_LIMIT_INTERVAL_SECONDS) || 60 * 15, ipRateLimitIntervalSeconds: Number(process.env.IP_RATE_LIMIT_INTERVAL_SECONDS) || 60 * 15,
ipRateLimitBanDurationSeconds: Number(process.env.IP_RATE_LIMIT_BAN_DURATION_SECONDS) || 60 * 15 ipRateLimitBanDurationSeconds: Number(process.env.IP_RATE_LIMIT_BAN_DURATION_SECONDS) || 60 * 15,
uidRateLimit: {
limitIntervalSeconds: Number(process.env.UID_RATE_LIMIT_INTERVAL_SECONDS) || 60 * 15,
banDurationSeconds: Number(process.env.UID_RATE_LIMIT_BAN_DURATION_SECONDS) || 60 * 15,
maxChecks: Number(process.env.UID_RATE_LIMIT) || 3
}
} }
} }

Просмотреть файл

@ -56,7 +56,7 @@ test(
}) })
.then(function (bis) { .then(function (bis) {
x = bis x = bis
return mcHelper.setLimits({ blockIntervalSeconds: bis + 1 }) return mcHelper.setLimits({ blockIntervalSeconds: bis + 1, uidRateLimit:{} })
}) })
.then(function (settings) { .then(function (settings) {
t.equal(x + 1, settings.blockIntervalSeconds, 'helper sees the change') t.equal(x + 1, settings.blockIntervalSeconds, 'helper sees the change')

Просмотреть файл

@ -0,0 +1,159 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
// Override limit values for testing
process.env.UID_RATE_LIMIT = 3
process.env.UID_RATE_LIMIT_INTERVAL_SECONDS = 2
process.env.UID_RATE_LIMIT_BAN_DURATION_SECONDS = 2
var test = require('tap').test
var TestServer = require('../test_server')
var Promise = require('bluebird')
var restify = Promise.promisifyAll(require('restify'))
var mcHelper = require('../memcache-helper')
var TEST_IP = '192.0.2.1'
var TEST_UID = 'abc123'
var ACTION_ONE = 'action1'
var ACTION_A = 'actionA'
var ACTION_B = 'actionB'
var config = {
listen: {
port: 7000
}
}
var testServer = new TestServer(config)
var client = restify.createJsonClient({
url: 'http://127.0.0.1:' + config.listen.port
})
Promise.promisifyAll(client, { multiArgs: true })
test(
'startup',
function (t) {
testServer.start(function (err) {
t.type(testServer.server, 'object', 'test server was started')
t.notOk(err, 'no errors were returned')
t.end()
})
}
)
test(
'clear everything',
function (t) {
mcHelper.clearEverything(
function (err) {
t.notOk(err, 'no errors were returned')
t.end()
}
)
}
)
test(
'/checkAuthenticated with same action',
function (t) {
// Send requests until it blocks
return client.postAsync('/checkAuthenticated', { action: ACTION_ONE, ip: TEST_IP, uid: TEST_UID})
.spread(function(req, res, obj){
t.equal(res.statusCode, 200, 'returns a 200 check1')
t.equal(obj.block, false, 'not rate limited')
return client.postAsync('/checkAuthenticated', { action: ACTION_ONE, ip: TEST_IP, uid: TEST_UID})
})
.spread(function(req, res, obj){
t.equal(res.statusCode, 200, 'returns a 200 check2')
t.equal(obj.block, false, 'not rate limited')
return client.postAsync('/checkAuthenticated', { action: ACTION_ONE, ip: TEST_IP, uid: TEST_UID})
})
// uid should be now blocked
.spread(function(req, res, obj){
t.equal(res.statusCode, 200, 'returns a 200 check3')
t.equal(obj.block, true, 'uid is rate limited')
// Delay ~2s for rate limit to go away
return Promise.delay(2010)
})
// uid should be now unblocked
.then(function(){
return client.postAsync('/checkAuthenticated', { action: ACTION_ONE, ip: TEST_IP, uid: TEST_UID})
})
.spread(function(req, res, obj){
t.equal(res.statusCode, 200, 'returns a 200')
t.equal(obj.block, false, 'is not rate limited after UID_RATE_LIMIT_BAN_DURATION_SECONDS')
t.end()
})
.catch(function(err){
t.fail(err)
t.end()
})
}
)
test(
'/checkAuthenticated with different actions',
function (t) {
// Send requests until one gets rate limited
return client.postAsync('/checkAuthenticated', { action: ACTION_A, ip: TEST_IP, uid: TEST_UID})
.spread(function(req, res, obj){
t.equal(res.statusCode, 200, 'returns a 200')
t.equal(obj.block, false, 'not rate limited on check1, actionA')
return client.postAsync('/checkAuthenticated', { action: ACTION_B, ip: TEST_IP, uid: TEST_UID})
})
.spread(function(req, res, obj){
t.equal(res.statusCode, 200, 'returns a 200')
t.equal(obj.block, false, 'not rate limited on check1, actionB')
return client.postAsync('/checkAuthenticated', { action: ACTION_A, ip: TEST_IP, uid: TEST_UID})
})
.spread(function(req, res, obj){
t.equal(res.statusCode, 200, 'returns a 200')
t.equal(obj.block, false, 'not rate limited on check2, actionA')
return client.postAsync('/checkAuthenticated', { action: ACTION_A, ip: TEST_IP, uid: TEST_UID})
})
// uid should be now blocked to action1
.spread(function(req, res, obj){
t.equal(res.statusCode, 200, 'returns a 200')
t.equal(obj.block, true, 'uid is actionA rate limited after check3')
return client.postAsync('/checkAuthenticated', { action: ACTION_B, ip: TEST_IP, uid: TEST_UID})
})
.spread(function(req, res, obj){
t.equal(res.statusCode, 200, 'returns a 200')
t.equal(obj.block, false, 'not rate limited for actionB after check2')
// Delay ~2s for rate limit to go away
return Promise.delay(2010)
})
// uid should be now unblocked
.then(function(){
return client.postAsync('/checkAuthenticated', { action: ACTION_A, ip: TEST_IP, uid: TEST_UID})
})
.spread(function(req, res, obj){
t.equal(res.statusCode, 200, 'returns a 200')
t.equal(obj.block, false, 'is not rate limited after UID_RATE_LIMIT_BAN_DURATION_SECONDS')
t.end()
})
.catch(function(err){
t.fail(err)
t.end()
})
}
)
test(
'teardown',
function (t) {
testServer.stop()
t.equal(testServer.server.killed, true, 'test server has been killed')
t.end()
}
)

Просмотреть файл

@ -91,7 +91,7 @@ test(
t.equal(res.statusCode, 200, 'returns a 200') t.equal(res.statusCode, 200, 'returns a 200')
t.equal(obj.block, true, 'ip is still rate limited') t.equal(obj.block, true, 'ip is still rate limited')
// Delay ~3s for rate limit to go away // Delay ~5s for rate limit to go away
return Promise.delay(5010) return Promise.delay(5010)
}) })
// IP should be now unblocked // IP should be now unblocked
@ -158,7 +158,6 @@ test(
} }
) )
test( test(
'teardown', 'teardown',
function (t) { function (t) {