Support "identifier" dicts in UIA (#8848)

The spec requires synapse to support `identifier` dicts for `m.login.password`
user-interactive auth, which it did not (instead, it required an undocumented
`user` parameter.)

To fix this properly, we need to pull the code that interprets `identifier`
into `AuthHandler.validate_login` so that it can be called from the UIA code.

Fixes #5665.
This commit is contained in:
Richard van der Hoff 2020-12-01 17:42:26 +00:00 committed by GitHub
parent 9edff901d1
commit 4d9496559d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 191 additions and 148 deletions

1
changelog.d/8848.bugfix Normal file
View File

@ -0,0 +1 @@
Fix a long-standing bug which caused Synapse to require unspecified parameters during user-interactive authentication.

View File

@ -238,6 +238,13 @@ class AuthHandler(BaseHandler):
burst_count=self.hs.config.rc_login_failed_attempts.burst_count, burst_count=self.hs.config.rc_login_failed_attempts.burst_count,
) )
# Ratelimitier for failed /login attempts
self._failed_login_attempts_ratelimiter = Ratelimiter(
clock=hs.get_clock(),
rate_hz=self.hs.config.rc_login_failed_attempts.per_second,
burst_count=self.hs.config.rc_login_failed_attempts.burst_count,
)
self._clock = self.hs.get_clock() self._clock = self.hs.get_clock()
# Expire old UI auth sessions after a period of time. # Expire old UI auth sessions after a period of time.
@ -650,14 +657,8 @@ class AuthHandler(BaseHandler):
res = await checker.check_auth(authdict, clientip=clientip) res = await checker.check_auth(authdict, clientip=clientip)
return res return res
# build a v1-login-style dict out of the authdict and fall back to the # fall back to the v1 login flow
# v1 code canonical_id, _ = await self.validate_login(authdict)
user_id = authdict.get("user")
if user_id is None:
raise SynapseError(400, "", Codes.MISSING_PARAM)
(canonical_id, callback) = await self.validate_login(user_id, authdict)
return canonical_id return canonical_id
def _get_params_recaptcha(self) -> dict: def _get_params_recaptcha(self) -> dict:
@ -832,15 +833,155 @@ class AuthHandler(BaseHandler):
return self._supported_login_types return self._supported_login_types
async def validate_login( async def validate_login(
self, username: str, login_submission: Dict[str, Any] self, login_submission: Dict[str, Any], ratelimit: bool = False,
) -> Tuple[str, Optional[Callable[[Dict[str, str]], None]]]: ) -> Tuple[str, Optional[Callable[[Dict[str, str]], None]]]:
"""Authenticates the user for the /login API """Authenticates the user for the /login API
Also used by the user-interactive auth flow to validate Also used by the user-interactive auth flow to validate auth types which don't
m.login.password auth types. have an explicit UIA handler, including m.password.auth.
Args: Args:
username: username supplied by the user login_submission: the whole of the login submission
(including 'type' and other relevant fields)
ratelimit: whether to apply the failed_login_attempt ratelimiter
Returns:
A tuple of the canonical user id, and optional callback
to be called once the access token and device id are issued
Raises:
StoreError if there was a problem accessing the database
SynapseError if there was a problem with the request
LoginError if there was an authentication problem.
"""
login_type = login_submission.get("type")
# ideally, we wouldn't be checking the identifier unless we know we have a login
# method which uses it (https://github.com/matrix-org/synapse/issues/8836)
#
# But the auth providers' check_auth interface requires a username, so in
# practice we can only support login methods which we can map to a username
# anyway.
# special case to check for "password" for the check_password interface
# for the auth providers
password = login_submission.get("password")
if login_type == LoginType.PASSWORD:
if not self._password_enabled:
raise SynapseError(400, "Password login has been disabled.")
if not isinstance(password, str):
raise SynapseError(400, "Bad parameter: password", Codes.INVALID_PARAM)
# map old-school login fields into new-school "identifier" fields.
identifier_dict = convert_client_dict_legacy_fields_to_identifier(
login_submission
)
# convert phone type identifiers to generic threepids
if identifier_dict["type"] == "m.id.phone":
identifier_dict = login_id_phone_to_thirdparty(identifier_dict)
# convert threepid identifiers to user IDs
if identifier_dict["type"] == "m.id.thirdparty":
address = identifier_dict.get("address")
medium = identifier_dict.get("medium")
if medium is None or address is None:
raise SynapseError(400, "Invalid thirdparty identifier")
# For emails, canonicalise the address.
# We store all email addresses canonicalised in the DB.
# (See add_threepid in synapse/handlers/auth.py)
if medium == "email":
try:
address = canonicalise_email(address)
except ValueError as e:
raise SynapseError(400, str(e))
# We also apply account rate limiting using the 3PID as a key, as
# otherwise using 3PID bypasses the ratelimiting based on user ID.
if ratelimit:
self._failed_login_attempts_ratelimiter.ratelimit(
(medium, address), update=False
)
# Check for login providers that support 3pid login types
if login_type == LoginType.PASSWORD:
# we've already checked that there is a (valid) password field
assert isinstance(password, str)
(
canonical_user_id,
callback_3pid,
) = await self.check_password_provider_3pid(medium, address, password)
if canonical_user_id:
# Authentication through password provider and 3pid succeeded
return canonical_user_id, callback_3pid
# No password providers were able to handle this 3pid
# Check local store
user_id = await self.hs.get_datastore().get_user_id_by_threepid(
medium, address
)
if not user_id:
logger.warning(
"unknown 3pid identifier medium %s, address %r", medium, address
)
# We mark that we've failed to log in here, as
# `check_password_provider_3pid` might have returned `None` due
# to an incorrect password, rather than the account not
# existing.
#
# If it returned None but the 3PID was bound then we won't hit
# this code path, which is fine as then the per-user ratelimit
# will kick in below.
if ratelimit:
self._failed_login_attempts_ratelimiter.can_do_action(
(medium, address)
)
raise LoginError(403, "", errcode=Codes.FORBIDDEN)
identifier_dict = {"type": "m.id.user", "user": user_id}
# by this point, the identifier should be an m.id.user: if it's anything
# else, we haven't understood it.
if identifier_dict["type"] != "m.id.user":
raise SynapseError(400, "Unknown login identifier type")
username = identifier_dict.get("user")
if not username:
raise SynapseError(400, "User identifier is missing 'user' key")
if username.startswith("@"):
qualified_user_id = username
else:
qualified_user_id = UserID(username, self.hs.hostname).to_string()
# Check if we've hit the failed ratelimit (but don't update it)
if ratelimit:
self._failed_login_attempts_ratelimiter.ratelimit(
qualified_user_id.lower(), update=False
)
try:
return await self._validate_userid_login(username, login_submission)
except LoginError:
# The user has failed to log in, so we need to update the rate
# limiter. Using `can_do_action` avoids us raising a ratelimit
# exception and masking the LoginError. The actual ratelimiting
# should have happened above.
if ratelimit:
self._failed_login_attempts_ratelimiter.can_do_action(
qualified_user_id.lower()
)
raise
async def _validate_userid_login(
self, username: str, login_submission: Dict[str, Any],
) -> Tuple[str, Optional[Callable[[Dict[str, str]], None]]]:
"""Helper for validate_login
Handles login, once we've mapped 3pids onto userids
Args:
username: the username, from the identifier dict
login_submission: the whole of the login submission login_submission: the whole of the login submission
(including 'type' and other relevant fields) (including 'type' and other relevant fields)
Returns: Returns:
@ -851,7 +992,6 @@ class AuthHandler(BaseHandler):
SynapseError if there was a problem with the request SynapseError if there was a problem with the request
LoginError if there was an authentication problem. LoginError if there was an authentication problem.
""" """
if username.startswith("@"): if username.startswith("@"):
qualified_user_id = username qualified_user_id = username
else: else:
@ -860,20 +1000,13 @@ class AuthHandler(BaseHandler):
login_type = login_submission.get("type") login_type = login_submission.get("type")
known_login_type = False known_login_type = False
# special case to check for "password" for the check_password interface
# for the auth providers
password = login_submission.get("password")
if login_type == LoginType.PASSWORD:
if not self._password_enabled:
raise SynapseError(400, "Password login has been disabled.")
if not password:
raise SynapseError(400, "Missing parameter: password")
for provider in self.password_providers: for provider in self.password_providers:
if hasattr(provider, "check_password") and login_type == LoginType.PASSWORD: if hasattr(provider, "check_password") and login_type == LoginType.PASSWORD:
known_login_type = True known_login_type = True
is_valid = await provider.check_password(qualified_user_id, password) # we've already checked that there is a (valid) password field
is_valid = await provider.check_password(
qualified_user_id, login_submission["password"]
)
if is_valid: if is_valid:
return qualified_user_id, None return qualified_user_id, None
@ -914,8 +1047,12 @@ class AuthHandler(BaseHandler):
if login_type == LoginType.PASSWORD and self.hs.config.password_localdb_enabled: if login_type == LoginType.PASSWORD and self.hs.config.password_localdb_enabled:
known_login_type = True known_login_type = True
# we've already checked that there is a (valid) password field
password = login_submission["password"]
assert isinstance(password, str)
canonical_user_id = await self._check_local_password( canonical_user_id = await self._check_local_password(
qualified_user_id, password # type: ignore qualified_user_id, password
) )
if canonical_user_id: if canonical_user_id:

View File

@ -19,10 +19,6 @@ from typing import Awaitable, Callable, Dict, Optional
from synapse.api.errors import Codes, LoginError, SynapseError from synapse.api.errors import Codes, LoginError, SynapseError
from synapse.api.ratelimiting import Ratelimiter from synapse.api.ratelimiting import Ratelimiter
from synapse.appservice import ApplicationService from synapse.appservice import ApplicationService
from synapse.handlers.auth import (
convert_client_dict_legacy_fields_to_identifier,
login_id_phone_to_thirdparty,
)
from synapse.http.server import finish_request from synapse.http.server import finish_request
from synapse.http.servlet import ( from synapse.http.servlet import (
RestServlet, RestServlet,
@ -33,7 +29,6 @@ from synapse.http.site import SynapseRequest
from synapse.rest.client.v2_alpha._base import client_patterns from synapse.rest.client.v2_alpha._base import client_patterns
from synapse.rest.well_known import WellKnownBuilder from synapse.rest.well_known import WellKnownBuilder
from synapse.types import JsonDict, UserID from synapse.types import JsonDict, UserID
from synapse.util.threepids import canonicalise_email
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -78,11 +73,6 @@ class LoginRestServlet(RestServlet):
rate_hz=self.hs.config.rc_login_account.per_second, rate_hz=self.hs.config.rc_login_account.per_second,
burst_count=self.hs.config.rc_login_account.burst_count, burst_count=self.hs.config.rc_login_account.burst_count,
) )
self._failed_attempts_ratelimiter = Ratelimiter(
clock=hs.get_clock(),
rate_hz=self.hs.config.rc_login_failed_attempts.per_second,
burst_count=self.hs.config.rc_login_failed_attempts.burst_count,
)
def on_GET(self, request: SynapseRequest): def on_GET(self, request: SynapseRequest):
flows = [] flows = []
@ -140,17 +130,6 @@ class LoginRestServlet(RestServlet):
result["well_known"] = well_known_data result["well_known"] = well_known_data
return 200, result return 200, result
def _get_qualified_user_id(self, identifier):
if identifier["type"] != "m.id.user":
raise SynapseError(400, "Unknown login identifier type")
if "user" not in identifier:
raise SynapseError(400, "User identifier is missing 'user' key")
if identifier["user"].startswith("@"):
return identifier["user"]
else:
return UserID(identifier["user"], self.hs.hostname).to_string()
async def _do_appservice_login( async def _do_appservice_login(
self, login_submission: JsonDict, appservice: ApplicationService self, login_submission: JsonDict, appservice: ApplicationService
): ):
@ -201,91 +180,9 @@ class LoginRestServlet(RestServlet):
login_submission.get("address"), login_submission.get("address"),
login_submission.get("user"), login_submission.get("user"),
) )
identifier = convert_client_dict_legacy_fields_to_identifier(login_submission)
# convert phone type identifiers to generic threepids
if identifier["type"] == "m.id.phone":
identifier = login_id_phone_to_thirdparty(identifier)
# convert threepid identifiers to user IDs
if identifier["type"] == "m.id.thirdparty":
address = identifier.get("address")
medium = identifier.get("medium")
if medium is None or address is None:
raise SynapseError(400, "Invalid thirdparty identifier")
# For emails, canonicalise the address.
# We store all email addresses canonicalised in the DB.
# (See add_threepid in synapse/handlers/auth.py)
if medium == "email":
try:
address = canonicalise_email(address)
except ValueError as e:
raise SynapseError(400, str(e))
# We also apply account rate limiting using the 3PID as a key, as
# otherwise using 3PID bypasses the ratelimiting based on user ID.
self._failed_attempts_ratelimiter.ratelimit((medium, address), update=False)
# Check for login providers that support 3pid login types
(
canonical_user_id,
callback_3pid,
) = await self.auth_handler.check_password_provider_3pid(
medium, address, login_submission["password"]
)
if canonical_user_id:
# Authentication through password provider and 3pid succeeded
result = await self._complete_login(
canonical_user_id, login_submission, callback_3pid
)
return result
# No password providers were able to handle this 3pid
# Check local store
user_id = await self.hs.get_datastore().get_user_id_by_threepid(
medium, address
)
if not user_id:
logger.warning(
"unknown 3pid identifier medium %s, address %r", medium, address
)
# We mark that we've failed to log in here, as
# `check_password_provider_3pid` might have returned `None` due
# to an incorrect password, rather than the account not
# existing.
#
# If it returned None but the 3PID was bound then we won't hit
# this code path, which is fine as then the per-user ratelimit
# will kick in below.
self._failed_attempts_ratelimiter.can_do_action((medium, address))
raise LoginError(403, "", errcode=Codes.FORBIDDEN)
identifier = {"type": "m.id.user", "user": user_id}
# by this point, the identifier should be an m.id.user: if it's anything
# else, we haven't understood it.
qualified_user_id = self._get_qualified_user_id(identifier)
# Check if we've hit the failed ratelimit (but don't update it)
self._failed_attempts_ratelimiter.ratelimit(
qualified_user_id.lower(), update=False
)
try:
canonical_user_id, callback = await self.auth_handler.validate_login( canonical_user_id, callback = await self.auth_handler.validate_login(
identifier["user"], login_submission login_submission, ratelimit=True
) )
except LoginError:
# The user has failed to log in, so we need to update the rate
# limiter. Using `can_do_action` avoids us raising a ratelimit
# exception and masking the LoginError. The actual ratelimiting
# should have happened above.
self._failed_attempts_ratelimiter.can_do_action(qualified_user_id.lower())
raise
result = await self._complete_login( result = await self._complete_login(
canonical_user_id, login_submission, callback canonical_user_id, login_submission, callback
) )

