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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ TUTORAI_BUILD_HASH=dev-build
OLLAMA_BASE_URL=http://localhost:11434

# CORS Configuration
# PRODUCTION: set this to your exact deployed origin(s), e.g. https://tutor.example.com
# The cookie-session CSRF check trusts these origins for cross-checks — a wrong or
# missing value here will 403 cookie-authenticated POSTs from the real domain.
CORS_ALLOW_ORIGIN=http://localhost:3000,http://localhost:5173

# OpenAI Configuration
Expand All @@ -31,6 +34,9 @@ DATABASE_URL=sqlite:///./var/tutorai.db
SECRET_KEY=dev-secret-key-change-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRATION_HOURS=24
# Session cookie: set AUTH_COOKIE_SECURE=true in any HTTPS deployment so the
# browser only sends the auth cookie over TLS. Left false for plain-HTTP local dev.
AUTH_COOKIE_SECURE=false

# File Upload Configuration
UPLOAD_DIR=./var/uploads
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ Settings are centralized in `config/settings.py` and loaded from `.env` via `pyt
- `DEBUG`: enables development behavior and allows the dev JWT secret fallback. Production requires `SECRET_KEY`.
- `GLOBAL_LOG_LEVEL`: controls Uvicorn log level when running `main.py`.
- `SECRET_KEY`: JWT signing key; required when `DEBUG=false`.
- `AUTH_COOKIE_SECURE`: adds `Secure` to the HttpOnly session cookie; set `true` on any HTTPS deployment, `false` (default) for plain-HTTP local dev.
- `DATABASE_URL`: defaults to `sqlite:///./var/tutorai.db`; non-SQLite URLs use SQLAlchemy defaults.
- `CORS_ALLOW_ORIGIN`: comma-separated origins; `*` is handled through CORS regex.
- `CORS_ALLOW_ORIGIN`: comma-separated origins; `*` is handled through CORS regex. Also trusted by the cookie-session CSRF check, so in production set it to the real deployed origin(s) or cookie-authenticated POSTs will be rejected.
- `UPLOAD_DIR`: defaults to `./var/uploads`.
- `MAX_UPLOAD_SIZE_MB`: integer upload size limit, default `100`.
- `VECTOR_DB_PATH`: defaults to `./var/vector_db`.
Expand Down
10 changes: 10 additions & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ class Settings:
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRATION_HOURS: int = 24

# Session cookie (HttpOnly) — the browser-facing auth channel. The JWT is
# still accepted via Authorization: Bearer for tests, tools, and API clients.
AUTH_COOKIE_NAME: str = "token"
# Secure requires HTTPS; local dev and LAN testing run plain HTTP, so it is
# opt-in via env for deployments behind TLS.
AUTH_COOKIE_SECURE: bool = (
os.getenv("AUTH_COOKIE_SECURE", "false").lower() == "true"
)
AUTH_COOKIE_SAMESITE: str = "lax"

# File upload configuration
UPLOAD_DIR: str = os.getenv("UPLOAD_DIR", "./var/uploads")
_upload_mb = os.getenv("MAX_UPLOAD_SIZE_MB", "100")
Expand Down
54 changes: 54 additions & 0 deletions gateway/http/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
from contextlib import asynccontextmanager
from urllib.parse import urlsplit

from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
Expand Down Expand Up @@ -130,6 +131,59 @@ async def reject_legacy_realtime_path(request: Request, call_next):
)
return await call_next(request)

