Newer
Older
bugtrail / server / app / routers / auth.py
import contextlib
from datetime import UTC, datetime

from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from ..db import get_session
from ..deps import require_user
from ..models import Session, User
from ..schemas import LoginIn, RegisterIn, TokenOut, UserOut
from ..security import (
    SESSION_COOKIE_NAME,
    generate_session_token,
    hash_password,
    hash_token,
    session_expiry,
    verify_password,
)

router = APIRouter(prefix="/auth", tags=["auth"])


async def issue_session(db: AsyncSession, user: User, client: str, response: Response) -> TokenOut:
    token = generate_session_token()
    expires_at = session_expiry(client)
    db.add(Session(user_id=user.id, token_hash=hash_token(token), client=client, expires_at=expires_at))
    await db.flush()
    if client == "web":
        ttl = max(int((expires_at - datetime.now(UTC)).total_seconds()), 0)
        response.set_cookie(
            SESSION_COOKIE_NAME,
            token,
            httponly=True,
            samesite="lax",
            secure=False,  # behind TLS in prod; flip via env when deploying
            max_age=ttl,
            path="/",
        )
    return TokenOut(token=token, expires_at=expires_at)


@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterIn, response: Response, db: AsyncSession = Depends(get_session)) -> UserOut:
    existing = await db.execute(select(User).where(User.email == body.email.lower()))
    if existing.scalar_one_or_none() is not None:
        raise HTTPException(status.HTTP_409_CONFLICT, "Email already registered")
    user = User(email=body.email.lower(), nickname=body.nickname, password_hash=hash_password(body.password))
    db.add(user)
    await db.commit()
    await db.refresh(user)
    await issue_session(db, user, "web", response)
    await db.commit()
    return UserOut.model_validate(user)


@router.post("/login")
async def login(
    body: LoginIn,
    request: Request,
    response: Response,
    db: AsyncSession = Depends(get_session),
) -> UserOut | TokenOut:
    client = request.headers.get("X-Client", "web")
    if client not in ("web", "extension"):
        client = "web"
    result = await db.execute(select(User).where(User.email == body.email.lower()))
    user = result.scalar_one_or_none()
    if user is None or not verify_password(body.password, user.password_hash):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid email or password")
    token_out = await issue_session(db, user, client, response)
    await db.commit()
    if client == "extension":
        return token_out
    return UserOut.model_validate(user)


@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(request: Request, response: Response, db: AsyncSession = Depends(get_session)) -> None:
    auth = request.headers.get("Authorization", "")
    token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else request.cookies.get(
        SESSION_COOKIE_NAME
    )
    if token:
        result = await db.execute(select(Session).where(Session.token_hash == hash_token(token)))
        sess = result.scalar_one_or_none()
        if sess is not None:
            await db.delete(sess)
            await db.commit()
    with contextlib.suppress(KeyError):
        response.delete_cookie(SESSION_COOKIE_NAME, path="/")


@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(require_user)) -> UserOut:
    return UserOut.model_validate(user)