mirror of
https://mau.dev/maunium/synapse.git
synced 2024-11-16 06:51:46 +01:00
Add ratelimiting on failed login attempts (#4865)
This commit is contained in:
parent
899e523d6d
commit
651ad8bc96
6 changed files with 86 additions and 5 deletions
1
changelog.d/4865.feature
Normal file
1
changelog.d/4865.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add configurable rate limiting to the /login endpoint.
|
|
@ -392,6 +392,9 @@ rc_message_burst_count: 10.0
|
||||||
# address.
|
# address.
|
||||||
# - one for login that ratelimits login requests based on the account the
|
# - one for login that ratelimits login requests based on the account the
|
||||||
# client is attempting to log into.
|
# 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.
|
# The defaults are as shown below.
|
||||||
#
|
#
|
||||||
|
@ -406,6 +409,9 @@ rc_message_burst_count: 10.0
|
||||||
# account:
|
# account:
|
||||||
# per_second: 0.17
|
# per_second: 0.17
|
||||||
# burst_count: 3
|
# burst_count: 3
|
||||||
|
# failed_attempts:
|
||||||
|
# per_second: 0.17
|
||||||
|
# burst_count: 3
|
||||||
|
|
||||||
# The federation window size in milliseconds
|
# The federation window size in milliseconds
|
||||||
#
|
#
|
||||||
|
|
|
@ -32,6 +32,9 @@ class RatelimitConfig(Config):
|
||||||
rc_login_config = config.get("rc_login", {})
|
rc_login_config = config.get("rc_login", {})
|
||||||
self.rc_login_address = RateLimitConfig(rc_login_config.get("address", {}))
|
self.rc_login_address = RateLimitConfig(rc_login_config.get("address", {}))
|
||||||
self.rc_login_account = RateLimitConfig(rc_login_config.get("account", {}))
|
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_window_size = config["federation_rc_window_size"]
|
||||||
self.federation_rc_sleep_limit = config["federation_rc_sleep_limit"]
|
self.federation_rc_sleep_limit = config["federation_rc_sleep_limit"]
|
||||||
|
@ -64,6 +67,9 @@ class RatelimitConfig(Config):
|
||||||
# address.
|
# address.
|
||||||
# - one for login that ratelimits login requests based on the account the
|
# - one for login that ratelimits login requests based on the account the
|
||||||
# client is attempting to log into.
|
# 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.
|
# The defaults are as shown below.
|
||||||
#
|
#
|
||||||
|
@ -78,6 +84,9 @@ class RatelimitConfig(Config):
|
||||||
# account:
|
# account:
|
||||||
# per_second: 0.17
|
# per_second: 0.17
|
||||||
# burst_count: 3
|
# burst_count: 3
|
||||||
|
# failed_attempts:
|
||||||
|
# per_second: 0.17
|
||||||
|
# burst_count: 3
|
||||||
|
|
||||||
# The federation window size in milliseconds
|
# The federation window size in milliseconds
|
||||||
#
|
#
|
||||||
|
|
|
@ -101,6 +101,7 @@ class AuthHandler(BaseHandler):
|
||||||
self._supported_login_types = login_types
|
self._supported_login_types = login_types
|
||||||
|
|
||||||
self._account_ratelimiter = Ratelimiter()
|
self._account_ratelimiter = Ratelimiter()
|
||||||
|
self._failed_attempts_ratelimiter = Ratelimiter()
|
||||||
|
|
||||||
self._clock = self.hs.get_clock()
|
self._clock = self.hs.get_clock()
|
||||||
|
|
||||||
|
@ -729,9 +730,16 @@ class AuthHandler(BaseHandler):
|
||||||
if not known_login_type:
|
if not known_login_type:
|
||||||
raise SynapseError(400, "Unknown login type %s" % login_type)
|
raise SynapseError(400, "Unknown login type %s" % login_type)
|
||||||
|
|
||||||
# unknown username or invalid password. We raise a 403 here, but note
|
# unknown username or invalid password.
|
||||||
# that if we're doing user-interactive login, it turns all LoginErrors
|
self._failed_attempts_ratelimiter.ratelimit(
|
||||||
# into a 401 anyway.
|
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(
|
raise LoginError(
|
||||||
403, "Invalid password",
|
403, "Invalid password",
|
||||||
errcode=Codes.FORBIDDEN
|
errcode=Codes.FORBIDDEN
|
||||||
|
@ -956,13 +964,23 @@ class AuthHandler(BaseHandler):
|
||||||
def ratelimit_login_per_account(self, user_id):
|
def ratelimit_login_per_account(self, user_id):
|
||||||
"""Checks whether the process must be stopped because of ratelimiting.
|
"""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:
|
Args:
|
||||||
user_id (unicode): complete @user:id
|
user_id (unicode): complete @user:id
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
LimitExceededError if the ratelimiter's login requests count for this
|
LimitExceededError if one of the ratelimiters' login requests count
|
||||||
user is too high too proceed.
|
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(
|
self._account_ratelimiter.ratelimit(
|
||||||
user_id.lower(), time_now_s=self._clock.time(),
|
user_id.lower(), time_now_s=self._clock.time(),
|
||||||
rate_hz=self.hs.config.rc_login_account.per_second,
|
rate_hz=self.hs.config.rc_login_account.per_second,
|
||||||
|
|
|
@ -116,3 +116,48 @@ class LoginRestServletTestCase(unittest.HomeserverTestCase):
|
||||||
self.render(request)
|
self.render(request)
|
||||||
|
|
||||||
self.assertEquals(channel.result["code"], b"200", channel.result)
|
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)
|
||||||
|
|
|
@ -157,6 +157,8 @@ def default_config(name):
|
||||||
config.rc_login_address.burst_count = 10000
|
config.rc_login_address.burst_count = 10000
|
||||||
config.rc_login_account.per_second = 10000
|
config.rc_login_account.per_second = 10000
|
||||||
config.rc_login_account.burst_count = 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.saml2_enabled = False
|
||||||
config.public_baseurl = None
|
config.public_baseurl = None
|
||||||
config.default_identity_server = None
|
config.default_identity_server = None
|
||||||
|
|
Loading…
Reference in a new issue