from __future__ import annotations import hashlib from typing import Optional import aiosqlite from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from api.deps import error_response, get_db from common import ApiModel from security import JwtConfig, create_jwt from settings import settings 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 def _hash_pw(pw: str) -> str: # Placeholder; replace with proper hashing (bcrypt/argon2) later return hashlib.sha256(pw.encode("utf-8")).hexdigest() def _jwt_config() -> JwtConfig: # Secrets can be provided base64-encoded via env; fallback to deterministic dev defaults (NOT for prod) import base64 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 @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 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 @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) if not stored or stored != _hash_pw(body.password): return error_response(request, 401, "Invalid credentials") 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