Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Allow for identifier dicts in User Interactive Auth dicts #7438

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,93 @@
from synapse.module_api import ModuleApi
from synapse.push.mailer import load_jinja2_templates
from synapse.types import Requester, UserID
from synapse.util.msisdn import phone_number_to_msisdn

from ._base import BaseHandler

logger = logging.getLogger(__name__)


def client_dict_convert_legacy_fields_to_identifier(
submission: Dict[str, Union[str, Dict]]
):
"""Take a legacy-formatted login submission or User-Interactive Authentication dict and
updates it to feature an identifier dict instead.
Providing user-identifying information at the top-level of a login or UIA submission is
now deprecated and replaced with identifiers:
https://matrix.org/docs/spec/client_server/r0.6.1#identifier-types
Args:
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
submission: The client dict to convert. Passed by reference and modified
Raises:
SynapseError: if the dict contains a "medium" parameter that is anything other than
"email"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment seems wrong? It raises if the format is invalid.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that was a check I added, as that's technically required by the spec for login although it's a bit of a weird requirement if identifier is allowed to be m.id.phone.

Will remove the comment though.

"""
if "user" in submission:
submission["identifier"] = {"type": "m.id.user", "user": submission["user"]}
del submission["user"]
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved

if "medium" in submission and "address" in submission:
submission["identifier"] = {
"type": "m.id.thirdparty",
"medium": submission["medium"],
"address": submission["address"],
}
del submission["medium"]
del submission["address"]

# We've converted valid, legacy login submissions to an identifier. If the
# dict still doesn't have an identifier, it's invalid
if "identifier" not in submission:
raise SynapseError(
400,
"Missing 'identifier' parameter in login submission",
errcode=Codes.MISSING_PARAM,
)
clokep marked this conversation as resolved.
Show resolved Hide resolved

# Ensure the identifier has a type
if "type" not in submission["identifier"]:
raise SynapseError(
400, "'identifier' dict has no key 'type'", errcode=Codes.MISSING_PARAM,
)


def login_id_phone_to_thirdparty(identifier: Dict[str, str]):
"""Convert a phone login identifier type to a generic threepid identifier. Modifies
the identifier dict in place
clokep marked this conversation as resolved.
Show resolved Hide resolved
Args:
identifier: Login identifier dict of type 'm.id.phone'
"""
if "type" not in identifier:
raise SynapseError(
400, "Invalid phone-type identifier", errcode=Codes.MISSING_PARAM
)

if "country" not in identifier or (
# XXX: We used to require `number` instead of `phone`. The spec
# defines `phone`. So accept both
"phone" not in identifier
and "number" not in identifier
):
raise SynapseError(
400, "Invalid phone-type identifier", errcode=Codes.INVALID_PARAM
)

# Accept both "phone" and "number" as valid keys in m.id.phone
phone_number = identifier.get("phone", identifier.get("number"))

# Convert user-provided phone number to a consistent representation
msisdn = phone_number_to_msisdn(identifier["country"], phone_number)

# Modify the passed dictionary by reference
del identifier["country"]
identifier.pop("number", None)
identifier.pop("phone", None)

identifier["type"] = "m.id.thirdparty"
identifier["medium"] = "msisdn"
identifier["address"] = msisdn


class AuthHandler(BaseHandler):
SESSION_EXPIRE_MS = 48 * 60 * 60 * 1000

