summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--changelog.d/4865.feature1
-rw-r--r--docs/sample_config.yaml6
-rw-r--r--synapse/config/ratelimiting.py9
-rw-r--r--synapse/handlers/auth.py28
-rw-r--r--tests/rest/client/v1/test_login.py45
-rw-r--r--tests/utils.py2
6 files changed, 86 insertions, 5 deletions
diff --git a/changelog.d/4865.feature b/changelog.d/4865.feature
new file mode 100644
index 0000000000..61d4eb8d60
--- /dev/null
+++ b/changelog.d/4865.feature
@@ -0,0 +1 @@
+Add configurable rate limiting to the /login endpoint.
diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml
index b3df272c54..84e2cc97f9 100644
--- a/docs/sample_config.yaml
+++ b/docs/sample_config.yaml
@@ -392,6 +392,9 @@ rc_message_burst_count: 10.0
 #     address.
 #   - one for login that ratelimits login requests based on the account the
 #     client is attempting to log into.
+#   - one for login that ratelimits login requests based on the account the
+#     client is attempting to log into, based on the amount of failed login
+#     attempts for this account.
 #
 # The defaults are as shown below.
 #
@@ -406,6 +409,9 @@ rc_message_burst_count: 10.0
 #  account:
 #    per_second: 0.17
 #    burst_count: 3
+#  failed_attempts:
+#    per_second: 0.17
+#    burst_count: 3
 
 # The federation window size in milliseconds
 #
diff --git a/synapse/config/ratelimiting.py b/synapse/config/ratelimiting.py
index 649f018356..7e6cc5d0ea 100644
--- a/synapse/config/ratelimiting.py
+++ b/synapse/config/ratelimiting.py
@@ -32,6 +32,9 @@ class RatelimitConfig(Config):
         rc_login_config = config.get("rc_login", {})
         self.rc_login_address = RateLimitConfig(rc_login_config.get("address", {}))
         self.rc_login_account = RateLimitConfig(rc_login_config.get("account", {}))
+        self.rc_login_failed_attempts = RateLimitConfig(
+            rc_login_config.get("failed_attempts", {}),
+        )
 
         self.federation_rc_window_size = config["federation_rc_window_size"]
         self.federation_rc_sleep_limit = config["federation_rc_sleep_limit"]
@@ -64,6 +67,9 @@ class RatelimitConfig(Config):
         #     address.
         #   - one for login that ratelimits login requests based on the account the
         #     client is attempting to log into.
+        #   - one for login that ratelimits login requests based on the account the
+        #     client is attempting to log into, based on the amount of failed login
+        #     attempts for this account.
         #
         # The defaults are as shown below.
         #
@@ -78,6 +84,9 @@ class RatelimitConfig(Config):
         #  account:
         #    per_second: 0.17
         #    burst_count: 3
+        #  failed_attempts:
+        #    per_second: 0.17
+        #    burst_count: 3
 
         # The federation window size in milliseconds
         #
diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py
index 74f3790f25..caad9ae2dd 100644
--- a/synapse/handlers/auth.py
+++ b/synapse/handlers/auth.py
@@ -101,6 +101,7 @@ class AuthHandler(BaseHandler):
         self._supported_login_types = login_types
 
         self._account_ratelimiter = Ratelimiter()
+        self._failed_attempts_ratelimiter = Ratelimiter()
 
         self._clock = self.hs.get_clock()
 
@@ -729,9 +730,16 @@ class AuthHandler(BaseHandler):
         if not known_login_type:
             raise SynapseError(400, "Unknown login type %s" % login_type)
 
