diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..8fd1acf --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.schemas.user import Token, UserCreate, UserLogin, UserResponse +from app.services.auth import ( + create_access_token, + create_user, + get_user_by_username, + verify_password, +) +from app.api.deps import get_current_user +from app.models.user import User + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserResponse, status_code=201) +async def register(data: UserCreate, db: AsyncSession = Depends(get_db)): + existing = await get_user_by_username(db, data.username) + if existing: + raise HTTPException(status_code=400, detail="Username already taken") + user = await create_user(db, data.username, data.email, data.password) + return user + + +@router.post("/login", response_model=Token) +async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): + user = await get_user_by_username(db, data.username) + if not user or not verify_password(data.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid credentials") + token = create_access_token(str(user.id)) + return Token(access_token=token) + + +@router.get("/me", response_model=UserResponse) +async def me(user: User = Depends(get_current_user)): + return user diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..2a01195 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,28 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User +from app.services.auth import decode_access_token + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + user_id = decode_access_token(credentials.credentials) + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" + ) + return user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..d04dfd3 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,19 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.api.auth import router as auth_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + + +app = FastAPI(title="Meeting Protocol Service", lifespan=lifespan) +app.include_router(auth_router) + + +@app.get("/api/health") +async def health(): + return {"status": "ok"} diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..d72e74b --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,33 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, EmailStr + +from app.models.user import UserRole + + +class UserCreate(BaseModel): + username: str + email: str + password: str + + +class UserLogin(BaseModel): + username: str + password: str + + +class UserResponse(BaseModel): + id: uuid.UUID + username: str + email: str + role: UserRole + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..f91d7d4 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,52 @@ +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.user import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(user_id: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + payload = {"sub": user_id, "exp": expire} + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> str | None: + try: + payload = jwt.decode( + token, settings.jwt_secret, algorithms=[settings.jwt_algorithm] + ) + return payload.get("sub") + except JWTError: + return None + + +async def get_user_by_username(db: AsyncSession, username: str) -> User | None: + result = await db.execute(select(User).where(User.username == username)) + return result.scalar_one_or_none() + + +async def create_user(db: AsyncSession, username: str, email: str, password: str) -> User: + user = User( + username=username, + email=email, + password_hash=hash_password(password), + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..902aca8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,64 @@ +import asyncio +import uuid + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.config import settings +from app.database import get_db +from app.main import app +from app.models import Base +from app.services.auth import create_access_token + +TEST_DB_URL = settings.database_url.replace("/meetproto", "/meetproto_test") + +engine_test = create_async_engine(TEST_DB_URL, echo=False) +async_session_test = async_sessionmaker(engine_test, expire_on_commit=False) + + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(autouse=True) +async def setup_db(): + async with engine_test.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine_test.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +async def override_get_db(): + async with async_session_test() as session: + yield session + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest_asyncio.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest_asyncio.fixture +async def auth_client(client: AsyncClient): + resp = await client.post("/api/auth/register", json={ + "username": f"testuser_{uuid.uuid4().hex[:8]}", + "email": f"test_{uuid.uuid4().hex[:8]}@example.com", + "password": "testpass123", + }) + token = (await client.post("/api/auth/login", json={ + "username": resp.json()["username"], + "password": "testpass123", + })).json()["access_token"] + client.headers["Authorization"] = f"Bearer {token}" + yield client diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..1df1d1e --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,66 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_register_success(client: AsyncClient): + resp = await client.post("/api/auth/register", json={ + "username": "newuser", + "email": "new@example.com", + "password": "pass123", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["username"] == "newuser" + assert data["role"] == "user" + assert "password" not in data + + +@pytest.mark.asyncio +async def test_register_duplicate(client: AsyncClient): + payload = {"username": "dupuser", "email": "dup@example.com", "password": "pass123"} + await client.post("/api/auth/register", json=payload) + resp = await client.post("/api/auth/register", json=payload) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_login_success(client: AsyncClient): + await client.post("/api/auth/register", json={ + "username": "loginuser", + "email": "login@example.com", + "password": "pass123", + }) + resp = await client.post("/api/auth/login", json={ + "username": "loginuser", + "password": "pass123", + }) + assert resp.status_code == 200 + assert "access_token" in resp.json() + + +@pytest.mark.asyncio +async def test_login_wrong_password(client: AsyncClient): + await client.post("/api/auth/register", json={ + "username": "wrongpw", + "email": "wrongpw@example.com", + "password": "pass123", + }) + resp = await client.post("/api/auth/login", json={ + "username": "wrongpw", + "password": "wrong", + }) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_me_authenticated(auth_client: AsyncClient): + resp = await auth_client.get("/api/auth/me") + assert resp.status_code == 200 + assert "username" in resp.json() + + +@pytest.mark.asyncio +async def test_me_unauthenticated(client: AsyncClient): + resp = await client.get("/api/auth/me") + assert resp.status_code == 403