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

Commit

Permalink
Save login tokens in database
Browse files Browse the repository at this point in the history
Signed-off-by: Quentin Gliech <quenting@element.io>
  • Loading branch information
sandhose committed Sep 21, 2022
1 parent f976184 commit fe6bf3d
Show file tree
Hide file tree
Showing 9 changed files with 294 additions and 218 deletions.
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.
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 @@ -1390,6 +1409,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 @@ -1416,15 +1447,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)
return res

async def delete_access_token(self, access_token: str) -> None:
Expand Down Expand Up @@ -1700,7 +1727,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
37 changes: 1 addition & 36 deletions synapse/module_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
# limitations under the License.
import email.utils
import logging
import warnings
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -772,45 +771,11 @@ async def create_login_token(
auth_provider_session_id: The session ID got during login from the SSO IdP,
if any.
"""
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 is deprecated in favor of create_login_token.
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.
"""
warnings.warn(
"ModuleApi.generate_short_term_login_token() is deprecated "
"in favor of ModuleApi.create_login_token().",
DeprecationWarning,
)
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 @@ -405,8 +405,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
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 @@ -1767,6 +1781,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
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 @@ -1933,6 +2055,12 @@ def __init__(
self._access_tokens_id_gen = IdGenerator(db_conn, "access_tokens", "id")
self._refresh_tokens_id_gen = IdGenerator(db_conn, "refresh_tokens", "id")

# 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 @@ -2503,6 +2631,20 @@ def start_or_continue_validation_session_txn(txn: LoggingTransaction) -> None:
start_or_continue_validation_session_txn,
)

@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/03login_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

0 comments on commit fe6bf3d

Please sign in to comment.