diff --git a/backend/src/infrastructure/auth/routes.py b/backend/src/infrastructure/auth/routes.py index fe4df370..98819906 100644 --- a/backend/src/infrastructure/auth/routes.py +++ b/backend/src/infrastructure/auth/routes.py @@ -4,6 +4,7 @@ from crudauth import Principal from crudauth.exceptions import UnauthorizedException from crudauth.oauth import OAuthState +from crudauth.ratelimit import KeyBy from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi.responses import RedirectResponse @@ -109,6 +110,43 @@ async def logout( return {"message": "Logged out successfully"} +@router.post( + "/logout-all", + summary="Logout All Sessions", + description=""" + Terminates every active session for the current user, across all devices. + + Use this to "log out everywhere" after a suspected compromise. By default it + invalidates every session the user holds, including the one making the + request, and clears the current client's cookies. + + Pass keep_current=true to keep the calling session and sign out only the + other devices. + """, + responses={ + 200: {"description": "Sessions terminated"}, + 401: {"description": "Not authenticated"}, + 429: {"description": "Too many requests, try again later"}, + }, + response_description="Confirmation with the number of sessions terminated", + dependencies=[Depends(crud_auth.rate_limit("logout_all", key=KeyBy.USER))], +) +async def logout_all( + response: Response, + principal: Annotated[Principal, Depends(get_current_principal)], + keep_current: bool = Query(False, description="Keep the calling session and sign out every other device"), +) -> dict[str, Any]: + """Terminate the current user's sessions (CSRF-protected); ``keep_current`` spares the calling one.""" + current_session_id = principal.metadata.get("session_id") + terminated = await crud_auth.sessions.revoke_all(principal.user_id, exclude=current_session_id if keep_current else None) + if keep_current: + return {"message": "All other sessions terminated.", "terminated_count": terminated} + + crud_auth.sessions.clear_session_cookies(response) + + return {"message": "All sessions terminated. Please log in again.", "terminated_count": terminated} + + @router.post( "/refresh-csrf", summary="Refresh CSRF Token", diff --git a/backend/tests/integration/auth/test_endpoints.py b/backend/tests/integration/auth/test_endpoints.py index 628617df..d379d481 100644 --- a/backend/tests/integration/auth/test_endpoints.py +++ b/backend/tests/integration/auth/test_endpoints.py @@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.infrastructure.auth.dependencies import get_optional_principal +from src.infrastructure.auth.setup import auth as crud_auth from src.interfaces.main import app from src.modules.user.models import User @@ -278,6 +279,86 @@ async def test_logout_without_csrf_token_rejected(client: AsyncClient, test_user assert response.status_code == 403 +async def _clear_sessions(user: dict) -> None: + """Drop sessions left for this user id by earlier tests. + + crudauth's in-memory session store lives for the whole test run, while each test's + fresh database hands ``test_user`` the same id, so leftovers would skew the counts. + """ + await crud_auth.sessions.revoke_all(user["id"]) + + +async def _login(client: AsyncClient, user: dict) -> tuple[str, str]: + """Log in and return the new session's ``(session_id, csrf_token)``.""" + response = await client.post( + "/api/v1/auth/login", + data={"username": user["username"], "password": user["password"]}, + ) + assert response.status_code == 200 + return response.cookies["session_id"], response.json()["csrf_token"] + + +async def _is_authenticated(client: AsyncClient, session_id: str | None = None) -> bool: + """check-auth as the client's current session, or as ``session_id`` when given.""" + if session_id is not None: + client.cookies.clear() + client.cookies.set("session_id", session_id) + response = await client.get("/api/v1/auth/check-auth") + assert response.status_code == 200 + return response.json()["authenticated"] + + +@pytest.mark.asyncio +async def test_logout_all_terminates_every_session(client: AsyncClient, test_user: dict): + """logout-all revokes every session of the user (not just the caller's) and clears cookies.""" + await _clear_sessions(test_user) + first_session_id, _ = await _login(client, test_user) + _, csrf_token = await _login(client, test_user) + + response = await client.post("/api/v1/auth/logout-all", headers={"X-CSRF-Token": csrf_token}) + + assert response.status_code == 200 + assert response.json()["terminated_count"] == 2 + assert any(c.startswith("session_id=") for c in response.headers.get_list("set-cookie")) + assert await _is_authenticated(client, first_session_id) is False + + +@pytest.mark.asyncio +async def test_logout_all_keep_current_spares_calling_session(client: AsyncClient, test_user: dict): + """keep_current=true revokes the other sessions but keeps the caller's session and cookies.""" + await _clear_sessions(test_user) + first_session_id, _ = await _login(client, test_user) + _, csrf_token = await _login(client, test_user) + + response = await client.post( + "/api/v1/auth/logout-all", + params={"keep_current": "true"}, + headers={"X-CSRF-Token": csrf_token}, + ) + + assert response.status_code == 200 + assert response.json()["terminated_count"] == 1 + assert not any(c.startswith("session_id=") for c in response.headers.get_list("set-cookie")) + assert await _is_authenticated(client) is True + assert await _is_authenticated(client, first_session_id) is False + + +@pytest.mark.asyncio +async def test_logout_all_unauthenticated_returns_401(client: AsyncClient): + """logout-all with no session is rejected.""" + response = await client.post("/api/v1/auth/logout-all") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_logout_all_without_csrf_token_rejected(client: AsyncClient, test_user: dict): + """A logged-in session can't log out everywhere without the CSRF header (403).""" + await _login(client, test_user) + + response = await client.post("/api/v1/auth/logout-all") + assert response.status_code == 403 + + @pytest.mark.asyncio async def test_refresh_csrf_token_success(client: AsyncClient, test_user: dict): """With a valid session cookie, /refresh-csrf mints a fresh token (no CSRF header needed).""" diff --git a/docs/user-guide/api/index.md b/docs/user-guide/api/index.md index d722ddc4..e8f56250 100644 --- a/docs/user-guide/api/index.md +++ b/docs/user-guide/api/index.md @@ -185,7 +185,7 @@ What ships out of the box (40 total routes): | `POST/GET/PATCH/DELETE /api/v1/users/*` | `modules/user/routes.py` | Open create, session/superuser-gated reads/updates | | `GET /api/v1/tiers/*` | `modules/tier/routes.py` | Public list + lookup by name | | `GET/PATCH/DELETE /api/v1/rate-limits/*` | `modules/rate_limit/routes.py` | List/get public; PATCH/DELETE require superuser | -| `POST /api/v1/auth/login`, `logout`, `refresh-csrf`, `check-auth` | `infrastructure/auth/routes.py` | Session auth | +| `POST /api/v1/auth/login`, `logout`, `logout-all`, `refresh-csrf`, `check-auth` | `infrastructure/auth/routes.py` | Session auth | | `GET /api/v1/auth/oauth/google`, `oauth/callback/google` | `infrastructure/auth/routes.py` | Google OAuth | | `POST/GET/PATCH/DELETE /api/v1/api-keys/*` | `modules/api_keys/routes.py` | Authenticated key management | | `GET /admin/*` | `interfaces/admin/initialize.py` | SQLAdmin UI | diff --git a/docs/user-guide/authentication/sessions.md b/docs/user-guide/authentication/sessions.md index b8d46b10..7094bbea 100644 --- a/docs/user-guide/authentication/sessions.md +++ b/docs/user-guide/authentication/sessions.md @@ -113,7 +113,7 @@ On every subsequent request, the auth dependency (via crudauth): 3. For mutating requests (POST/PUT/DELETE/PATCH), validates the CSRF token if `CSRF_ENABLED=true` 4. Hands back a `Principal`; `get_current_user` then re-loads the full user row (joined with the `Tier` relationship via `lazy="selectin"`) -Logout (`POST /api/v1/auth/logout`) terminates the session record and clears the cookies. +Logout (`POST /api/v1/auth/logout`) terminates the session record and clears the cookies. To end every session the user holds on all devices (e.g. after a suspected compromise), use `POST /api/v1/auth/logout-all`. See [Logout All Sessions](#logout-all-sessions). ## CSRF Protection @@ -239,6 +239,30 @@ curl -X POST http://localhost:8000/api/v1/auth/logout -b cookies.txt Terminates the session and clears the cookies. +### Logout All Sessions + +```bash +curl -X POST http://localhost:8000/api/v1/auth/logout-all \ + -b cookies.txt \ + -H "X-CSRF-Token: " +``` + +Terminates **every** session for the current user across all devices, including this one, and clears the cookies: + +```json +{ "message": "All sessions terminated. Please log in again.", "terminated_count": 3 } +``` + +To sign out only the *other* devices and stay logged in here, pass `keep_current=true`: + +```bash +curl -X POST "http://localhost:8000/api/v1/auth/logout-all?keep_current=true" \ + -b cookies.txt \ + -H "X-CSRF-Token: " +``` + +No re-authentication step is required, because this is the action a user needs when they can't trust their current session. It's rate limited per user (crudauth's `logout_all` default: 10 per hour). + ## Key Files | Component | Location | @@ -246,7 +270,7 @@ Terminates the session and clears the cookies. | `auth = CRUDAuth(...)` singleton | `backend/src/infrastructure/auth/setup.py` | | Dependencies | `backend/src/infrastructure/auth/dependencies.py` | | OAuth building blocks | `backend/src/infrastructure/auth/oauth.py` | -| Login/logout/OAuth routes | `backend/src/infrastructure/auth/routes.py` | +| Login/logout/logout-all/OAuth routes | `backend/src/infrastructure/auth/routes.py` | | HTTP exceptions (fastcrud re-export) | `backend/src/infrastructure/auth/http_exceptions.py` | | Auth settings | `backend/src/infrastructure/config/settings.py` (`AuthSettings`) | diff --git a/docs/user-guide/project-structure.md b/docs/user-guide/project-structure.md index 6ff6a1c8..6fc63ecd 100644 --- a/docs/user-guide/project-structure.md +++ b/docs/user-guide/project-structure.md @@ -96,7 +96,7 @@ infrastructure/ │ ├── setup.py # The `auth = CRUDAuth(...)` singleton (composition root) │ ├── dependencies.py # get_current_user / _superuser / _optional_user + Principal deps │ ├── oauth.py # crudauth OAuth building blocks (Google wired) -│ ├── routes.py # /auth/login, /logout, /oauth/google, /check-auth +│ ├── routes.py # /auth/login, /logout, /logout-all, /oauth/google, /check-auth │ └── http_exceptions.py # fastcrud HTTP exception re-export ├── cache/ # Redis/Memcached cache + decorator │ └── backends/