From a0723b52370ef3411ea238a60e7d12313a0fc8c2 Mon Sep 17 00:00:00 2001 From: Indiana Date: Mon, 20 Jul 2026 15:26:58 +0000 Subject: [PATCH] feat: add login, session cookies, and get_current_user --- backend/app/deps.py | 30 ++++++++++++++++++++ backend/app/models/__init__.py | 3 +- backend/app/models/auth_session.py | 32 +++++++++++++++++++++ backend/app/routes/auth.py | 45 ++++++++++++++++++++++++++++-- backend/app/schemas.py | 5 ++++ backend/tests/test_auth.py | 25 +++++++++++++++++ 6 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 backend/app/deps.py create mode 100644 backend/app/models/auth_session.py diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..9461bd0 --- /dev/null +++ b/backend/app/deps.py @@ -0,0 +1,30 @@ +from datetime import datetime, timezone + +from fastapi import Cookie, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_db +from app.models.auth_session import AuthSession, hash_token +from app.models.user import User + +SESSION_COOKIE_NAME = "qm_session" + + +async def get_current_user( + qm_session: str | None = Cookie(default=None), + db: AsyncSession = Depends(get_db), +) -> User: + if qm_session is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated") + + token_hash = hash_token(qm_session) + result = await db.execute(select(AuthSession).where(AuthSession.token_hash == token_hash)) + session = result.scalar_one_or_none() + if session is None or session.expires_at < datetime.now(timezone.utc): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session expired") + + user = await db.get(User, session.user_id) + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found") + return user diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b2e47e8..f77aa4e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,3 +1,4 @@ +from app.models.auth_session import AuthSession from app.models.user import User -__all__ = ["User"] +__all__ = ["User", "AuthSession"] diff --git a/backend/app/models/auth_session.py b/backend/app/models/auth_session.py new file mode 100644 index 0000000..3a4ea57 --- /dev/null +++ b/backend/app/models/auth_session.py @@ -0,0 +1,32 @@ +import hashlib +import secrets +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + +SESSION_TTL = timedelta(days=14) + + +class AuthSession(Base): + __tablename__ = "auth_sessions" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id")) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + +def generate_session_token() -> tuple[str, str]: + raw = secrets.token_urlsafe(32) + return raw, hash_token(raw) + + +def hash_token(raw: str) -> str: + return hashlib.sha256(raw.encode()).hexdigest() diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index c1e8e6a..0987b97 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,11 +1,15 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_db +from app.deps import SESSION_COOKIE_NAME, get_current_user +from app.models.auth_session import AuthSession, SESSION_TTL, generate_session_token from app.models.user import User -from app.schemas import RegisterRequest, UserOut -from app.security import hash_password +from app.schemas import LoginRequest, RegisterRequest, UserOut +from app.security import hash_password, verify_password router = APIRouter(prefix="/auth", tags=["auth"]) @@ -25,3 +29,38 @@ async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)) await db.commit() await db.refresh(user) return user + + +@router.post("/login", response_model=UserOut) +async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)): + user = await db.scalar(select(User).where(User.username == payload.username)) + if user is None or not verify_password(payload.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials") + + raw_token, token_hash = generate_session_token() + session = AuthSession( + user_id=user.id, + token_hash=token_hash, + expires_at=datetime.now(timezone.utc) + SESSION_TTL, + ) + db.add(session) + await db.commit() + + response.set_cookie( + SESSION_COOKIE_NAME, + raw_token, + httponly=True, + samesite="lax", + max_age=int(SESSION_TTL.total_seconds()), + ) + return user + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(response: Response): + response.delete_cookie(SESSION_COOKIE_NAME) + + +@router.get("/me", response_model=UserOut) +async def me(user: User = Depends(get_current_user)): + return user diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 21f9f66..f265258 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -15,3 +15,8 @@ class UserOut(BaseModel): class Config: from_attributes = True + + +class LoginRequest(BaseModel): + username: str + password: str diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index fbae32f..fb1b427 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -19,3 +19,28 @@ async def test_register_duplicate_username_rejected(client): await client.post("/auth/register", json={"username": "medium1", "password": "spookyspooky"}) response = await client.post("/auth/register", json={"username": "medium1", "password": "anotherpass"}) assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_login_sets_cookie_and_me_returns_user(client): + await client.post("/auth/register", json={"username": "medium2", "password": "spookyspooky"}) + login_resp = await client.post("/auth/login", json={"username": "medium2", "password": "spookyspooky"}) + assert login_resp.status_code == 200 + assert "qm_session" in login_resp.cookies + + me_resp = await client.get("/auth/me") + assert me_resp.status_code == 200 + assert me_resp.json()["username"] == "medium2" + + +@pytest.mark.asyncio +async def test_login_wrong_password_rejected(client): + await client.post("/auth/register", json={"username": "medium3", "password": "spookyspooky"}) + response = await client.post("/auth/login", json={"username": "medium3", "password": "wrongpass"}) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_me_without_cookie_rejected(client): + response = await client.get("/auth/me") + assert response.status_code == 401