4fa79bad77
Security and frontend-correctness hardening: analytics IDOR fix, scraper log scrubbing, lowercase email normalisation (migration 009), stale-response guards in the plan hooks, busy flags cleared unconditionally across all useLatestRequest consumers, Calendar no longer blanks on mutation, plus dependabot/CI config and removal of the stray frontend/out.js. Conflicts came from main's later "day assertions" restructure, which made usePlannedDays read-only and replaced the calendar's per-day add/update with useDayAssertions.assertDay. Both intents kept: - usePlannedDays: branch's fetch machinery (isRefreshing, in-flight dedup, unguarded busy clear); mutators dropped as on main. - usePlanSync: branch's useLatestRequest guard also applied to main's new syncWithWish. - useDayAssertions / useLatestBalance (new on main): busy flag now cleared unconditionally, as the branch did for every other consumer. - useDayAssertions.assertDay gains the branch's `skipRefresh` option and a `refresh`, so Calendar's multi-day save keeps the batch shape (N writes, one trailing bumpPlanVersion + one assertions refresh) on main's assertDay path. Branch's M7 tests ported from the removed add/update API to assertDay. - PlanSyncProvider: branch's isAuthenticated gate kept; eviction-preview state stays removed as on main. - usePlannedDayExclusions.ts stays deleted (superseded by useDayAssertions). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
151 lines
5.3 KiB
Python
151 lines
5.3 KiB
Python
"""HTTP routes for auth: register, login, logout, me, refresh."""
|
|
|
|
from fastapi import APIRouter, Cookie, HTTPException, Response, status
|
|
from pydantic import BaseModel
|
|
from starlette.requests import Request
|
|
|
|
from app.config import settings
|
|
from app.dependencies import (
|
|
AuthServiceDep,
|
|
ChildRepositoryDep,
|
|
CurrentUserDep,
|
|
PersonalDataRepositoryDep,
|
|
WorkspaceRepositoryDep,
|
|
)
|
|
from app.rate_limit import limiter
|
|
from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse
|
|
from app.utils.errors import AuthError, ConflictError, InvitationError, UnauthorizedError
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
_REFRESH_COOKIE = "refresh_token"
|
|
_ACCESS_EXPIRES_IN = settings.jwt_access_token_expire_minutes * 60
|
|
|
|
|
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
|
@limiter.limit("10/minute")
|
|
async def register(
|
|
request: Request, payload: RegisterRequest, response: Response, service: AuthServiceDep
|
|
) -> TokenResponse:
|
|
try:
|
|
user, _pd = await service.register(
|
|
email=str(payload.email),
|
|
password=payload.password,
|
|
display_name=payload.display_name,
|
|
invite_token=payload.invite_token,
|
|
)
|
|
except ConflictError as exc:
|
|
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
|
except InvitationError as exc:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
|
|
access = service.create_access_token(user.id)
|
|
refresh = service.create_refresh_token(user.id)
|
|
_set_refresh_cookie(response, refresh)
|
|
return TokenResponse(access_token=access, expires_in=_ACCESS_EXPIRES_IN)
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
@limiter.limit("10/minute")
|
|
async def login(
|
|
request: Request, payload: LoginRequest, response: Response, service: AuthServiceDep
|
|
) -> TokenResponse:
|
|
try:
|
|
user = await service.login(str(payload.email), payload.password)
|
|
except AuthError as exc:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
|
|
|
|
access = service.create_access_token(user.id)
|
|
refresh = service.create_refresh_token(user.id)
|
|
_set_refresh_cookie(response, refresh)
|
|
return TokenResponse(access_token=access, expires_in=_ACCESS_EXPIRES_IN)
|
|
|
|
|
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def logout(response: Response) -> None:
|
|
response.delete_cookie(_REFRESH_COOKIE)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
@limiter.limit("30/minute")
|
|
async def refresh(
|
|
request: Request,
|
|
response: Response,
|
|
service: AuthServiceDep,
|
|
refresh_token: str | None = Cookie(default=None, alias=_REFRESH_COOKIE),
|
|
) -> TokenResponse:
|
|
if not refresh_token:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No refresh token")
|
|
try:
|
|
access = await service.refresh_access_token(refresh_token)
|
|
except UnauthorizedError as exc:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
|
|
return TokenResponse(access_token=access, expires_in=_ACCESS_EXPIRES_IN)
|
|
|
|
|
|
@router.get("/me", response_model=UserMeResponse)
|
|
async def me(current_user: CurrentUserDep) -> UserMeResponse:
|
|
# PersonalData is fetched separately when workspace context is needed.
|
|
# Here we return just the user-level data.
|
|
return UserMeResponse(
|
|
id=str(current_user.id),
|
|
email=current_user.email,
|
|
display_name="", # enriched by the frontend via workspace API
|
|
workspace_id=None, # client fetches via /api/workspace after login
|
|
)
|
|
|
|
|
|
class _ChildContext(BaseModel):
|
|
id: str
|
|
name: str
|
|
birth_date: str
|
|
|
|
|
|
class _UserContext(BaseModel):
|
|
personal_data_uuid: str
|
|
workspace_id: str
|
|
parent_name: str
|
|
children: list[_ChildContext]
|
|
# Mirrors `WorkspaceResponse.take_pl_on_red_days` — included here so the
|
|
# frontend's cold-load `refreshContext()` can build its `workspace`
|
|
# state from this response alone, without a follow-up `GET
|
|
# /workspace/:id` (see AGENTS.md H12 step 1).
|
|
take_pl_on_red_days: bool
|
|
|
|
|
|
@router.get("/context", response_model=_UserContext)
|
|
async def get_context(
|
|
current_user: CurrentUserDep,
|
|
pd_repo: PersonalDataRepositoryDep,
|
|
child_repo: ChildRepositoryDep,
|
|
ws_repo: WorkspaceRepositoryDep,
|
|
) -> _UserContext:
|
|
"""Return workspace + children for the current user. Used by the dev harness."""
|
|
pd = await pd_repo.get_by_id(current_user.personal_data_uuid)
|
|
if pd is None:
|
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "No parent profile found")
|
|
children = await child_repo.list_by_workspace(pd.workspace_id)
|
|
workspace = await ws_repo.get_by_id(pd.workspace_id)
|
|
if workspace is None:
|
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "No workspace found")
|
|
return _UserContext(
|
|
personal_data_uuid=str(pd.uuid),
|
|
workspace_id=str(pd.workspace_id),
|
|
parent_name=pd.name,
|
|
children=[
|
|
_ChildContext(id=str(c.id), name=c.name, birth_date=str(c.birth_date)) for c in children
|
|
],
|
|
take_pl_on_red_days=workspace.take_pl_on_red_days,
|
|
)
|
|
|
|
|
|
def _set_refresh_cookie(response: Response, token: str) -> None:
|
|
response.set_cookie(
|
|
key=_REFRESH_COOKIE,
|
|
value=token,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="lax",
|
|
max_age=settings.jwt_refresh_token_expire_days * 86400,
|
|
)
|