diff --git a/data/database.py b/data/database.py index 349ad1b58..69932273d 100644 --- a/data/database.py +++ b/data/database.py @@ -76,6 +76,8 @@ class User(BaseModel): organization = BooleanField(default=False, index=True) robot = BooleanField(default=False, index=True) invoice_email = BooleanField(default=False) + invalid_login_attempts = IntegerField(default=0) + last_invalid_login = DateTimeField(default=datetime.utcnow) class TeamRole(BaseModel): diff --git a/data/model/legacy.py b/data/model/legacy.py index 52723bd11..2e703b003 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -1,12 +1,17 @@ import bcrypt import logging -import datetime import dateutil.parser import json +from datetime import datetime, timedelta + from data.database import * from util.validation import * from util.names import format_robot_username +from util.backoff import exponential_backoff + + +EXPONENTIAL_BACKOFF_SCALE = timedelta(seconds=1) logger = logging.getLogger(__name__) @@ -68,6 +73,12 @@ class TooManyUsersException(DataModelException): pass +class TooManyLoginAttemptsException(Exception): + def __init__(self, message, retry_after): + super(TooManyLoginAttemptsException, self).__init__(message) + self.retry_after = retry_after + + def is_create_user_allowed(): return True @@ -551,11 +562,30 @@ def verify_user(username_or_email, password): except User.DoesNotExist: return None + now = datetime.utcnow() + + if fetched.invalid_login_attempts > 0: + can_retry_at = exponential_backoff(fetched.invalid_login_attempts, EXPONENTIAL_BACKOFF_SCALE, + fetched.last_invalid_login) + + if can_retry_at > now: + retry_after = can_retry_at - now + raise TooManyLoginAttemptsException('Too many login attempts.', retry_after.total_seconds()) + if (fetched.password_hash and bcrypt.hashpw(password, fetched.password_hash) == fetched.password_hash): + + if fetched.invalid_login_attempts > 0: + fetched.invalid_login_attempts = 0 + fetched.save() + return fetched + fetched.invalid_login_attempts += 1 + fetched.last_invalid_login = now + fetched.save() + # We weren't able to authorize the user return None diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index e8dab28dc..854c3cad1 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -87,6 +87,14 @@ def handle_api_error(error): return response +@api_bp.app_errorhandler(model.TooManyLoginAttemptsException) +@crossdomain(origin='*', headers=['Authorization', 'Content-Type']) +def handle_too_many_login_attempts(error): + response = make_response('Too many login attempts', 429) + response.headers['Retry-After'] = int(error.retry_after) + return response + + def resource(*urls, **kwargs): def wrapper(api_resource): if not api_resource: diff --git a/test/data/test.db b/test/data/test.db index 34882e117..3e631b5d7 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/util/backoff.py b/util/backoff.py new file mode 100644 index 000000000..15429936e --- /dev/null +++ b/util/backoff.py @@ -0,0 +1,5 @@ +def exponential_backoff(attempts, scaling_factor, base): + backoff = 5 * (pow(2, attempts) - 1) + backoff_time = backoff * scaling_factor + retry_at = backoff_time/10 + base + return retry_at