View File

@ -358,9 +358,6 @@ class PasswordAuthProviderTests(unittest.HomeserverTestCase):
"auth": { "auth": {
"type": "test.login_type", "type": "test.login_type",
"identifier": {"type": "m.id.user", "user": "localuser"}, "identifier": {"type": "m.id.user", "user": "localuser"},
# FIXME "identifier" is ignored
# https://github.com/matrix-org/synapse/issues/5665
"user": "localuser",
"session": session, "session": session,
}, },
} }
@ -489,9 +486,6 @@ class PasswordAuthProviderTests(unittest.HomeserverTestCase):
"auth": { "auth": {
"type": "m.login.password", "type": "m.login.password",
"identifier": {"type": "m.id.user", "user": "localuser"}, "identifier": {"type": "m.id.user", "user": "localuser"},
# FIXME "identifier" is ignored
# https://github.com/matrix-org/synapse/issues/5665
"user": "localuser",
"password": "localpass", "password": "localpass",
"session": session, "session": session,
}, },
@ -541,7 +535,7 @@ class PasswordAuthProviderTests(unittest.HomeserverTestCase):
return self._send_login(type="m.login.password", user=user, password=password) return self._send_login(type="m.login.password", user=user, password=password)
def _send_login(self, type, user, **params) -> FakeChannel: def _send_login(self, type, user, **params) -> FakeChannel:
params.update({"user": user, "type": type}) params.update({"identifier": {"type": "m.id.user", "user": user}, "type": type})
_, channel = self.make_request("POST", "/_matrix/client/r0/login", params) _, channel = self.make_request("POST", "/_matrix/client/r0/login", params)
return channel return channel
@ -569,9 +563,6 @@ class PasswordAuthProviderTests(unittest.HomeserverTestCase):
"auth": { "auth": {
"type": "m.login.password", "type": "m.login.password",
"identifier": {"type": "m.id.user", "user": user_id}, "identifier": {"type": "m.id.user", "user": user_id},
# FIXME "identifier" is ignored
# https://github.com/matrix-org/synapse/issues/5665
"user": user_id,
"password": password, "password": password,
"session": session, "session": session,
}, },

