Repository navigation
Protect the todo API with signed session cookies #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| """Signed session cookies for protecting the API.""" | ||
|
|
||
| import base64 | ||
| import hashlib | ||
| import hmac | ||
| import json | ||
| import time | ||
|
|
||
| from fastapi import Cookie, HTTPException, status | ||
|
|
||
| from app.config import settings | ||
|
|
||
| SESSION_COOKIE = "todorabbit_session" | ||
|
|
||
|
|
||
| def _b64encode(raw: bytes) -> str: | ||
| return base64.urlsafe_b64encode(raw).decode().rstrip("=") | ||
|
|
||
|
|
||
| def _b64decode(value: str) -> bytes: | ||
| return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) | ||
|
|
||
|
|
||
| def _sign(payload: str) -> str: | ||
| digest = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).digest() | ||
| return _b64encode(digest) | ||
|
|
||
|
|
||
| def create_session_token(username: str, now: float | None = None) -> str: | ||
| """Create a signed token that expires after ``session_ttl_minutes``.""" | ||
| issued_at = time.time() if now is None else now | ||
| claims = {"sub": username, "exp": int(issued_at + settings.session_ttl_minutes * 60)} | ||
| payload = _b64encode(json.dumps(claims, separators=(",", ":")).encode()) | ||
| return f"{payload}.{_sign(payload)}" | ||
|
|
||
|
|
||
| def verify_session_token(token: str, now: float | None = None) -> str | None: | ||
| """Return the username for a valid, unexpired token, otherwise ``None``.""" | ||
| payload, separator, signature = token.partition(".") | ||
| if not separator or not hmac.compare_digest(signature, _sign(payload)): | ||
| return None | ||
|
|
||
| try: | ||
| claims = json.loads(_b64decode(payload)) | ||
| expires_at = int(claims["exp"]) | ||
| username = str(claims["sub"]) | ||
| except (ValueError, KeyError, TypeError): | ||
| return None | ||
|
|
||
| if expires_at <= (time.time() if now is None else now): | ||
| return None | ||
| return username | ||
|
|
||
|
|
||
| async def require_session( | ||
| session: str | None = Cookie(None, alias=SESSION_COOKIE), | ||
| ) -> str: | ||
| """Dependency that rejects requests without a valid session cookie.""" | ||
| if not settings.auth_enabled: | ||
| return "anonymous" | ||
|
|
||
| username = verify_session_token(session) if session else None | ||
| if username is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_401_UNAUTHORIZED, | ||
| detail="Not authenticated", | ||
| ) | ||
| return username | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| """Login and logout endpoints.""" | ||
|
|
||
| import hmac | ||
|
|
||
| from fastapi import APIRouter, HTTPException, Response, status | ||
| from pydantic import BaseModel | ||
|
|
||
| from app.auth import SESSION_COOKIE, create_session_token | ||
| from app.config import settings | ||
|
|
||
| router = APIRouter(prefix="/api/auth", tags=["auth"]) | ||
|
|
||
|
|
||
| class LoginRequest(BaseModel): | ||
| """Credentials submitted to start a session.""" | ||
|
|
||
| username: str | ||
| password: str | ||
|
|
||
|
|
||
| class SessionResponse(BaseModel): | ||
| """The user a session belongs to.""" | ||
|
|
||
| username: str | ||
|
|
||
|
|
||
| def _matches(provided: str, expected: str) -> bool: | ||
| return hmac.compare_digest(provided.encode(), expected.encode()) | ||
|
|
||
|
|
||
| @router.post("/login", response_model=SessionResponse) | ||
| async def login(credentials: LoginRequest, response: Response) -> SessionResponse: | ||
| """Exchange the configured credentials for a session cookie.""" | ||
| if not settings.auth_enabled: | ||
| raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Authentication is disabled") | ||
|
|
||
| username_ok = _matches(credentials.username, settings.auth_username) | ||
| password_ok = _matches(credentials.password, settings.auth_password) | ||
| if not (username_ok and password_ok): | ||
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") | ||
|
Comment on lines
+31
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: sed -n '1,55p' docker-compose.yml
sed -n '1,55p' frontend/nginx.conf
sed -n '25,55p' backend/app/routes/auth.pyRepository: coderabbitai/ToDoRabbit Length of output: 2470 🏁 Script executed: set -eu
printf '%s\n' '--- deployment files ---'
find . -maxdepth 3 -type f \( -name 'docker-compose*.yml' -o -name 'docker-compose*.yaml' -o -name 'Dockerfile' -o -name '*.conf' -o -name '*.yaml' -o -name '*.yml' \) -print | sort
printf '%s\n' '--- relevant controls and exposure references ---'
rg -n -i -F --glob '!*.lock' --glob '!*.min.*' \
'limit_req|rate.?limit|throttl|lockout|fail2ban|iptables|ufw|network_mode|ports:|8000:8000|proxy_pass|middleware|require_session|login' \
docker-compose.yml frontend backend .github 2>/dev/null || test "$?" -le 1
printf '%s\n' '--- backend entrypoint and app setup ---'
sed -n '1,180p' backend/app/main.py
sed -n '1,180p' backend/Dockerfile
printf '%s\n' '--- compose remainder ---'
sed -n '1,180p' docker-compose.ymlRepository: coderabbitai/ToDoRabbit Length of output: 2591 Protect login attempts at the backend entrypoint. When authentication is enabled, Add backend-side throttling, or remove the public backend port and apply a login-specific nginx limit to the remaining frontend entrypoint. An nginx-only limit cannot protect both currently reachable entrypoints. 🤖 Prompt for AI Agents |
||
|
|
||
| response.set_cookie( | ||
| SESSION_COOKIE, | ||
| create_session_token(credentials.username), | ||
| max_age=settings.session_ttl_minutes * 60, | ||
| httponly=True, | ||
| samesite="lax", | ||
| secure=settings.session_cookie_secure, | ||
| ) | ||
| return SessionResponse(username=credentials.username) | ||
|
|
||
|
|
||
| @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) | ||
| async def logout(response: Response) -> None: | ||
| """End the current session.""" | ||
| response.delete_cookie(SESSION_COOKIE, httponly=True, samesite="lax") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| """Tests for session-cookie authentication.""" | ||
|
|
||
| import pytest | ||
| from httpx import AsyncClient | ||
|
|
||
| from app.auth import SESSION_COOKIE, create_session_token, verify_session_token | ||
| from app.config import settings | ||
|
|
||
| CREDENTIALS = {"username": "admin", "password": "correct horse battery staple"} | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def auth_enabled(monkeypatch: pytest.MonkeyPatch) -> None: | ||
| monkeypatch.setattr(settings, "auth_enabled", True) | ||
| monkeypatch.setattr(settings, "auth_username", CREDENTIALS["username"]) | ||
| monkeypatch.setattr(settings, "auth_password", CREDENTIALS["password"]) | ||
| monkeypatch.setattr(settings, "session_secret", "test-secret") | ||
| monkeypatch.setattr(settings, "session_cookie_secure", False) | ||
|
|
||
|
|
||
| def test_token_round_trip(): | ||
| token = create_session_token("admin", now=1_000) | ||
| assert verify_session_token(token, now=1_001) == "admin" | ||
|
|
||
|
|
||
| def test_expired_token_is_rejected(monkeypatch: pytest.MonkeyPatch): | ||
| monkeypatch.setattr(settings, "session_secret", "test-secret") | ||
| token = create_session_token("admin", now=1_000) | ||
| assert verify_session_token(token, now=1_000 + settings.session_ttl_minutes * 60) is None | ||
|
|
||
|
|
||
| def test_tampered_token_is_rejected(monkeypatch: pytest.MonkeyPatch): | ||
| monkeypatch.setattr(settings, "session_secret", "test-secret") | ||
| payload, signature = create_session_token("admin", now=1_000).split(".") | ||
| forged = create_session_token("someone-else", now=1_000).split(".")[0] | ||
| assert verify_session_token(f"{forged}.{signature}", now=1_001) is None | ||
| assert verify_session_token(f"{payload}.bad-signature", now=1_001) is None | ||
| assert verify_session_token("not-a-token", now=1_001) is None | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_todos_require_a_session(client: AsyncClient, auth_enabled: None): | ||
| response = await client.get("/api/todos") | ||
| assert response.status_code == 401 | ||
| assert response.json()["detail"] == "Not authenticated" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_login_sets_a_http_only_session_cookie(client: AsyncClient, auth_enabled: None): | ||
| response = await client.post("/api/auth/login", json=CREDENTIALS) | ||
| assert response.status_code == 200 | ||
| assert response.json() == {"username": "admin"} | ||
|
|
||
| set_cookie = response.headers["set-cookie"] | ||
| assert set_cookie.startswith(f"{SESSION_COOKIE}=") | ||
| assert "HttpOnly" in set_cookie | ||
| assert "SameSite=lax" in set_cookie | ||
|
|
||
| todos = await client.get("/api/todos") | ||
| assert todos.status_code == 200 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_login_rejects_wrong_password(client: AsyncClient, auth_enabled: None): | ||
| response = await client.post( | ||
| "/api/auth/login", json={**CREDENTIALS, "password": "wrong"} | ||
| ) | ||
| assert response.status_code == 401 | ||
| assert SESSION_COOKIE not in response.headers.get("set-cookie", "") | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_logout_clears_the_session(client: AsyncClient, auth_enabled: None): | ||
| await client.post("/api/auth/login", json=CREDENTIALS) | ||
|
|
||
| response = await client.post("/api/auth/logout") | ||
| assert response.status_code == 204 | ||
|
|
||
| todos = await client.get("/api/todos") | ||
| assert todos.status_code == 401 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_health_check_stays_public(client: AsyncClient, auth_enabled: None): | ||
| response = await client.get("/api/health") | ||
| assert response.status_code == 200 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: coderabbitai/ToDoRabbit
Length of output: 243
🌐 Web query:
Starlette requests cookies parse_cookie quoted octal SimpleCookie source💡 Result:
🏁 Script executed:
Repository: coderabbitai/ToDoRabbit
Length of output: 3333
🏁 Script executed (no clone):
Length of output: 267
Compare the signature as bytes so a malformed cookie cannot cause a 500 error.
Starlette decodes quoted octal escapes in cookie values. A header such as
session="abc.\351"can produce the non-ASCII stringabc.é. This value reacheshmac.compare_digestbefore thetryblock, where it raisesTypeErrorinstead of producing the intended 401 response.🐛 Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents