| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- """A one-time token minted before its user's session began is refused.
- Deleting or renaming a user drops the tokens pending under the name in its
- transaction; one minted in the milliseconds around it (a WebSocket token from
- the old user's still-valid JWT, a forgot-password link) kept the name and
- worked for whoever was given it next. Every token named after a user is now
- refused if it was minted before that user's password_changed_at, which a new
- user, a rename, a password change or reset, a 2FA removal and a restore set.
- """
- from datetime import datetime, timedelta, timezone
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import text
- from backend.app.core import auth
- from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
- pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
- @pytest.fixture
- async def carol(async_client: AsyncClient, db_session) -> int:
- await async_client.post(
- "/api/v1/auth/setup",
- json={"auth_enabled": True, "admin_username": "tokadmin", "admin_password": "AdminPass1!"},
- )
- login = await async_client.post("/api/v1/auth/login", json={"username": "tokadmin", "password": "AdminPass1!"})
- admin = {"Authorization": f"Bearer {login.json()['access_token']}"}
- groups = (await async_client.get("/api/v1/groups/", headers=admin)).json()
- viewers = next(g["id"] for g in groups if g["name"] == "Viewers")
- user = await async_client.post(
- "/api/v1/users/",
- headers=admin,
- json={"username": "carol", "password": "Userpass1!", "role": "user", "group_ids": [viewers]},
- )
- carol_id = user.json()["id"]
- # Carol's current session began 30 minutes ago (a rename, a new account...).
- await db_session.execute(
- text("UPDATE users SET password_changed_at = :t WHERE id = :id"),
- {"t": _ago(minutes=30), "id": carol_id},
- )
- await db_session.commit()
- return carol_id
- def _ago(**delta) -> datetime:
- return datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(**delta)
- async def _token(db, kind: str, *, minted_ago_minutes: int, **fields) -> str:
- value = f"{kind}-{minted_ago_minutes}"
- db.add(
- AuthEphemeralToken(
- token=value,
- token_type=kind,
- username=fields.pop("username", "carol"),
- created_at=_ago(minutes=minted_ago_minutes),
- expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
- **fields,
- )
- )
- await db.commit()
- return value
- @pytest.mark.parametrize(
- ("kind", "verify"),
- [
- ("websocket", lambda t: auth.verify_websocket_token_principal(t)),
- ("camera_stream", lambda t: auth.camera_stream_access(t)),
- ("media", lambda t: auth.verify_media_token(t)),
- ],
- )
- async def test_a_reusable_token_from_before_the_session_is_refused(carol, db_session, kind, verify):
- stale = await _token(db_session, kind, minted_ago_minutes=60)
- fresh = await _token(db_session, kind, minted_ago_minutes=10)
- assert await verify(stale) is None
- assert await verify(fresh) is not None
- async def test_a_pre_auth_token_from_before_the_session_is_refused(carol, db_session):
- from backend.app.api.routes.mfa import consume_pre_auth_token, peek_pre_auth_token
- stale = await _token(db_session, TokenType.PRE_AUTH, minted_ago_minutes=60)
- fresh = await _token(db_session, TokenType.PRE_AUTH, minted_ago_minutes=10)
- assert await peek_pre_auth_token(db_session, stale) is None
- assert await consume_pre_auth_token(db_session, stale) is None
- assert await consume_pre_auth_token(db_session, fresh) == "carol"
- async def test_a_reset_link_from_before_the_session_is_refused(async_client: AsyncClient, carol, db_session):
- stale = await _token(db_session, TokenType.PASSWORD_RESET, minted_ago_minutes=60)
- response = await async_client.post(
- "/api/v1/auth/forgot-password/confirm", json={"token": stale, "new_password": "Newpass12!"}
- )
- assert response.status_code == 400, response.text
- async def test_an_oidc_exchange_token_from_before_the_session_is_refused(async_client: AsyncClient, carol, db_session):
- stale = await _token(db_session, TokenType.OIDC_EXCHANGE, minted_ago_minutes=60)
- response = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": stale})
- assert response.status_code == 401, response.text
- async def test_an_email_otp_setup_token_from_before_the_session_is_refused(
- async_client: AsyncClient, carol, db_session
- ):
- from backend.app.api.routes.mfa import pwd_context
- stale = await _token(db_session, TokenType.EMAIL_OTP_SETUP, minted_ago_minutes=60, nonce=pwd_context.hash("123456"))
- login = await async_client.post("/api/v1/auth/login", json={"username": "carol", "password": "Userpass1!"})
- response = await async_client.post(
- "/api/v1/auth/2fa/email/enable/confirm",
- headers={"Authorization": f"Bearer {login.json()['access_token']}"},
- json={"setup_token": stale, "code": "123456"},
- )
- assert response.status_code == 400, response.text
- async def test_a_connect_code_from_before_the_session_is_refused(async_client: AsyncClient, carol, db_session):
- from backend.tests.integration.test_connected_apps import _exchange, _pkce, _register
- login = await async_client.post("/api/v1/auth/login", json={"username": "tokadmin", "password": "AdminPass1!"})
- app = await _register(async_client, login.json()["access_token"])
- verifier, challenge = _pkce()
- code = "connect-code-stale"
- from backend.app.api.routes.connected_apps import _hash_code
- db_session.add(
- AuthEphemeralToken.new_connect_code(
- code_hash=_hash_code(code),
- username="carol",
- app_id=app["id"],
- code_challenge=challenge,
- expires_at=datetime.now(timezone.utc) + timedelta(minutes=1),
- )
- )
- await db_session.commit()
- await db_session.execute(
- text("UPDATE auth_ephemeral_tokens SET created_at = :t WHERE token = :h"),
- {"t": _ago(minutes=60), "h": _hash_code(code)},
- )
- await db_session.commit()
- response = await _exchange(async_client, app, code, verifier)
- assert response.status_code == 400, response.text
- async def test_every_token_kind_named_after_a_user_is_covered():
- """A new kind must get the check (and a test above)."""
- covered = {
- TokenType.WEBSOCKET.value,
- TokenType.CAMERA_STREAM.value,
- TokenType.MEDIA.value,
- TokenType.PRE_AUTH.value,
- TokenType.PASSWORD_RESET.value,
- TokenType.OIDC_EXCHANGE.value,
- TokenType.EMAIL_OTP_SETUP.value,
- TokenType.CONNECT_CODE.value,
- }
- named_by_nobody = {
- TokenType.OIDC_STATE.value, # before anyone is signed in
- TokenType.SLICER_DOWNLOAD.value, # bound to a resource, not a user
- TokenType.REVOKED_JTI.value, # the logout list, not a credential
- }
- assert {kind.value for kind in TokenType} - named_by_nobody <= covered
- async def test_every_token_kind_in_the_code_is_in_the_enum():
- """A kind written as a bare string would escape the check above."""
- import re
- from pathlib import Path
- import backend.app as app_pkg
- root = Path(app_pkg.__file__).parent
- kinds = set()
- for path in root.rglob("*.py"):
- kinds.update(re.findall(r'token_type\s*(?:==|=)\s*"([a-z_]+)"', path.read_text()))
- kinds.discard("bearer") # the OAuth token_type of a login response, not a stored kind
- assert kinds <= {kind.value for kind in TokenType}, kinds - {kind.value for kind in TokenType}
- async def test_a_token_of_a_user_that_does_not_exist_is_refused(carol, db_session):
- """Callers look the user up again on their own: a recreate in between would
- otherwise be served as the new user."""
- stale = await _token(db_session, "media", minted_ago_minutes=10, username="ghost")
- assert await auth.verify_media_token(stale) is None
- async def test_a_token_from_the_same_second_but_before_the_session_is_refused(carol, db_session):
- """Both timestamps keep microseconds; nothing writes a user and its first
- token in one flush, so there is no reason to round."""
- session_start = datetime.now(timezone.utc).replace(tzinfo=None, microsecond=600000)
- await db_session.execute(
- text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": session_start, "id": carol}
- )
- db_session.add(
- AuthEphemeralToken(
- token="same-second",
- token_type="media",
- username="carol",
- created_at=session_start.replace(microsecond=300000),
- expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
- )
- )
- await db_session.commit()
- assert await auth.verify_media_token("same-second") is None
- async def test_a_token_minted_as_the_session_ends_is_dated_by_its_session(carol, db_session):
- """A stolen JWT keeps minting while the owner changes the password: a mint
- checked before the change committed wrote its row after. The token counts
- from when the session that minted it began, so it is refused."""
- import jwt as pyjwt
- from backend.app.models.user import User
- issued = datetime.now(timezone.utc) - timedelta(minutes=20) # the session began 20 min ago
- session_jwt = pyjwt.encode(
- {"sub": "carol", "iat": issued, "exp": issued + timedelta(days=1), "jti": "stolen"},
- auth.SECRET_KEY,
- algorithm=auth.ALGORITHM,
- )
- user = await db_session.get(User, carol)
- marker = auth._authenticated_user.set((session_jwt, user))
- try:
- token = await auth.create_websocket_token("carol")
- finally:
- auth._authenticated_user.reset(marker)
- # The password change, which started before the row was written, commits now.
- await db_session.execute(
- text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": _ago(minutes=5), "id": carol}
- )
- await db_session.commit()
- assert await auth.verify_websocket_token_principal(token) is None
- async def test_a_keys_token_does_not_pass_to_the_next_key_given_its_id(carol, db_session):
- """Minted while the key was deleted: SQLite gives the id to the next key."""
- from backend.app.models.api_key import APIKey
- key = APIKey(name="old", key_hash="h", key_prefix="bb_old1")
- db_session.add(key)
- await db_session.commit()
- key_id = key.id
- ws = await auth.create_websocket_token(None, key_id, api_key_prefix="bb_old1")
- cam = await auth.create_camera_stream_token(api_key_id=key_id, api_key_prefix="bb_old1")
- await db_session.execute(text("DELETE FROM api_keys WHERE id = :id"), {"id": key_id})
- await db_session.commit()
- db_session.add(APIKey(id=key_id, name="new", key_hash="h2", key_prefix="bb_new1"))
- await db_session.commit()
- assert await auth.verify_websocket_token_principal(ws) is None
- assert await auth.camera_stream_access(cam) is None
- async def test_a_long_lived_token_does_not_pass_to_the_next_user_given_its_id(carol, db_session):
- """Minted while its user was deleted: SQLite gives the id to the next user,
- and the token would stream with their printers for up to a year."""
- from backend.app.services.long_lived_tokens import create_token
- created = await create_token(db_session, user_id=carol, name="wall", expires_in_days=30, scope="camwall")
- await db_session.execute(
- text("UPDATE long_lived_tokens SET created_at = :t WHERE id = :id"),
- {"t": _ago(minutes=60), "id": created.record.id},
- )
- await db_session.execute(
- text("UPDATE users SET created_at = :t WHERE id = :id"), {"t": _ago(minutes=1), "id": carol}
- )
- await db_session.commit() # carol now stands for the user created after the token
- assert await auth.verify_camwall_token(created.plaintext) is None
- assert await auth.camera_stream_access(created.plaintext) is None
- async def _session_then_password_change(client: AsyncClient, db, carol_id: int, mint) -> str:
- """Sign carol in, mint through the real route a second later, then commit a
- password change dated between the session's start and the token's row.
- Returns the token the route handed out."""
- import asyncio
- login = await client.post("/api/v1/auth/login", json={"username": "carol", "password": "Userpass1!"})
- headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
- await asyncio.sleep(1.1) # the row is written in a later second than the session began
- token = await mint(headers)
- # The change commits just before the request wrote its row.
- changed = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(milliseconds=50)
- await db.execute(text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": changed, "id": carol_id})
- await db.commit()
- return token
- @pytest.mark.parametrize(
- ("route", "verify"),
- [
- ("/api/v1/auth/ws-token", lambda t: auth.verify_websocket_token_principal(t)),
- ("/api/v1/auth/media-token", lambda t: auth.verify_media_token(t)),
- ("/api/v1/printers/camera/stream-token", lambda t: auth.camera_stream_access(t)),
- ],
- )
- async def test_a_token_minted_through_the_route_counts_from_its_session(
- async_client: AsyncClient, carol, db_session, route, verify
- ):
- async def mint(headers):
- response = await async_client.post(route, headers=headers)
- assert response.status_code == 200, response.text
- return response.json()["token"]
- token = await _session_then_password_change(async_client, db_session, carol, mint)
- assert await verify(token) is None
- async def test_a_connect_code_counts_from_its_session(async_client: AsyncClient, carol, db_session):
- from backend.tests.integration.test_connected_apps import _authorize, _exchange, _pkce, _register
- login = await async_client.post("/api/v1/auth/login", json={"username": "tokadmin", "password": "AdminPass1!"})
- app = await _register(async_client, login.json()["access_token"])
- verifier, challenge = _pkce()
- codes = []
- async def mint(headers):
- response = await _authorize(async_client, headers["Authorization"].split()[1], app, challenge)
- assert response.status_code == 200, response.text
- codes.append(response.json()["code"])
- return codes[-1]
- code = await _session_then_password_change(async_client, db_session, carol, mint)
- assert (await _exchange(async_client, app, code, verifier)).status_code == 400
- async def test_an_email_otp_setup_token_counts_from_its_session(async_client: AsyncClient, carol, db_session):
- from unittest.mock import AsyncMock, MagicMock, patch
- await db_session.execute(text("UPDATE users SET email = 'carol@example.com' WHERE id = :id"), {"id": carol})
- await db_session.commit()
- async def mint(headers):
- with (
- patch("backend.app.api.routes.mfa.get_smtp_settings", AsyncMock(return_value=MagicMock())),
- patch("backend.app.api.routes.mfa.send_email", MagicMock()),
- ):
- response = await async_client.post("/api/v1/auth/2fa/email/enable", headers=headers)
- assert response.status_code == 200, response.text
- return response.json()["setup_token"]
- setup = await _session_then_password_change(async_client, db_session, carol, mint)
- written = await db_session.execute(
- text("SELECT created_at FROM auth_ephemeral_tokens WHERE token = :t"), {"t": setup}
- )
- changed = await db_session.execute(text("SELECT password_changed_at FROM users WHERE id = :id"), {"id": carol})
- assert written.scalar_one() < changed.scalar_one() # dated before the change, so refused on use
- async def test_a_connect_code_from_the_grant_race_retry_counts_from_its_session(
- async_client: AsyncClient, carol, db_session
- ):
- """Two consents at once: the loser rolls back and mints the code again."""
- from unittest.mock import patch
- from sqlalchemy.exc import IntegrityError
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.tests.integration.test_connected_apps import _authorize, _exchange, _pkce, _register
- login = await async_client.post("/api/v1/auth/login", json={"username": "tokadmin", "password": "AdminPass1!"})
- app = await _register(async_client, login.json()["access_token"])
- verifier, challenge = _pkce()
- real_commit = AsyncSession.commit
- raised = []
- async def commit_losing_the_grant_race(self):
- if not raised and any(getattr(o, "token_type", None) == TokenType.CONNECT_CODE for o in self.new):
- raised.append(1)
- raise IntegrityError("INSERT", {}, Exception("grant exists"))
- return await real_commit(self)
- async def mint(headers):
- with patch.object(AsyncSession, "commit", commit_losing_the_grant_race):
- response = await _authorize(async_client, headers["Authorization"].split()[1], app, challenge)
- assert response.status_code == 200, response.text
- return response.json()["code"]
- code = await _session_then_password_change(async_client, db_session, carol, mint)
- assert raised, "the retry path was not taken"
- assert (await _exchange(async_client, app, code, verifier)).status_code == 400
|