@app.middleware("http")
async def csrf_origin_check(request: Request, call_next):
"""CSRF guard for cookie-authenticated state changes.

The session cookie is attached by the browser automatically, so a
malicious site could otherwise trigger authenticated mutations. This is
the second line of defence behind SameSite=Lax on the cookie.

Scope: only requests authenticated *solely* by the cookie. A request
also carrying `Authorization: Bearer` is not a CSRF vector (a cross-site
page cannot set that header without a CORS preflight we control), so
tests/curl/SDKs are unaffected.

For a cookie-only unsafe request the `Origin` (or `Referer`) MUST be
present and match this host or a configured CORS origin — a *missing*
origin is rejected too, closing the "no Origin header" bypass. The
expected host honours `X-Forwarded-Proto`/`Host` so the same-origin
check still works behind a TLS-terminating reverse proxy.

Auth-establishment endpoints (signin/signup/login) are exempt: they
don't act on an existing session, so cookie-CSRF doesn't apply.
"""
is_auth_establish = request.url.path.endswith(("/signin", "/signup", "/login"))
if (
request.method not in ("GET", "HEAD", "OPTIONS")
and not is_auth_establish
and request.cookies.get(settings.AUTH_COOKIE_NAME)
):
auth = request.headers.get("authorization", "")
bearer = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
has_bearer = bearer not in ("", "null", "undefined")
if not has_bearer:
origin = request.headers.get("origin")
if not origin and (referer := request.headers.get("referer")):
parsed = urlsplit(referer)
origin = f"{parsed.scheme}://{parsed.netloc}"
proto = (
request.headers.get("x-forwarded-proto", request.url.scheme)
.split(",")[0]
.strip()
)
host = request.headers.get("x-forwarded-host", request.url.netloc)
same_origin = f"{proto}://{host}"
allowed = bool(origin) and (
origin in settings.cors_origins_list or origin == same_origin
)
if not allowed:
return JSONResponse(
status_code=403,
content={"detail": "Origin check failed"},
)
return await call_next(request)

# Health — no version prefix, matches Docker healthcheck and compose
app.include_router(health.router)

Expand Down
27 changes: 22 additions & 5 deletions gateway/http/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""FastAPI dependency injection — auth guard + service factories."""

from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import InvalidTokenError, decode
from sqlalchemy.orm import Session
Expand All @@ -13,20 +13,37 @@
from governance.self_regulation.service import SelfRegulationService
from learning.supports.service import SupportsService

security = HTTPBearer()
security = HTTPBearer(auto_error=False)


# ── Auth guard ────────────────────────────────────────────────────────────────


async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: Session = Depends(get_db),
) -> User:
"""Decode JWT and return the authenticated user."""
"""Decode the JWT and return the authenticated user.

An explicit `Authorization: Bearer` token wins (tests, tools, API clients
state exactly who they are); otherwise the HttpOnly session cookie — the
browser channel — is used. Legacy UI code that still sends the literal
strings "null"/"undefined" (from a now-empty localStorage) is treated as
sending nothing, so those requests fall through to the cookie.
"""
bearer = credentials.credentials if credentials else None
if bearer in ("null", "undefined", ""):
bearer = None
token = bearer or request.cookies.get(settings.AUTH_COOKIE_NAME)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
try:
payload = decode(
credentials.credentials,
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM],
)
Expand Down
54 changes: 47 additions & 7 deletions gateway/http/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ def _create_token(user_id: str) -> str:
)


def _set_auth_cookie(response: Response, token: str) -> None:
"""Attach the session JWT as an HttpOnly cookie — the browser auth channel.

