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)