Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
38 changes: 38 additions & 0 deletions backend/src/infrastructure/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/integration/auth/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)."""
Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
28 changes: 26 additions & 2 deletions docs/user-guide/authentication/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -239,14 +239,38 @@ 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: <token-from-login-response>"
```

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: <token-from-login-response>"
```

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 |
|-----------|----------|
| `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`) |

Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
Loading