Expand Down Expand Up @@ -522,6 +603,87 @@ async def _check_auth_dict(
(canonical_id, callback) = await self.validate_login(user_id, authdict)
return canonical_id

async def username_from_identifier(
self, identifier: Dict[str, str], password: Optional[str] = None
) -> Optional[str]:
"""Given a dictionary containing an identifier from a client, extract the
possibly unqualified username of the user that it identifies. Does *not*
guarantee that the user exists.
If this identifier dict contains a threepid, we attempt to ask password
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
auth providers about it or, failing that, look up an associated user in
the database.
Args:
identifier: The identifier dictionary provided by the client
password: The user provided password if one exists. Used for asking
password auth providers for usernames from 3pid+password combos.
Returns:
A username if one was found, or None otherwise
Raises:
SynapseError: If the identifier dict is invalid
"""

# Convert phone type identifiers to generic threepid identifiers, which
# will be handled in the next step
if identifier["type"] == "m.id.phone":
login_id_phone_to_thirdparty(identifier)

# Convert a threepid identifier to an user identifier
if identifier["type"] == "m.id.thirdparty":
address = identifier.get("address")
medium = identifier.get("medium")

if not medium or not address:
# An error would've already been raised in
# `login_id_thirdparty_from_phone` if the original submission
# was a phone identifier
raise SynapseError(
400, "Invalid thirdparty identifier", errcode=Codes.INVALID_PARAM,
)

if medium == "email":
# For emails, transform the address to lowercase.
# We store all email addresses as lowercase in the DB.
# (See add_threepid in synapse/handlers/auth.py)
address = address.lower()

# Check for auth providers that support 3pid login types
if password is not None:
canonical_user_id, _ = await self.check_password_provider_3pid(
medium, address, password,
)
if canonical_user_id:
# Authentication through password provider and 3pid succeeded
return canonical_user_id

# Check local store
user_id = await self.hs.get_datastore().get_user_id_by_threepid(
medium, address
)
if not user_id:
# We were unable to find a user_id that belonged to the threepid returned
# by the password auth provider
return None

identifier = {"type": "m.id.user", "user": user_id}

# By this point, the identifier should be a `m.id.user`: if it's anything
# else, we haven't understood it.
if identifier["type"] != "m.id.user":
raise SynapseError(
400, "Unknown login identifier type", errcode=Codes.INVALID_PARAM,
)

# User identifiers have a "user" key
user = identifier.get("user")
if user is None:
raise SynapseError(
400,
"User identifier is missing 'user' key",
errcode=Codes.INVALID_PARAM,
)

return user

def _get_params_recaptcha(self) -> dict:
return {"public_key": self.hs.config.recaptcha_public_key}

Expand Down
153 changes: 34 additions & 119 deletions synapse/rest/client/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from synapse.api.errors import Codes, LoginError, SynapseError
from synapse.api.ratelimiting import Ratelimiter
from synapse.handlers.auth import client_dict_convert_legacy_fields_to_identifier
from synapse.http.server import finish_request
from synapse.http.servlet import (
RestServlet,
Expand All @@ -27,47 +28,10 @@
from synapse.rest.client.v2_alpha._base import client_patterns
from synapse.rest.well_known import WellKnownBuilder
from synapse.types import UserID
from synapse.util.msisdn import phone_number_to_msisdn

logger = logging.getLogger(__name__)


def login_submission_legacy_convert(submission):
"""
If the input login submission is an old style object
(ie. with top-level user / medium / address) convert it
to a typed object.
"""
if "user" in submission:
submission["identifier"] = {"type": "m.id.user", "user": submission["user"]}
del submission["user"]

if "medium" in submission and "address" in submission:
submission["identifier"] = {
"type": "m.id.thirdparty",
"medium": submission["medium"],
"address": submission["address"],
}
del submission["medium"]
del submission["address"]


def login_id_thirdparty_from_phone(identifier):
"""
Convert a phone login identifier type to a generic threepid identifier
Args:
identifier(dict): Login identifier dict of type 'm.id.phone'

Returns: Login identifier dict of type 'm.id.threepid'
"""
if "country" not in identifier or "number" not in identifier:
raise SynapseError(400, "Invalid phone-type identifier")

msisdn = phone_number_to_msisdn(identifier["country"], identifier["number"])

return {"type": "m.id.thirdparty", "medium": "msisdn", "address": msisdn}


class LoginRestServlet(RestServlet):
PATTERNS = client_patterns("/login$", v1=True)
CAS_TYPE = "m.login.cas"
Expand Down Expand Up @@ -174,101 +138,52 @@ async def _do_other_login(self, login_submission):
login_submission.get("address"),
login_submission.get("user"),
)
login_submission_legacy_convert(login_submission)

if "identifier" not in login_submission:
raise SynapseError(400, "Missing param: identifier")

identifier = login_submission["identifier"]
if "type" not in identifier:
raise SynapseError(400, "Login identifier has no type")

# convert phone type identifiers to generic threepids
if identifier["type"] == "m.id.phone":
identifier = login_id_thirdparty_from_phone(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")

if medium == "email":
# For emails, transform the address to lowercase.
# We store all email addreses as lowercase in the DB.
# (See add_threepid in synapse/handlers/auth.py)
address = address.lower()

# We also apply account rate limiting using the 3PID as a key, as
# otherwise using 3PID bypasses the ratelimiting based on user ID.
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
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"]
# Convert deprecated authdict formats to the current scheme
client_dict_convert_legacy_fields_to_identifier(login_submission)

# Check whether this attempt uses a threepid, if so, check if our failed attempt
# ratelimiter allows another attempt at this time
medium, address = (
login_submission.get("medium"),
login_submission.get("address"),
)
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
if medium and address:
self._failed_attempts_ratelimiter.ratelimit(
(medium.lower(), address.lower()), update=False
Copy link
Member

@clokep clokep Jun 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lower-casing of medium here seems to be a change in behavior? Or maybe I can't follow the logic...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! Yes it is, and apologies for not calling it out during the commit.

This would've allowed someone to bypass the ratelimiter by specifying eMail or some other combination of capitalisation.

However, now that I'm testing it, this trickery will get caught later on in the flow anyways, and return an immediate 403 before trying anything CPU-heavy like password hashing.

So it's not too sensitive - but I am going to pull it out of here into another PR as I think it's worthwhile, but needs more careful thought.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, please do! 👍

)
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
Copy link
Member

@clokep clokep Jun 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code path seems to now go through an additional self.auth_handler.validate_login, I don't know if that's OK or not.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha, yes good spot.

I believe this originally did not as we have already validated the password via the password auth provider, so there's no need to validate_login at that point (which will itself ask password auth providers with a user_id/password combo).

Considering this would change Synapse's behaviour related to third-party code we should probably try to get the behaviour to match.

# Extract a localpart or user ID from the values in the identifier
username = await self.auth_handler.username_from_identifier(
login_submission["identifier"], login_submission.get("password")
)

# 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
if not username:
if medium and address:
# The user attempted to login via threepid and failed
# Record this failed attempt
self._failed_attempts_ratelimiter.can_do_action(
(medium.lower(), address.lower())
)
# 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.
Comment on lines -255 to -262
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment seems more helpful than the new version.

Copy link
Member Author

@anoadragon453 anoadragon453 Jun 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think things have slightly changed now though (which may or may not be an issue as well).

> # If it returned None but the 3PID was bound then we won't hit
> # this code path,

This is no longer true. If None is returned by username_from_identifier and a medium and address was used, then we will check the ratelimiter. This sounds sensible, but it is a change from before, where it seems like the code would rate limit on the User ID instead.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm...I think I was thinking the part about the incorrect password vs. the account not existing seems useful to keep (if that's still true).

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.
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("@"):
qualified_user_id = identifier["user"]
else:
qualified_user_id = UserID(identifier["user"], self.hs.hostname).to_string()
Comment on lines -275 to -278
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic seems to have disappeared.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It moved to AuthHandler.validate_login.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, actually this was always in validate_login, I think? So this was just duplicated?


# Check if we've hit the failed ratelimit (but don't update it)
self._failed_attempts_ratelimiter.ratelimit(
qualified_user_id.lower(), update=False
)

raise LoginError(403, "Unauthorized threepid", errcode=Codes.FORBIDDEN)

# The login failed for another reason
raise LoginError(403, "Invalid login", errcode=Codes.FORBIDDEN)

# We were able to extract a username successfully
# Check if we've hit the failed ratelimit for this user ID
self._failed_attempts_ratelimiter.ratelimit(username.lower(), update=False)

try:
canonical_user_id, callback = await self.auth_handler.validate_login(
identifier["user"], login_submission
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.
self._failed_attempts_ratelimiter.can_do_action(qualified_user_id.lower())
self._failed_attempts_ratelimiter.can_do_action(username.lower())
raise

result = await self._complete_login(
Expand Down