[#8126] rate limits for 2FA
diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py
index 3c665cf..39d5b56 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -23,7 +23,7 @@
 
 import bson
 import tg
-from allura.lib.exceptions import InvalidRecoveryCode
+from allura.lib.exceptions import InvalidRecoveryCode, MultifactorRateLimitError
 from tg import expose, flash, redirect, validate, config, session
 from tg.decorators import with_trailing_slash, without_trailing_slash
 from pylons import tmpl_context as c, app_globals as g
@@ -346,7 +346,7 @@
             if mode == 'totp':
                 totp_service = TotpService.get()
                 totp = totp_service.get_totp(user)
-                totp_service.verify(totp, code)
+                totp_service.verify(totp, code, user)
             elif mode == 'recovery':
                 recovery = RecoveryCodeService.get()
                 recovery.verify_and_remove_code(user, code)
@@ -354,6 +354,9 @@
         except (InvalidToken, InvalidRecoveryCode):
             c.form_errors['code'] = 'Invalid code, please try again.'
             return self.multifactor(mode=mode, **kwargs)
+        except MultifactorRateLimitError:
+            c.form_errors['code'] = 'Multifactor rate limit exceeded, slow down and try again later.'
+            return self.multifactor(mode=mode, **kwargs)
         else:
             plugin.AuthenticationProvider.get(request).login(user=user, multifactor_success=True)
             return_to = self._verify_return_to(kwargs.get('return_to'))
@@ -706,7 +709,7 @@
         totp_service = TotpService.get()
         totp = totp_service.Totp(key)
         try:
-            totp_service.verify(totp, code)
+            totp_service.verify(totp, code, c.user)
         except InvalidToken:
             h.auditlog_user('Failed to set up multifactor TOTP (wrong code)')
             c.form_errors['code'] = 'Invalid code, please try again.'
diff --git a/Allura/allura/lib/exceptions.py b/Allura/allura/lib/exceptions.py
index e43a8fc..32be05d 100644
--- a/Allura/allura/lib/exceptions.py
+++ b/Allura/allura/lib/exceptions.py
@@ -48,6 +48,10 @@
     pass
 
 
+class MultifactorRateLimitError(RatelimitError):
+    pass
+
+
 class ProjectPhoneVerificationError(ForgeError):
     pass
 
diff --git a/Allura/allura/lib/multifactor.py b/Allura/allura/lib/multifactor.py
index cfd6247..45b732d 100644
--- a/Allura/allura/lib/multifactor.py
+++ b/Allura/allura/lib/multifactor.py
@@ -25,7 +25,7 @@
 import errno
 
 import bson
-from allura.lib.exceptions import InvalidRecoveryCode
+from allura.lib.exceptions import InvalidRecoveryCode, MultifactorRateLimitError
 from tg import config
 from pylons import app_globals as g
 from paste.deploy.converters import asint
@@ -42,11 +42,29 @@
 log = logging.getLogger(__name__)
 
 
+def check_rate_limit(num_allowed, time_allowed, attempts):
+    '''
+    :param int num_allowed:
+    :param int time_allowed:
+    :param list[int] attempts:
+    :return: tuple: ok (bool), attempts still in window (list[int])
+    '''
+    attempts_in_limit = []
+    now = int(time())
+    for prev_attempt in attempts:
+        if now - prev_attempt <= time_allowed:
+            attempts_in_limit.append(prev_attempt)
+    attempts_in_limit.append(now)
+
+    ok = len(attempts_in_limit) <= num_allowed
+    return ok, attempts_in_limit
+
+
 class TotpService(object):
     '''
     An interface for handling multifactor auth TOTP secret keys.  Common functionality
     is provided in this base class, and specific subclasses implement different storage options.
-    A provider must implement :meth:`get_secret_key` and :meth:`set_secret_key`.
+    A provider must implement :meth:`get_secret_key` and :meth:`set_secret_key` and :meth:`enforce_rate_limit`
 
     To use a new provider, expose an entry point in setup.py::
 
@@ -80,10 +98,12 @@
 
         return totp
 
-    def verify(self, totp, code):
+    def verify(self, totp, code, user):
         code = code.replace(' ', '')  # Google authenticator puts a space in their codes
         code = bytes(code)  # can't be unicode
 
+        self.enforce_rate_limit(user)
+
         # TODO prohibit re-use of a successful code, although it seems unlikely with a 30s window
         # see https://tools.ietf.org/html/rfc6238#section-5.2 paragraph 5
 
@@ -126,8 +146,31 @@
         '''
         raise NotImplementedError('set_secret_key')
 
+    def enforce_rate_limit(self, user):
+        '''
+        :param user: a :class:`User <allura.model.auth.User>`
+        :raises: MultifactorRateLimitError
+        '''
+        raise NotImplementedError('enforce_rate_limit')
 
-class MongodbTotpService(TotpService):
+
+class MongodbMultifactorCommon(object):
+
+    def enforce_rate_limit(self, user):
+        prev_attempts = user.get_tool_data('allura', 'multifactor_attempts') or []
+
+        num_allowed = asint(config.get('auth.multifactor.rate_limit.num', 3))
+        time_allowed = asint(config.get('auth.multifactor.rate_limit.time', 30))
+
+        ok, attempts_in_limit = check_rate_limit(num_allowed, time_allowed, prev_attempts)
+
+        user.set_tool_data('allura', multifactor_attempts=attempts_in_limit)
+
+        if not ok:
+            raise MultifactorRateLimitError
+
+
+class MongodbTotpService(MongodbMultifactorCommon, TotpService):
     '''
     Store in TOTP keys in mongodb.
     '''
@@ -218,7 +261,9 @@
             if e.errno == errno.ENOENT:  # file doesn't exist
                 if autocreate:
                     gaf = GoogleAuthenticatorFile()
-                    gaf.options['RATE_LIMIT'] = '3 30'
+                    gaf.options['RATE_LIMIT'] = '{} {}'.format(
+                        asint(config.get('auth.multifactor.rate_limit.num', 3)),
+                        asint(config.get('auth.multifactor.rate_limit.time', 30)))
                     gaf.options['DISALLOW_REUSE'] = None
                     gaf.options['TOTP_AUTH'] = None
                     return gaf
@@ -231,8 +276,30 @@
         with open(self.config_file(user), 'w') as f:
             f.write(gaf.dump())
 
+    def enforce_rate_limit(self, user, existing_gaf=None):
+        if existing_gaf:
+            gaf = existing_gaf
+        else:
+            gaf = self.read_file(user)
+        if not gaf:
+            return
+        rate_limits = gaf.options['RATE_LIMIT'].split(' ')
+        num_allowed = int(rate_limits.pop(0))
+        time_allowed = int(rate_limits.pop(0))
+        prev_attempts = map(int, rate_limits)
 
-class GoogleAuthenticatorPamFilesystemTotpService(TotpService, GoogleAuthenticatorPamFilesystemMixin):
+        ok, attempts_in_limit = check_rate_limit(num_allowed, time_allowed, prev_attempts)
+
+        gaf.options['RATE_LIMIT'] = ' '.join(map(str, [num_allowed, time_allowed] + attempts_in_limit))
+
+        if not existing_gaf:
+            self.write_file(user, gaf)
+
+        if not ok:
+            raise MultifactorRateLimitError
+
+
+class GoogleAuthenticatorPamFilesystemTotpService(GoogleAuthenticatorPamFilesystemMixin, TotpService):
     '''
     Store in home directories, compatible with the TOTP PAM module for Google Authenticator
     https://github.com/google/google-authenticator/tree/master/libpam
@@ -315,14 +382,17 @@
 
     def verify_and_remove_code(self, user, code):
         '''
+        Verify and remove recovery codes.  Also check for rate limiting.
+
         :param user: a :class:`User <allura.model.auth.User>`
         :param code: str
         :raises: InvalidRecoveryCode
+        :raises: MultifactorRateLimitError
         '''
         raise NotImplementedError('verify_and_remove_code')
 
 
-class MongodbRecoveryCodeService(RecoveryCodeService):
+class MongodbRecoveryCodeService(MongodbMultifactorCommon, RecoveryCodeService):
 
     def get_codes(self, user):
         return [rc.code for rc in
@@ -335,6 +405,7 @@
             session(rc).flush(rc)
 
     def verify_and_remove_code(self, user, code):
+        self.enforce_rate_limit(user)
         rc = RecoveryCode.query.get(user_id=user._id, code=code)
         if rc:
             rc.query.delete()
@@ -344,7 +415,7 @@
             raise InvalidRecoveryCode
 
 
-class GoogleAuthenticatorPamFilesystemRecoveryCodeService(RecoveryCodeService, GoogleAuthenticatorPamFilesystemMixin):
+class GoogleAuthenticatorPamFilesystemRecoveryCodeService(GoogleAuthenticatorPamFilesystemMixin, RecoveryCodeService):
 
     def get_codes(self, user):
         gaf = self.read_file(user)
@@ -363,8 +434,13 @@
 
     def verify_and_remove_code(self, user, code):
         gaf = self.read_file(user)
-        if gaf and code in gaf.recovery_codes:
-            gaf.recovery_codes.remove(code)
-            self.write_file(user, gaf)
-            return True
+        if gaf:
+            try:
+                self.enforce_rate_limit(user, gaf)
+                if code in gaf.recovery_codes:
+                    gaf.recovery_codes.remove(code)
+                    return True
+            finally:
+                # write both rate limit & recovery code changes
+                self.write_file(user, gaf)
         raise InvalidRecoveryCode
diff --git a/Allura/allura/tests/functional/test_auth.py b/Allura/allura/tests/functional/test_auth.py
index 2b957b9..8d09d8c 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -2204,6 +2204,34 @@
         assert_equal(r.session['username'], 'test-admin')
         assert r.location.endswith('/p/foo'), r
 
+    def test_login_rate_limit(self):
+        self._init_totp()
+
+        # so test-admin isn't automatically logged in for all requests
+        self.app.extra_environ = {'disable_auth_magic': 'True'}
+
+        # regular login
+        r = self.app.get('/auth/?return_to=/p/foo')
+        r.form['username'] = 'test-admin'
+        r.form['password'] = 'foo'
+        r = r.form.submit()
+        r = r.follow()
+
+        # try some invalid codes
+        for i in xrange(3):
+            r.form['code'] = 'invalid-code'
+            r = r.form.submit()
+            assert_in('Invalid code', r)
+
+        # use a valid code, but it'll hit rate limit
+        totp = TotpService().Totp(self.sample_key)
+        code = totp.generate(time_time())
+        r.form['code'] = code
+        r = r.form.submit()
+
+        assert_in('rate limit exceeded', r)
+        assert not r.session.get('username')
+
     def test_login_totp_disrupted(self):
         self._init_totp()
 
diff --git a/Allura/allura/tests/test_multifactor.py b/Allura/allura/tests/test_multifactor.py
index 3c7bb03..6b28c79 100644
--- a/Allura/allura/tests/test_multifactor.py
+++ b/Allura/allura/tests/test_multifactor.py
@@ -19,15 +19,15 @@
 import os
 
 import bson
-from allura.lib.exceptions import InvalidRecoveryCode
 from paste.deploy.converters import asint
-
 import ming
 from cryptography.hazmat.primitives.twofactor import InvalidToken
 from mock import patch, Mock
 from nose.tools import assert_equal, assert_raises
 from tg import config
 
+from allura import model as M
+from allura.lib.exceptions import InvalidRecoveryCode, MultifactorRateLimitError
 from allura.lib.multifactor import GoogleAuthenticatorFile, TotpService, MongodbTotpService
 from allura.lib.multifactor import GoogleAuthenticatorPamFilesystemTotpService
 from allura.lib.multifactor import RecoveryCodeService, MongodbRecoveryCodeService
@@ -75,6 +75,11 @@
         assert_equal(gaf.dump(), self.sample2)
 
 
+class GenericTotpService(TotpService):
+    def enforce_rate_limit(self, *args, **kwargs):
+        pass
+
+
 class TestTotpService(object):
 
     sample_key = b'\x00K\xda\xbfv\xc2B\xaa\x1a\xbe\xa5\x96b\xb2\xa0Z:\xc9\xcf\x8a'
@@ -88,28 +93,28 @@
     @patch('allura.lib.multifactor.time')
     def test_verify_types(self, time):
         time.return_value = self.sample_time
-        srv = TotpService()
+        srv = GenericTotpService()
         totp = srv.Totp(key=self.sample_key)
-        srv.verify(totp, u'283 397')
-        srv.verify(totp, b'283397')
+        srv.verify(totp, u'283 397', None)
+        srv.verify(totp, b'283397', None)
 
     @patch('allura.lib.multifactor.time')
     def test_verify_window(self, time):
         time.return_value = self.sample_time
-        srv = TotpService()
+        srv = GenericTotpService()
         totp = srv.Totp(key=self.sample_key)
-        srv.verify(totp, b'283397')
+        srv.verify(totp, b'283397', None)
 
         time.return_value = self.sample_time + 30
-        srv.verify(totp, b'283397')
+        srv.verify(totp, b'283397', None)
 
         time.return_value = self.sample_time + 60
         with assert_raises(InvalidToken):
-            srv.verify(totp, b'283397')
+            srv.verify(totp, b'283397', None)
 
         time.return_value = self.sample_time - 30
         with assert_raises(InvalidToken):
-            srv.verify(totp, b'283397')
+            srv.verify(totp, b'283397', None)
 
     def test_get_qr_code(self):
         srv = TotpService()
@@ -119,8 +124,57 @@
         assert srv.get_qr_code(totp, user)
 
 
-class TestMongodbTotpService():
+class TestAnyTotpServiceImplementation(object):
+
+    __test__ = False
+
     sample_key = b'\x00K\xda\xbfv\xc2B\xaa\x1a\xbe\xa5\x96b\xb2\xa0Z:\xc9\xcf\x8a'
+    sample_time = 1472502664
+    # these generate code 283397
+
+    def mock_user(self):
+        return M.User(username='some-user-guy')
+
+    def test_none(self):
+        srv = self.Service()
+        user = self.mock_user()
+        assert_equal(None, srv.get_secret_key(user))
+
+    def test_set_get(self):
+        srv = self.Service()
+        user = self.mock_user()
+        srv.set_secret_key(user, self.sample_key)
+        assert_equal(self.sample_key, srv.get_secret_key(user))
+
+    def test_delete(self):
+        srv = self.Service()
+        user = self.mock_user()
+        srv.set_secret_key(user, self.sample_key)
+        assert_equal(self.sample_key, srv.get_secret_key(user))
+        srv.set_secret_key(user, None)
+        assert_equal(None, srv.get_secret_key(user))
+
+    @patch('allura.lib.multifactor.time')
+    def test_rate_limiting(self, time):
+        time.return_value = self.sample_time
+        srv = self.Service()
+        user = self.mock_user()
+        totp = srv.Totp(key=self.sample_key)
+
+        # 4th attempt (good or bad) will trip over the default limit of 3 in 30s
+        with assert_raises(InvalidToken):
+            srv.verify(totp, b'34dfvdasf', user)
+        with assert_raises(InvalidToken):
+            srv.verify(totp, b'234asdfsadf', user)
+        srv.verify(totp, b'283397', user)
+        with assert_raises(MultifactorRateLimitError):
+            srv.verify(totp, b'283397', user)
+
+
+class TestMongodbTotpService(TestAnyTotpServiceImplementation):
+
+    __test__ = True
+    Service = MongodbTotpService
 
     def setUp(self):
         config = {
@@ -128,31 +182,6 @@
         }
         ming.configure(**config)
 
-    def test_none(self):
-        srv = MongodbTotpService()
-        user = Mock(_id=bson.ObjectId(),
-                    is_anonymous=lambda: False,
-                    )
-        assert_equal(None, srv.get_secret_key(user))
-
-    def test_set_get(self):
-        srv = MongodbTotpService()
-        user = Mock(_id=bson.ObjectId(),
-                    is_anonymous=lambda: False,
-                    )
-        srv.set_secret_key(user, self.sample_key)
-        assert_equal(self.sample_key, srv.get_secret_key(user))
-
-    def test_delete(self):
-        srv = MongodbTotpService()
-        user = Mock(_id=bson.ObjectId(),
-                    is_anonymous=lambda: False,
-                    )
-        srv.set_secret_key(user, self.sample_key)
-        assert_equal(self.sample_key, srv.get_secret_key(user))
-        srv.set_secret_key(user, None)
-        assert_equal(None, srv.get_secret_key(user))
-
 
 class TestGoogleAuthenticatorPamFilesystemMixin(object):
 
@@ -165,28 +194,17 @@
             shutil.rmtree(self.totp_basedir)
 
 
-class TestGoogleAuthenticatorPamFilesystemTotpService(TestGoogleAuthenticatorPamFilesystemMixin):
+class TestGoogleAuthenticatorPamFilesystemTotpService(TestAnyTotpServiceImplementation,
+                                                      TestGoogleAuthenticatorPamFilesystemMixin):
 
-    sample_key = b'\x00K\xda\xbfv\xc2B\xaa\x1a\xbe\xa5\x96b\xb2\xa0Z:\xc9\xcf\x8a'
+    __test__ = True
+    Service = GoogleAuthenticatorPamFilesystemTotpService
 
-    def test_none(self):
-        srv = GoogleAuthenticatorPamFilesystemTotpService()
-        user = Mock(username='some-user-guy')
-        assert_equal(None, srv.get_secret_key(user))
-
-    def test_set_get(self):
-        srv = GoogleAuthenticatorPamFilesystemTotpService()
-        user = Mock(username='some-user-guy')
-        srv.set_secret_key(user, self.sample_key)
-        assert_equal(self.sample_key, srv.get_secret_key(user))
-
-    def test_delete(self):
-        srv = GoogleAuthenticatorPamFilesystemTotpService()
-        user = Mock(username='some-user-guy')
-        srv.set_secret_key(user, self.sample_key)
-        assert_equal(self.sample_key, srv.get_secret_key(user))
-        srv.set_secret_key(user, None)
-        assert_equal(None, srv.get_secret_key(user))
+    def test_rate_limiting(self):
+        # make a regular .google-authenticator file first, so rate limit info has somewhere to go
+        self.Service().set_secret_key(self.mock_user(), self.sample_key)
+        # then run test
+        super(TestGoogleAuthenticatorPamFilesystemTotpService, self).test_rate_limiting()
 
 
 class TestRecoveryCodeService(object):
@@ -215,6 +233,9 @@
 
     __test__ = False
 
+    def mock_user(self):
+        return M.User(username='some-user-guy')
+
     def test_get_codes_none(self):
         recovery = self.Service()
         user = self.mock_user()
@@ -256,6 +277,24 @@
         assert_equal(result, True)
         assert_equal(recovery.get_codes(user), ['67890'])
 
+    def test_rate_limiting(self):
+        recovery = self.Service()
+        user = self.mock_user()
+        codes = [
+            '11111',
+            '22222',
+        ]
+        recovery.replace_codes(user, codes)
+
+        # 4th attempt (good or bad) will trip over the default limit of 3 in 30s
+        with assert_raises(InvalidRecoveryCode):
+            recovery.verify_and_remove_code(user, '13485u0233')
+        with assert_raises(InvalidRecoveryCode):
+            recovery.verify_and_remove_code(user, '34123rdxafs')
+        recovery.verify_and_remove_code(user, '11111')
+        with assert_raises(MultifactorRateLimitError):
+            recovery.verify_and_remove_code(user, '22222')
+
 
 class TestMongodbRecoveryCodeService(TestAnyRecoveryCodeServiceImplementation):
 
@@ -269,9 +308,6 @@
         }
         ming.configure(**config)
 
-    def mock_user(self):
-        return Mock(_id=bson.ObjectId())
-
 
 class TestGoogleAuthenticatorPamFilesystemRecoveryCodeService(TestAnyRecoveryCodeServiceImplementation,
                                                               TestGoogleAuthenticatorPamFilesystemMixin):
@@ -280,9 +316,6 @@
 
     Service = GoogleAuthenticatorPamFilesystemRecoveryCodeService
 
-    def mock_user(self):
-        return Mock(username='some-user-guy')
-
     def setUp(self):
         super(TestGoogleAuthenticatorPamFilesystemRecoveryCodeService, self).setUp()