-        # unknown username or invalid password. We raise a 403 here, but note
-        # that if we're doing user-interactive login, it turns all LoginErrors
-        # into a 401 anyway.
+        # unknown username or invalid password.
+        self._failed_attempts_ratelimiter.ratelimit(
+            qualified_user_id.lower(), time_now_s=self._clock.time(),
+            rate_hz=self.hs.config.rc_login_failed_attempts.per_second,
+            burst_count=self.hs.config.rc_login_failed_attempts.burst_count,
+            update=True,
+        )
+
+        # We raise a 403 here, but note that if we're doing user-interactive
+        # login, it turns all LoginErrors into a 401 anyway.
         raise LoginError(
             403, "Invalid password",
             errcode=Codes.FORBIDDEN
@@ -956,13 +964,23 @@ class AuthHandler(BaseHandler):
     def ratelimit_login_per_account(self, user_id):
         """Checks whether the process must be stopped because of ratelimiting.
 
+        Checks against two ratelimiters: the generic one for login attempts per
+        account and the one specific to failed attempts.
+
         Args:
             user_id (unicode): complete @user:id
 
         Raises:
-            LimitExceededError if the ratelimiter's login requests count for this
-                user is too high too proceed.
+            LimitExceededError if one of the ratelimiters' login requests count
+                for this user is too high too proceed.
         """
+        self._failed_attempts_ratelimiter.ratelimit(
+            user_id.lower(), time_now_s=self._clock.time(),
+            rate_hz=self.hs.config.rc_login_failed_attempts.per_second,
+            burst_count=self.hs.config.rc_login_failed_attempts.burst_count,
+            update=False,
+        )
+
         self._account_ratelimiter.ratelimit(
             user_id.lower(), time_now_s=self._clock.time(),
             rate_hz=self.hs.config.rc_login_account.per_second,
diff --git a/tests/rest/client/v1/test_login.py b/tests/rest/client/v1/test_login.py
index 4035f76cca..86312f1096 100644
--- a/tests/rest/client/v1/test_login.py
+++ b/tests/rest/client/v1/test_login.py
@@ -116,3 +116,48 @@ class LoginRestServletTestCase(unittest.HomeserverTestCase):
         self.render(request)
 
         self.assertEquals(channel.result["code"], b"200", channel.result)
+
+    def test_POST_ratelimiting_per_account_failed_attempts(self):
+        self.hs.config.rc_login_failed_attempts.burst_count = 5
+        self.hs.config.rc_login_failed_attempts.per_second = 0.17
+
+        self.register_user("kermit", "monkey")
+
+        for i in range(0, 6):
+            params = {
+                "type": "m.login.password",
+                "identifier": {
+                    "type": "m.id.user",
+                    "user": "kermit",
+                },
+                "password": "notamonkey",
+            }
+            request_data = json.dumps(params)
+            request, channel = self.make_request(b"POST", LOGIN_URL, request_data)
+            self.render(request)
+
+            if i == 5:
+                self.assertEquals(channel.result["code"], b"429", channel.result)
+                retry_after_ms = int(channel.json_body["retry_after_ms"])
+            else:
+                self.assertEquals(channel.result["code"], b"403", channel.result)
+
+        # Since we're ratelimiting at 1 request/min, retry_after_ms should be lower
+        # than 1min.
+        self.assertTrue(retry_after_ms < 6000)
+
+        self.reactor.advance(retry_after_ms / 1000.)
+
+        params = {
+            "type": "m.login.password",
+            "identifier": {
+                "type": "m.id.user",
+                "user": "kermit",
+            },
+            "password": "notamonkey",
+        }
+        request_data = json.dumps(params)
+        request, channel = self.make_request(b"POST", LOGIN_URL, params)
+        self.render(request)
+
+        self.assertEquals(channel.result["code"], b"403", channel.result)
diff --git a/tests/utils.py b/tests/utils.py
index a412736492..b58b674aa4 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -157,6 +157,8 @@ def default_config(name):
     config.rc_login_address.burst_count = 10000
     config.rc_login_account.per_second = 10000
     config.rc_login_account.burst_count = 10000
+    config.rc_login_failed_attempts.per_second = 10000
+    config.rc_login_failed_attempts.burst_count = 10000
     config.saml2_enabled = False
     config.public_baseurl = None
     config.default_identity_server = None