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

Save login tokens in database #13844

Merged
merged 4 commits into from
Oct 26, 2022
Merged
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
1 change: 1 addition & 0 deletions changelog.d/13844.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Save login tokens in database and prevent login token reuse.
9 changes: 9 additions & 0 deletions docs/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ process, for example:
dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb
```

# Upgrading to v1.71.0

## Removal of the `generate_short_term_login_token` module API method

As announced with the release of [Synapse 1.69.0](#deprecation-of-the-generate_short_term_login_token-module-api-method), the deprecated `generate_short_term_login_token` module method has been removed.

Modules relying on it can instead use the `create_login_token` method.


# Upgrading to v1.69.0

## Changes to the receipts replication streams
Expand Down
45 changes: 36 additions & 9 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@
from synapse.http.site import SynapseRequest
from synapse.logging.context import defer_to_thread
from synapse.metrics.background_process_metrics import run_as_background_process
from synapse.storage.databases.main.registration import LoginTokenLookupResult
from synapse.types import JsonDict, Requester, UserID
from synapse.util import stringutils as stringutils
from synapse.util.async_helpers import delay_cancellation, maybe_awaitable
from synapse.util.macaroons import LoginTokenAttributes
from synapse.util.msisdn import phone_number_to_msisdn
from synapse.util.stringutils import base62_encode
from synapse.util.threepids import canonicalise_email
Expand Down Expand Up @@ -883,6 +883,25 @@ def _verify_refresh_token(self, token: str) -> bool:

return True

async def create_login_token_for_user_id(
self,
user_id: str,
duration_ms: int = (2 * 60 * 1000),
auth_provider_id: Optional[str] = None,
auth_provider_session_id: Optional[str] = None,
) -> str:
login_token = self.generate_login_token()
now = self._clock.time_msec()
expiry_ts = now + duration_ms
await self.store.add_login_token_to_user(
user_id=user_id,
token=login_token,
expiry_ts=expiry_ts,
auth_provider_id=auth_provider_id,
auth_provider_session_id=auth_provider_session_id,
)
return login_token

async def create_refresh_token_for_user_id(
self,
user_id: str,
Expand Down Expand Up @@ -1401,6 +1420,18 @@ async def _check_local_password(self, user_id: str, password: str) -> Optional[s
return None
return user_id

def generate_login_token(self) -> str:
"""Generates an opaque string, for use as an short-term login token"""

# we use the following format for access tokens:
# syl_<random string>_<base62 crc check>

random_string = stringutils.random_string(20)
base = f"syl_{random_string}"

crc = base62_encode(crc32(base.encode("ascii")), minwidth=6)
return f"{base}_{crc}"

def generate_access_token(self, for_user: UserID) -> str:
"""Generates an opaque string, for use as an access token"""

Expand All @@ -1427,15 +1458,11 @@ def generate_refresh_token(self, for_user: UserID) -> str:
crc = base62_encode(crc32(base.encode("ascii")), minwidth=6)
return f"{base}_{crc}"

async def validate_short_term_login_token(
self, login_token: str
) -> LoginTokenAttributes:
try:
res = self.macaroon_gen.verify_short_term_login_token(login_token)
except Exception:
async def consume_login_token(self, login_token: str) -> LoginTokenLookupResult:
res = await self.store.consume_login_token(login_token)
if res is None:
raise AuthError(403, "Invalid login token", errcode=Codes.FORBIDDEN)

await self.auth_blocking.check_auth_blocking(res.user_id)
Copy link
Member Author

Choose a reason for hiding this comment

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

This is not needed, because the check_auth_blocking is called by the caller. So before we effectively checked that twice.

return res

async def delete_access_token(self, access_token: str) -> None:
Expand Down Expand Up @@ -1711,7 +1738,7 @@ async def complete_sso_login(
)

# Create a login token
login_token = self.macaroon_gen.generate_short_term_login_token(
login_token = await self.create_login_token_for_user_id(
registered_user_id,
auth_provider_id=auth_provider_id,
auth_provider_session_id=auth_provider_session_id,
Expand Down
41 changes: 1 addition & 40 deletions synapse/module_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,50 +771,11 @@ async def create_login_token(
auth_provider_session_id: The session ID got during login from the SSO IdP,
if any.
"""
# The deprecated `generate_short_term_login_token` method defaulted to an empty
# string for the `auth_provider_id` because of how the underlying macaroon was
# generated. This will change to a proper NULL-able field when the tokens get
# moved to the database.
return self._hs.get_macaroon_generator().generate_short_term_login_token(
return await self._hs.get_auth_handler().create_login_token_for_user_id(
user_id,
auth_provider_id or "",
auth_provider_session_id,
duration_in_ms,
)

def generate_short_term_login_token(
self,
user_id: str,
duration_in_ms: int = (2 * 60 * 1000),
auth_provider_id: str = "",
auth_provider_session_id: Optional[str] = None,
) -> str:
"""Generate a login token suitable for m.login.token authentication

Added in Synapse v1.9.0.

This was deprecated in Synapse v1.69.0 in favor of create_login_token, and will
be removed in Synapse 1.71.0.

Args:
user_id: gives the ID of the user that the token is for

duration_in_ms: the time that the token will be valid for

auth_provider_id: the ID of the SSO IdP that the user used to authenticate
to get this token, if any. This is encoded in the token so that
/login can report stats on number of successful logins by IdP.
"""
logger.warn(
"A module configured on this server uses ModuleApi.generate_short_term_login_token(), "
"which is deprecated in favor of ModuleApi.create_login_token(), and will be removed in "
"Synapse 1.71.0",
)
return self._hs.get_macaroon_generator().generate_short_term_login_token(
user_id,
auth_provider_id,
auth_provider_session_id,
duration_in_ms,
)

@defer.inlineCallbacks
Expand Down
3 changes: 1 addition & 2 deletions synapse/rest/client/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,8 +436,7 @@ async def _do_token_login(
The body of the JSON response.
"""
token = login_submission["token"]
auth_handler = self.auth_handler
res = await auth_handler.validate_short_term_login_token(token)
res = await self.auth_handler.consume_login_token(token)

return await self._complete_login(
res.user_id,
Expand Down
5 changes: 2 additions & 3 deletions synapse/rest/client/login_token_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ def __init__(self, hs: "HomeServer"):
self.store = hs.get_datastores().main
self.clock = hs.get_clock()
self.server_name = hs.config.server.server_name
self.macaroon_gen = hs.get_macaroon_generator()
self.auth_handler = hs.get_auth_handler()
self.token_timeout = hs.config.experimental.msc3882_token_timeout
self.ui_auth = hs.config.experimental.msc3882_ui_auth
Expand All @@ -76,10 +75,10 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
can_skip_ui_auth=False, # Don't allow skipping of UI auth
)

login_token = self.macaroon_gen.generate_short_term_login_token(
login_token = await self.auth_handler.create_login_token_for_user_id(
user_id=requester.user.to_string(),
auth_provider_id="org.matrix.msc3882.login_token_request",
duration_in_ms=self.token_timeout,
duration_ms=self.token_timeout,
)

return (
Expand Down
142 changes: 142 additions & 0 deletions synapse/storage/databases/main/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,20 @@ class RefreshTokenLookupResult:
If None, the session can be refreshed indefinitely."""


@attr.s(auto_attribs=True, frozen=True, slots=True)
class LoginTokenLookupResult:
"""Result of looking up a login token."""

user_id: str
"""The user this token belongs to."""

auth_provider_id: Optional[str]
"""The SSO Identity Provider that the user authenticated with, to get this token."""

auth_provider_session_id: Optional[str]
"""The session ID advertised by the SSO Identity Provider."""


class RegistrationWorkerStore(CacheInvalidationWorkerStore):
def __init__(
self,
Expand Down Expand Up @@ -1789,6 +1803,114 @@ def _replace_refresh_token_txn(txn: LoggingTransaction) -> None:
"replace_refresh_token", _replace_refresh_token_txn
)

async def add_login_token_to_user(
self,
user_id: str,
token: str,
expiry_ts: int,
auth_provider_id: Optional[str],
auth_provider_session_id: Optional[str],
) -> None:
"""Adds a short-term login token for the given user.

Args:
user_id: The user ID.
token: The new login token to add.
expiry_ts (milliseconds since the epoch): Time after which the login token
cannot be used.
auth_provider_id: The SSO Identity Provider that the user authenticated with
to get this token, if any
auth_provider_session_id: The session ID advertised by the SSO Identity
Provider, if any.
"""
await self.db_pool.simple_insert(
"login_tokens",
{
"token": token,
"user_id": user_id,
"expiry_ts": expiry_ts,
"auth_provider_id": auth_provider_id,
"auth_provider_session_id": auth_provider_session_id,
},
desc="add_login_token_to_user",
)

def _consume_login_token(
self,
txn: LoggingTransaction,
token: str,
ts: int,
) -> Optional[LoginTokenLookupResult]:
if self.database_engine.supports_returning:
# If the database engine supports the `RETURNING` keyword, delete and fetch
# the token with one query
txn.execute(
"""
DELETE FROM login_tokens
Copy link
Member

Choose a reason for hiding this comment

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

How does this work with idempotency? Ideally we'd be able to retry the login request. Otherwise on awful networks you might get stuck trying to login, fail to get the response, have to get another login token, etc.

Copy link
Member Author

Choose a reason for hiding this comment

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

We had this discussion with VdH in the comments here: #13844 (comment)

OAuth suggests only accepting an authorisation code once, even if the response gets lost, and instead re-start an authorisation.
It is worth noting that Element Web won't retry exchanging the login token if the request fails. I haven't checked what other clients do.
It is, in my opinion, better than letting a single token issue an infinite number of sessions, especially since there is nothing in m.login.sso verifying who is exchanging the token

Copy link
Member

Choose a reason for hiding this comment

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

Oh, sorry.

I agree that we shouldn't allow infinite uses, but there are standard ways to avoid that, by e.g.:

  • having client send a txn ID and then only allowing login token to be used with that txn ID going forwards, etc. Though in this case we don't have one for /login API (though we probably should).
  • record the access token returned, and delete from login_tokens once we see it used.

I'm a bit in two minds about whether this is fine to leave as is for now. On the one hand its definitely something we could add later, but on the other hand its also something that is only going to bite people on bad connections, which is something we rarely suffer from ourselves and so are unlikely to notice how problematic it is.

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe we could start tracking that by recording token reuse in a prometheus counter?

Copy link
Member Author

Choose a reason for hiding this comment

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

@erikjohnston: I added a Prometheus counter (fb0b2f2) to track invalid login token, so we know if a token was reused, expired or if it is just unknown

Copy link
Member

Choose a reason for hiding this comment

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

Ok, let's leave this as is for now. Though I would love it if when we came to changing the /login API we added a session ID to allow us to get out of this situation.

WHERE token = ?
RETURNING user_id, expiry_ts, auth_provider_id, auth_provider_session_id
""",
(token,),
)
ret = txn.fetchone()
if ret is None:
return None

user_id, expiry_ts, auth_provider_id, auth_provider_session_id = ret
else:
values = self.db_pool.simple_select_one_txn(
txn,
"login_tokens",
keyvalues={"token": token},
retcols=(
"user_id",
"expiry_ts",
"auth_provider_id",
"auth_provider_session_id",
),
allow_none=True,
)

if values is None:
return None

self.db_pool.simple_delete_one_txn(
txn,
"login_tokens",
keyvalues={"token": token},
)
user_id = values["user_id"]
expiry_ts = values["expiry_ts"]
auth_provider_id = values["auth_provider_id"]
auth_provider_session_id = values["auth_provider_session_id"]

# Token expired
if ts > int(expiry_ts):
return None

return LoginTokenLookupResult(
user_id=user_id,
auth_provider_id=auth_provider_id,
auth_provider_session_id=auth_provider_session_id,
)

async def consume_login_token(self, token: str) -> Optional[LoginTokenLookupResult]:
"""Lookup a login token and consume it.

Args:
token: The login token.

Returns:
The data stored with that token, including the `user_id`. Returns `None` if
the token does not exist or if it expired.
"""
return await self.db_pool.runInteraction(
"consume_login_token",
self._consume_login_token,
token,
self._clock.time_msec(),
)

@cached()
async def is_guest(self, user_id: str) -> bool:
res = await self.db_pool.simple_select_one_onecol(
Expand Down Expand Up @@ -2019,6 +2141,12 @@ def __init__(
and hs.config.experimental.msc3866.require_approval_for_new_accounts
)

# Create a background job for removing expired login tokens
if hs.config.worker.run_background_tasks:
self._clock.looping_call(
self._delete_expired_login_tokens, THIRTY_MINUTES_IN_MS
)

async def add_access_token_to_user(
self,
user_id: str,
Expand Down Expand Up @@ -2617,6 +2745,20 @@ async def update_user_approval_status(
approved,
)

@wrap_as_background_process("delete_expired_login_tokens")
async def _delete_expired_login_tokens(self) -> None:
"""Remove login tokens with expiry dates that have passed."""

def _delete_expired_login_tokens_txn(txn: LoggingTransaction, ts: int) -> None:
sql = "DELETE FROM login_tokens WHERE expiry_ts <= ?"
txn.execute(sql, (ts,))

await self.db_pool.runInteraction(
"delete_expired_login_tokens",
_delete_expired_login_tokens_txn,
self._clock.time_msec(),
)


def find_max_generated_user_id_localpart(cur: Cursor) -> int:
"""
Expand Down
34 changes: 34 additions & 0 deletions synapse/storage/schema/main/delta/73/10login_tokens.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

-- Login tokens are short-lived tokens that are used for the m.login.token
-- login method, mainly during SSO logins
CREATE TABLE login_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expiry_ts BIGINT NOT NULL,
auth_provider_id TEXT,
auth_provider_session_id TEXT
);

-- We're sometimes querying them by their session ID we got from their IDP
CREATE INDEX login_tokens_auth_provider_idx
ON login_tokens (auth_provider_id, auth_provider_session_id);

-- We're deleting them by their expiration time
CREATE INDEX login_tokens_expiry_time_idx
ON login_tokens (expiry_ts);

Loading