View File

@ -38,11 +38,6 @@ class DummyRecaptchaChecker(UserInteractiveAuthChecker):
return succeed(True) return succeed(True)
class DummyPasswordChecker(UserInteractiveAuthChecker):
def check_auth(self, authdict, clientip):
return succeed(authdict["identifier"]["user"])
class FallbackAuthTests(unittest.HomeserverTestCase): class FallbackAuthTests(unittest.HomeserverTestCase):
servlets = [ servlets = [
@ -162,9 +157,6 @@ class UIAuthTests(unittest.HomeserverTestCase):
] ]
def prepare(self, reactor, clock, hs): def prepare(self, reactor, clock, hs):
auth_handler = hs.get_auth_handler()
auth_handler.checkers[LoginType.PASSWORD] = DummyPasswordChecker(hs)
self.user_pass = "pass" self.user_pass = "pass"
self.user = self.register_user("test", self.user_pass) self.user = self.register_user("test", self.user_pass)
self.user_tok = self.login("test", self.user_pass) self.user_tok = self.login("test", self.user_pass)
@ -234,6 +226,31 @@ class UIAuthTests(unittest.HomeserverTestCase):
}, },
) )
def test_grandfathered_identifier(self):
"""Check behaviour without "identifier" dict
Synapse used to require clients to submit a "user" field for m.login.password
UIA - check that still works.
"""
device_id = self.get_device_ids()[0]
channel = self.delete_device(device_id, 401)
session = channel.json_body["session"]
# Make another request providing the UI auth flow.
self.delete_device(
device_id,
200,
{
"auth": {
"type": "m.login.password",
"user": self.user,
"password": self.user_pass,
"session": session,
},
},
)
def test_can_change_body(self): def test_can_change_body(self):
""" """
The client dict can be modified during the user interactive authentication session. The client dict can be modified during the user interactive authentication session.