mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 01:43:21 +08:00
Harden auth with revocable sessions, cookies, and single-login default.
Issue short-lived access JWTs backed by AuthSession rows, HttpOnly cookies with refresh rotation, idle timeout, session management UI, WebCRT ownership caps, and optional Redis login rate limits; new logins revoke other sessions by default. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ba33ab5c4f
commit
20c2fcd496
34 changed files with 1462 additions and 149 deletions
94
netx_api/auth_cookies.py
Normal file
94
netx_api/auth_cookies.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Auth cookie helpers (HttpOnly access + refresh)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Response
|
||||
from starlette.requests import Request
|
||||
|
||||
from .config import settings
|
||||
|
||||
COOKIE_ACCESS = "netx_at"
|
||||
COOKIE_REFRESH = "netx_rt"
|
||||
|
||||
|
||||
def _cookie_secure(request: Request | None = None) -> bool:
|
||||
configured = getattr(settings, "auth_cookie_secure", None)
|
||||
if configured is True:
|
||||
return True
|
||||
if configured is False:
|
||||
return False
|
||||
# Auto: secure when request is HTTPS (or behind TLS-terminating proxy).
|
||||
if request is None:
|
||||
return False
|
||||
if request.url.scheme == "https":
|
||||
return True
|
||||
fwd = str(request.headers.get("x-forwarded-proto") or "").split(",")[0].strip().lower()
|
||||
return fwd == "https"
|
||||
|
||||
|
||||
def _cookie_samesite() -> str:
|
||||
raw = str(getattr(settings, "auth_cookie_samesite", "lax") or "lax").strip().lower()
|
||||
if raw in ("lax", "strict", "none"):
|
||||
return raw
|
||||
return "lax"
|
||||
|
||||
|
||||
def access_cookie_max_age() -> int:
|
||||
return max(300, int(getattr(settings, "auth_token_ttl_sec", 3600) or 3600))
|
||||
|
||||
|
||||
def refresh_cookie_max_age() -> int:
|
||||
return max(3600, int(getattr(settings, "auth_refresh_ttl_sec", 604800) or 604800))
|
||||
|
||||
|
||||
def set_auth_cookies(
|
||||
response: Response,
|
||||
*,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
request: Request | None = None,
|
||||
) -> None:
|
||||
if not bool(getattr(settings, "auth_cookie_enabled", True)):
|
||||
return
|
||||
secure = _cookie_secure(request)
|
||||
samesite = _cookie_samesite()
|
||||
# SameSite=None requires Secure.
|
||||
if samesite == "none":
|
||||
secure = True
|
||||
common: dict[str, Any] = {
|
||||
"httponly": True,
|
||||
"secure": secure,
|
||||
"samesite": samesite,
|
||||
"path": "/",
|
||||
}
|
||||
response.set_cookie(
|
||||
COOKIE_ACCESS,
|
||||
access_token,
|
||||
max_age=access_cookie_max_age(),
|
||||
**common,
|
||||
)
|
||||
response.set_cookie(
|
||||
COOKIE_REFRESH,
|
||||
refresh_token,
|
||||
max_age=refresh_cookie_max_age(),
|
||||
**common,
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response, *, request: Request | None = None) -> None:
|
||||
secure = _cookie_secure(request)
|
||||
samesite = _cookie_samesite()
|
||||
if samesite == "none":
|
||||
secure = True
|
||||
for name in (COOKIE_ACCESS, COOKIE_REFRESH):
|
||||
response.delete_cookie(name, path="/", secure=secure, httponly=True, samesite=samesite)
|
||||
|
||||
|
||||
def read_access_cookie(request: Request) -> str:
|
||||
return str(request.cookies.get(COOKIE_ACCESS) or "").strip()
|
||||
|
||||
|
||||
def read_refresh_cookie(request: Request) -> str:
|
||||
return str(request.cookies.get(COOKIE_REFRESH) or "").strip()
|
||||
Loading…
Add table
Add a link
Reference in a new issue