2025-11-01 02:43:26 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2025-11-01 04:02:37 +00:00
|
|
|
import base64
|
2025-11-01 02:43:26 +00:00
|
|
|
import hashlib
|
2025-11-01 04:02:37 +00:00
|
|
|
import hmac
|
|
|
|
|
import os
|
2025-11-01 02:43:26 +00:00
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
import aiosqlite
|
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
2025-11-01 03:14:58 +00:00
|
|
|
from fastapi.responses import JSONResponse
|
2025-11-01 02:43:26 +00:00
|
|
|
|
|
|
|
|
from api.deps import error_response, get_db
|
|
|
|
|
from common import ApiModel
|
2025-11-01 03:14:58 +00:00
|
|
|
from security import JwtConfig, create_jwt
|
|
|
|
|
from settings import settings
|
2025-11-01 02:43:26 +00:00
|
|
|
from users import repository as users_db
|
|
|
|
|
from users.models import User
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth-v2"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RegisterBody(ApiModel):
|
|
|
|
|
email: str
|
|
|
|
|
password: str
|
|
|
|
|
display_name: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LoginBody(ApiModel):
|
|
|
|
|
email: str
|
|
|
|
|
password: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TokenResponse(ApiModel):
|
|
|
|
|
access_token: str
|
|
|
|
|
token_type: str = "bearer"
|
|
|
|
|
user: User
|
|
|
|
|
|
2025-11-01 04:02:37 +00:00
|
|
|
PBKDF2_ALG = "pbkdf2_sha256"
|
|
|
|
|
PBKDF2_ITER = 390000 # similar to Django default; adjust in settings if needed
|
|
|
|
|
SALT_BYTES = 16
|
|
|
|
|
|
2025-11-01 02:43:26 +00:00
|
|
|
|
|
|
|
|
def _hash_pw(pw: str) -> str:
|
2025-11-01 04:02:37 +00:00
|
|
|
salt = os.urandom(SALT_BYTES)
|
|
|
|
|
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, PBKDF2_ITER)
|
|
|
|
|
return f"{PBKDF2_ALG}${PBKDF2_ITER}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_pw(pw: str, stored: str) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
alg, iter_s, salt_b64, hash_b64 = stored.split("$", 3)
|
|
|
|
|
if alg != PBKDF2_ALG:
|
|
|
|
|
return False
|
|
|
|
|
iters = int(iter_s)
|
|
|
|
|
salt = base64.b64decode(salt_b64)
|
|
|
|
|
expected = base64.b64decode(hash_b64)
|
|
|
|
|
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, iters)
|
|
|
|
|
# constant-time compare
|
|
|
|
|
return hmac.compare_digest(dk, expected)
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
2025-11-01 02:43:26 +00:00
|
|
|
|
|
|
|
|
|
2025-11-01 03:14:58 +00:00
|
|
|
def _jwt_config() -> JwtConfig:
|
|
|
|
|
# Secrets can be provided base64-encoded via env; fallback to deterministic dev defaults (NOT for prod)
|
|
|
|
|
if settings.access_secret_b64:
|
|
|
|
|
access = base64.b64decode(settings.access_secret_b64)
|
|
|
|
|
else:
|
|
|
|
|
access = b"dev-access-secret-change-me-32bytes!!"[:32]
|
|
|
|
|
if settings.refresh_secret_b64:
|
|
|
|
|
refresh = base64.b64decode(settings.refresh_secret_b64)
|
|
|
|
|
else:
|
|
|
|
|
refresh = b"dev-refresh-secret-change-me-32bytes!!"[:32]
|
|
|
|
|
return JwtConfig(
|
|
|
|
|
issuer=settings.jwt_issuer,
|
|
|
|
|
audience=settings.jwt_audience,
|
|
|
|
|
access_secret=access,
|
|
|
|
|
refresh_secret=refresh,
|
|
|
|
|
access_ttl_seconds=settings.access_ttl_seconds,
|
|
|
|
|
refresh_ttl_seconds=settings.refresh_ttl_seconds,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _token_pair_for_user(user: User) -> tuple[str, str]:
|
|
|
|
|
cfg = _jwt_config()
|
|
|
|
|
sub = str(user.id)
|
|
|
|
|
access = create_jwt(cfg, sub, kind="access", extra={"user_id": user.id, "email": user.email})
|
|
|
|
|
refresh = create_jwt(cfg, sub, kind="refresh")
|
|
|
|
|
return access, refresh
|
|
|
|
|
|
|
|
|
|
|
2025-11-01 02:43:26 +00:00
|
|
|
@router.post("/register", response_model=TokenResponse, operation_id="register")
|
|
|
|
|
async def register(request: Request, body: RegisterBody, conn: aiosqlite.Connection = Depends(get_db)):
|
|
|
|
|
existing = await users_db.get_by_email(conn, body.email)
|
|
|
|
|
if existing:
|
|
|
|
|
return error_response(request, 400, "Email already registered")
|
|
|
|
|
uid = await users_db.insert_user(conn, body.email, body.display_name)
|
|
|
|
|
await users_db.set_local_credentials(conn, uid, _hash_pw(body.password))
|
|
|
|
|
user = await users_db.get_by_email(conn, body.email)
|
|
|
|
|
assert user is not None
|
2025-11-01 03:14:58 +00:00
|
|
|
access, refresh = _token_pair_for_user(user)
|
|
|
|
|
resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True))
|
|
|
|
|
# HttpOnly refresh cookie
|
|
|
|
|
resp.set_cookie(
|
|
|
|
|
key="refresh_token",
|
|
|
|
|
value=refresh,
|
|
|
|
|
httponly=True,
|
|
|
|
|
secure=False,
|
|
|
|
|
samesite="lax",
|
|
|
|
|
max_age=settings.refresh_ttl_seconds,
|
|
|
|
|
path="/api/v1/auth/refresh",
|
|
|
|
|
)
|
|
|
|
|
return resp
|
2025-11-01 02:43:26 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse, operation_id="loginV2")
|
|
|
|
|
async def login(request: Request, body: LoginBody, conn: aiosqlite.Connection = Depends(get_db)):
|
|
|
|
|
user: Optional[User] = await users_db.get_by_email(conn, body.email)
|
|
|
|
|
if not user:
|
|
|
|
|
return error_response(request, 401, "Invalid credentials")
|
|
|
|
|
stored = await users_db.get_local_password_hash(conn, user.id)
|
2025-11-01 04:02:37 +00:00
|
|
|
if not stored or not _verify_pw(body.password, stored):
|
2025-11-01 02:43:26 +00:00
|
|
|
return error_response(request, 401, "Invalid credentials")
|
2025-11-01 03:14:58 +00:00
|
|
|
access, refresh = _token_pair_for_user(user)
|
|
|
|
|
resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True))
|
|
|
|
|
resp.set_cookie(
|
|
|
|
|
key="refresh_token",
|
|
|
|
|
value=refresh,
|
|
|
|
|
httponly=True,
|
|
|
|
|
secure=False,
|
|
|
|
|
samesite="lax",
|
|
|
|
|
max_age=settings.refresh_ttl_seconds,
|
|
|
|
|
path="/api/v1/auth/refresh",
|
|
|
|
|
)
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RefreshResponse(ApiModel):
|
|
|
|
|
access_token: str
|
|
|
|
|
token_type: str = "bearer"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/refresh", response_model=RefreshResponse, operation_id="refreshV2")
|
|
|
|
|
async def refresh(request: Request):
|
|
|
|
|
token = request.cookies.get("refresh_token")
|
|
|
|
|
if not token:
|
|
|
|
|
return error_response(request, 401, "Unauthorized")
|
|
|
|
|
from security import verify_jwt
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
_h, payload = verify_jwt(_jwt_config(), token, expected_kind="refresh")
|
|
|
|
|
except Exception:
|
|
|
|
|
return error_response(request, 401, "Unauthorized")
|
|
|
|
|
user_id = str(payload.get("sub"))
|
|
|
|
|
access = create_jwt(_jwt_config(), user_id, kind="access")
|
|
|
|
|
return RefreshResponse(access_token=access)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/logout", operation_id="logoutV2")
|
|
|
|
|
async def logout():
|
|
|
|
|
resp = JSONResponse({"ok": True})
|
|
|
|
|
resp.delete_cookie("refresh_token", path="/api/v1/auth/refresh")
|
|
|
|
|
return resp
|