S.H.O.N.A.R._Desktop_Companion/backend/shonar/api/v1/auth.py
avi 76c867fca4 Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar
- shared/ = portable Android-origin sources vendored from deferred/desktop-server
  (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only)
- backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the
  old checkout, PYTHONPATH pins THIS backend's code over any editable install
- repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh
  watches shared/ + backend/
- Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010,
  self-migrates, /healthz ok
2026-09-14 17:14:54 -05:00

91 lines
3.1 KiB
Python

"""Auth endpoints (rate-limited)."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from shonar.api.deps import CurrentUser, SessionDep
from shonar.api.schemas_common import (
DeleteAccountRequest,
LoginRequest,
LogoutRequest,
RefreshRequest,
RegisterRequest,
TokenPair,
UserOut,
)
from shonar.core.config import get_settings
from shonar.core.ratelimit import auth_limit
from shonar.core.security import create_access_token
from shonar.services import auth as auth_service
from shonar.services.auth import AuthError
router = APIRouter(tags=["auth"])
@router.post("/auth/register", response_model=TokenPair, status_code=201)
@auth_limit
async def register(body: RegisterRequest, request: Request, session: SessionDep):
settings = get_settings()
if not settings.allow_registration:
raise HTTPException(403, "Registration is disabled on this server.") from None
try:
user = await auth_service.register_user(
session, body.email, body.password, body.display_name
)
except AuthError as e:
raise HTTPException(e.status_code, e.message) from None
access, ttl = create_access_token(user.id)
from shonar.services.auth import issue_refresh_token
refresh, _ = await issue_refresh_token(session, user.id, None, None)
return TokenPair(access_token=access, expires_in=ttl, refresh_token=refresh)
@router.post("/auth/login", response_model=TokenPair)
@auth_limit
async def login(body: LoginRequest, request: Request, session: SessionDep):
try:
user, refresh, device = await auth_service.login(
session, body.email, body.password, body.device_name, body.platform
)
except AuthError as e:
raise HTTPException(e.status_code, e.message) from None
access, ttl = create_access_token(user.id, device.id)
return TokenPair(
access_token=access, expires_in=ttl, refresh_token=refresh, device_id=device.id
)
@router.post("/auth/refresh", response_model=TokenPair)
@auth_limit
async def refresh(body: RefreshRequest, request: Request, session: SessionDep):
try:
user, new_refresh, device_id = await auth_service.rotate_refresh_token(
session, body.refresh_token
)
except AuthError as e:
raise HTTPException(e.status_code, e.message) from None
access, ttl = create_access_token(user.id, device_id)
return TokenPair(
access_token=access, expires_in=ttl, refresh_token=new_refresh, device_id=device_id
)
@router.post("/auth/logout", status_code=204)
async def logout(body: LogoutRequest, session: SessionDep):
await auth_service.logout(session, body.refresh_token)
@router.get("/auth/me", response_model=UserOut)
async def me(user: CurrentUser):
return user
@router.post("/auth/delete-account", status_code=202)
async def delete_account(body: DeleteAccountRequest, user: CurrentUser, session: SessionDep):
try:
await auth_service.delete_account(session, user, body.password)
except AuthError as e:
raise HTTPException(e.status_code, e.message) from None
return {"detail": "Account scheduled for deletion.", "grace_days": 30}