HttpOnly keeps the token out of reach of any script running on the page
(XSS cannot exfiltrate it); SameSite=Lax stops the browser attaching it to
cross-site requests. `Secure` is enabled via AUTH_COOKIE_SECURE on TLS
deployments.
"""
response.set_cookie(
key=settings.AUTH_COOKIE_NAME,
value=token,
max_age=settings.JWT_EXPIRATION_HOURS * 3600,
httponly=True,
secure=settings.AUTH_COOKIE_SECURE,
samesite=settings.AUTH_COOKIE_SAMESITE,
path="/",
)


def _user_payload(token: str, user: User) -> dict:
return {
"token": token,
Expand Down Expand Up @@ -82,9 +101,10 @@ async def get_session_user(current_user: User = Depends(get_current_user)):
@router.post("/signin")
async def sign_in(
request: SignInRequest,
response: Response,
svc: AccountService = Depends(get_account_service),
):
"""Sign in — UI calls /auths/signin."""
"""Sign in — UI calls /auths/signin. Sets the HttpOnly session cookie."""
user = svc.authenticate(request.email, request.password)
if not user:
raise HTTPException(
Expand All @@ -94,24 +114,28 @@ async def sign_in(
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Account inactive"
)
return _user_payload(_create_token(user.id), user)
token = _create_token(user.id)
_set_auth_cookie(response, token)
return _user_payload(token, user)


@router.post("/login")
async def login(
request: SignInRequest,
response: Response,
svc: AccountService = Depends(get_account_service),
):
"""Login alias — kept for internal/tool use."""
return await sign_in(request, svc)
return await sign_in(request, response, svc)


@router.post("/signup")
async def signup(
request: SignUpRequest,
response: Response,
svc: AccountService = Depends(get_account_service),
):
"""Sign up — first user becomes admin."""
"""Sign up — first user becomes admin. Sets the HttpOnly session cookie."""
is_admin = svc.count_users() == 0
try:
user = svc.create_user(
Expand All @@ -124,13 +148,29 @@ async def signup(
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return _user_payload(_create_token(user.id), user)
token = _create_token(user.id)
_set_auth_cookie(response, token)
return _user_payload(token, user)


@router.post("/cookie")
async def establish_cookie_session(
response: Response,
current_user: User = Depends(get_current_user),
):
"""Exchange a Bearer-authenticated request for an HttpOnly cookie session.

Used by flows that receive a token out-of-band (e.g. OAuth URL fragments)
so the browser can continue with cookie auth instead of storing the token.
"""
_set_auth_cookie(response, _create_token(current_user.id))
return {"status": "success"}


@router.get("/signout")
async def sign_out(response: Response):
"""Sign out — clears session cookie."""
response.delete_cookie("token")
"""Sign out — clears the session cookie."""
response.delete_cookie(settings.AUTH_COOKIE_NAME, path="/")
return {"status": "success"}


Expand Down
31 changes: 27 additions & 4 deletions gateway/realtime/socket.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
"""Socket.IO ASGI sub-application mounted at /realtime.

The client connects to TUTOR_BASE_URL with path='/realtime/socket.io'.
Authentication is via JWT token passed as `token` query parameter or
Authorization header.
Authentication is via the HttpOnly session cookie (browser flow) or a JWT
passed in the Socket.IO auth payload / `token` query parameter (tools, tests).
"""

import logging
import time
from http.cookies import SimpleCookie
from typing import Any

import socketio

from config import settings
from gateway.http.dependencies import decode_jwt_token

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -58,16 +60,37 @@ async def _broadcast_usage():
log.debug("Broadcast usage: %s", models)


def _token_from_cookie(environ: dict) -> str | None:
"""Extract the session JWT from the HTTP cookie header of the handshake."""
raw = environ.get("HTTP_COOKIE")
if not raw:
return None
cookie = SimpleCookie()
try:
cookie.load(raw)
except Exception:
return None
morsel = cookie.get(settings.AUTH_COOKIE_NAME)
return morsel.value if morsel else None


@sio.event
async def connect(sid: str, environ: dict, auth: dict | None = None):
"""Authenticate on connect. Disconnect immediately if no valid token."""
token = None

# 1. Try auth dict (from Socket.IO client auth option)
# 1. Try auth dict (from Socket.IO client auth option). Older clients may
# send the literal strings "null"/"undefined" when nothing is stored.
if auth and isinstance(auth, dict):
token = auth.get("token")
if token in ("null", "undefined", ""):
token = None

# 2. HttpOnly session cookie — the browser flow (JS never sees the token).
if not token:
token = _token_from_cookie(environ)

# 2. Fall back to query string: ?token=<jwt>
# 3. Fall back to query string: ?token=<jwt>
if not token:
qs = environ.get("QUERY_STRING", "")
for part in qs.split("&"):
Expand Down
Loading
Loading