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 7 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
1 change: 1 addition & 0 deletions changelog.d/7438.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support `identifier` dictionary fields in User-Interactive Authentication flows. Relax requirement of the `user` parameter.
215 changes: 203 additions & 12 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 @@ -315,7 +396,7 @@ async def check_auth(
# otherwise use whatever was last provided.
#
# This was designed to allow the client to omit the parameters
# and just supply the session in subsequent calls so it split
# and just supply the session in subsequent calls. So it splits
# auth between devices by just sharing the session, (eg. so you
# could continue registration from your phone having clicked the
# email auth link on there). It's probably too open to abuse
Expand Down Expand Up @@ -512,16 +593,125 @@ async def _check_auth_dict(
res = await checker.check_auth(authdict, clientip=clientip)
return res

# build a v1-login-style dict out of the authdict and fall back to the
# v1 code
user_id = authdict.get("user")
# We don't have a checker for the auth type provided by the client
# Assume that it is `m.login.password`.
if login_type != LoginType.PASSWORD:
raise SynapseError(
400, "Unknown authentication type", errcode=Codes.INVALID_PARAM,
)

password = authdict.get("password")
if password is None:
raise SynapseError(
400,
"Missing parameter for m.login.password dict: 'password'",
errcode=Codes.INVALID_PARAM,
)

# Retrieve the user ID using details provided in the authdict

# Deprecation notice: Clients used to be able to simply provide a
# `user` field which pointed to a user_id or localpart. This has
# been deprecated in favour of an `identifier` key, which is a
# dictionary providing information on how to identify a single
# user.
# https://matrix.org/docs/spec/client_server/r0.6.1#identifier-types
#
# We convert old-style dicts to new ones here
client_dict_convert_legacy_fields_to_identifier(authdict)

# Extract a user ID from the values in the identifier
username = await self.username_from_identifier(authdict["identifier"], password)

if username is None:
raise SynapseError(400, "Valid username not found")

if user_id is None:
raise SynapseError(400, "", Codes.MISSING_PARAM)
# Now that we've found the username, validate that the password is correct
canonical_id, _ = await self.validate_login(username, authdict)

(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 Expand Up @@ -686,7 +876,8 @@ async def validate_login(
m.login.password auth types.

Args:
username: username supplied by the user
username: a localpart or fully qualified user ID - what is provided by the
client
login_submission: the whole of the login submission
(including 'type' and other relevant fields)
Returns:
Expand All @@ -698,10 +889,10 @@ async def validate_login(
LoginError if there was an authentication problem.
"""

if username.startswith("@"):
qualified_user_id = username
else:
qualified_user_id = UserID(username, self.hs.hostname).to_string()
# We need a fully qualified User ID for some method calls here
qualified_user_id = username
clokep marked this conversation as resolved.
Show resolved Hide resolved
if not qualified_user_id.startswith("@"):
qualified_user_id = UserID(qualified_user_id, self.hs.hostname).to_string()

login_type = login_submission.get("type")
known_login_type = False
Expand Down
Loading