diff --git a/decnet/web/dependencies.py b/decnet/web/dependencies.py index 78e82359..d11fdb01 100644 --- a/decnet/web/dependencies.py +++ b/decnet/web/dependencies.py @@ -219,13 +219,15 @@ async def _resolve_request(request: Request) -> tuple[str, dict[str, Any]]: return await _resolve_token(token) -def get_token_claims(request: Request) -> dict[str, Any]: +async def get_token_claims(request: Request) -> dict[str, Any]: """Return the validated claims of the presented Bearer token (decode + - signature + revocation checks). Used by logout, which needs the token's own - ``jti``/``exp`` to denylist *this* session even for must_change users.""" + signature + user-exists + revocation checks, but NOT must_change). Used by + logout, which needs the token's own ``jti``/``exp`` to denylist *this* + session — and must still reject an already-revoked token.""" token = _bearer_from_header(request) if not token: raise _CREDENTIALS_EXCEPTION + await _resolve_token(token) # enforce user-exists + revocation; raises 401 return _decode_payload(token) diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index ea34a359..22cd7e21 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from .auth.api_login import router as login_router from .auth.api_change_pass import router as change_pass_router +from .auth.api_logout import router as logout_router from .logs.api_get_logs import router as logs_router from .logs.api_get_histogram import router as histogram_router from .bounty.api_get_bounties import router as bounty_router @@ -89,6 +90,7 @@ api_router = APIRouter( # Authentication api_router.include_router(login_router) api_router.include_router(change_pass_router) +api_router.include_router(logout_router) # Logs & Analytics api_router.include_router(logs_router) diff --git a/decnet/web/router/auth/api_logout.py b/decnet/web/router/auth/api_logout.py new file mode 100644 index 00000000..1f62f014 --- /dev/null +++ b/decnet/web/router/auth/api_logout.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, Depends + +from decnet.telemetry import traced as _traced +from decnet.web.dependencies import get_token_claims, invalidate_token_cache, repo +from decnet.web.db.models import MessageResponse + +router = APIRouter() + + +@router.post( + "/auth/logout", + tags=["Authentication"], + response_model=MessageResponse, + responses={ + 401: {"description": "Missing, invalid, or already-revoked token"}, + }, +) +@_traced("api.logout") +async def logout(claims: dict[str, Any] = Depends(get_token_claims)) -> dict[str, str]: + """Revoke the presented token by adding its ``jti`` to the denylist. + + Single-session logout: only *this* token dies. "Log out everywhere" is a + separate lever (``tokens_valid_from``) driven by password/role changes. + Reachable for must_change_password users so they can always end a session. + """ + # exp is always present (create_access_token stamps it); jti is guaranteed + # by get_token_claims, which rejects tokens without one. + expires_at = datetime.fromtimestamp(claims["exp"], tz=timezone.utc) + await repo.revoke_token(claims["jti"], claims["uuid"], expires_at) + # Drop the local negative-cache entry so reuse 401s immediately, not after TTL. + invalidate_token_cache(claims["jti"]) + return {"message": "Logged out"} diff --git a/tests/api/auth/test_logout.py b/tests/api/auth/test_logout.py new file mode 100644 index 00000000..022a2b15 --- /dev/null +++ b/tests/api/auth/test_logout.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Logout endpoint (WI2): denylists the presented token's jti.""" +from __future__ import annotations + +import uuid as _uuid + +import pytest +from sqlalchemy import select + +from decnet.web.auth import get_password_hash +from decnet.web.db.models import User +from decnet.web.dependencies import repo + +PROTECTED = "/api/v1/attackers?limit=1" + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio +async def test_logout_revokes_the_presented_token(client, auth_token): + # Works before logout. + assert (await client.get(PROTECTED, headers=_auth(auth_token))).status_code == 200 + # Logout succeeds. + r = await client.post("/api/v1/auth/logout", headers=_auth(auth_token)) + assert r.status_code == 200, r.text + # The same token is now dead. + assert (await client.get(PROTECTED, headers=_auth(auth_token))).status_code == 401 + + +@pytest.mark.asyncio +async def test_logout_without_a_token_is_401(client): + r = await client.post("/api/v1/auth/logout") + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_logout_twice_is_rejected(client, auth_token): + assert (await client.post("/api/v1/auth/logout", headers=_auth(auth_token))).status_code == 200 + # Second attempt with the now-revoked token fails closed. + assert (await client.post("/api/v1/auth/logout", headers=_auth(auth_token))).status_code == 401 + + +@pytest.mark.asyncio +async def test_logout_only_kills_the_one_session(client, auth_token): + # A second independent login for the same user keeps working after the + # first session logs out — single-session, not log-out-everywhere. + from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD + second = (await client.post( + "/api/v1/auth/login", + json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD}, + )).json()["access_token"] + + assert (await client.post("/api/v1/auth/logout", headers=_auth(auth_token))).status_code == 200 + assert (await client.get(PROTECTED, headers=_auth(auth_token))).status_code == 401 + # The other session is untouched. + assert (await client.get(PROTECTED, headers=_auth(second))).status_code == 200 + + +@pytest.mark.asyncio +async def test_must_change_user_can_still_logout(client): + # A user with must_change_password=True is blocked from protected routes + # but must always be able to end their session. + username, password = "logout-mcp-user", "logout-mcp-pass-1" + async with repo.session_factory() as session: + if not (await session.execute( + select(User).where(User.username == username) + )).scalar_one_or_none(): + session.add(User( + uuid=str(_uuid.uuid4()), + username=username, + password_hash=get_password_hash(password), + role="viewer", + must_change_password=True, + )) + await session.commit() + + token = (await client.post( + "/api/v1/auth/login", json={"username": username, "password": password}, + )).json()["access_token"] + # Protected route is blocked by must_change... + assert (await client.get(PROTECTED, headers=_auth(token))).status_code == 403 + # ...but logout still works. + assert (await client.post("/api/v1/auth/logout", headers=_auth(token))).status_code == 200 + # And the token is revoked afterwards. + assert (await client.post("/api/v1/auth/logout", headers=_auth(token))).status_code == 401