diff --git a/.env.example b/.env.example index 4a1d6b3..e6d8532 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,29 @@ ENV=dev # dev | prod | demo DATABASE_URL=postgresql+psycopg2://evsy:evsy@db:5432/evsy FRONTEND_URL=http://localhost:3000 + # Frontend VITE_ENV=dev # dev | prod | demo VITE_API_URL=http://localhost:8000/api/v1 VITE_LOG_LEVEL=error -__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=demo.evsy.dev \ No newline at end of file +__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=demo.evsy.dev + + +# Auth + +## Secret key for signing JWTs. Use a secure 32+ character string. +SECRET_KEY=YOUR_32_CHAR_SECRET_KEY + +## GitHub OAuth credentials +## Create your app here: https://github.com/settings/developers +## Set "Authorization callback URL" to: +## http://localhost:8000/api/v1/auth/oauth/callback +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +## Google OAuth credentials +## Create credentials here: https://console.cloud.google.com/apis/credentials +## Set "Authorized redirect URI" to: +## http://localhost:8000/api/v1/auth/oauth/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= \ No newline at end of file diff --git a/Makefile b/Makefile index 113136b..baf5a43 100644 --- a/Makefile +++ b/Makefile @@ -4,11 +4,11 @@ up: down: docker compose down -migrate: # e. g. make revision name="add auth" +migrate: docker compose exec backend alembic upgrade head -revision: - docker compose exec backend alembic revision -m "$(name)" +revision: # e. g. make revision name="add auth" + docker compose exec backend alembic revision --autogenerate -m "$(name)" dev: docker compose -f docker-compose.dev.yaml up --build -d \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore index 0bafb3f..479f20f 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -9,9 +9,6 @@ __pycache__/ env/ venv/ -# Alembic -migrations/versions/ - # Database files *.sqlite3 *.db diff --git a/backend/app/api/v1/routes/admin.py b/backend/app/api/v1/routes/admin.py index a9b908a..83750e3 100644 --- a/backend/app/api/v1/routes/admin.py +++ b/backend/app/api/v1/routes/admin.py @@ -1,10 +1,11 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends from app.modules.admin.io.router import router as io_router from app.modules.admin.reset.router import router as reset_router from app.modules.admin.seed.router import router as seed_router +from app.modules.auth.token import get_current_user -router = APIRouter(prefix="/admin") +router = APIRouter(prefix="/admin", dependencies=[Depends(get_current_user)]) router.include_router(io_router) router.include_router(seed_router) diff --git a/backend/app/api/v1/routes/auth.py b/backend/app/api/v1/routes/auth.py new file mode 100644 index 0000000..8f73b51 --- /dev/null +++ b/backend/app/api/v1/routes/auth.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from app.modules.auth.router import router as auth_router + +router = APIRouter(prefix="/auth", tags=["auth"]) + +router.include_router(auth_router) diff --git a/backend/app/api/v1/routes/events.py b/backend/app/api/v1/routes/events.py index 622bf0c..aca5e78 100644 --- a/backend/app/api/v1/routes/events.py +++ b/backend/app/api/v1/routes/events.py @@ -11,13 +11,16 @@ from sqlalchemy.orm import Session from app.core.database import get_db +from app.modules.auth.token import get_current_user from app.modules.events import crud as event_crud from app.modules.events.schemas import EventCreate, EventOut from app.modules.events.service import generate_json_schema_for_event from app.modules.fields.crud import get_fields_by_ids from app.modules.tags.crud import get_or_create_tags -router = APIRouter(prefix="/events", tags=["events"]) +router = APIRouter( + prefix="/events", tags=["events"], dependencies=[Depends(get_current_user)] +) @router.post( diff --git a/backend/app/api/v1/routes/fields.py b/backend/app/api/v1/routes/fields.py index a2ddb3d..a80c8ff 100644 --- a/backend/app/api/v1/routes/fields.py +++ b/backend/app/api/v1/routes/fields.py @@ -2,10 +2,13 @@ from sqlalchemy.orm import Session from app.core.database import get_db +from app.modules.auth.token import get_current_user from app.modules.fields import crud as field_crud from app.modules.fields.schemas import FieldCreate, FieldOut, FieldOutWithEventCount -router = APIRouter(prefix="/fields", tags=["fields"]) +router = APIRouter( + prefix="/fields", tags=["fields"], dependencies=[Depends(get_current_user)] +) @router.post( diff --git a/backend/app/api/v1/routes/tags.py b/backend/app/api/v1/routes/tags.py index 4b531b3..bd9af87 100644 --- a/backend/app/api/v1/routes/tags.py +++ b/backend/app/api/v1/routes/tags.py @@ -2,10 +2,13 @@ from sqlalchemy.orm import Session from app.core.database import get_db +from app.modules.auth.token import get_current_user from app.modules.tags import crud as tag_crud from app.modules.tags.schemas import TagCreate, TagOut -router = APIRouter(prefix="/tags", tags=["tags"]) +router = APIRouter( + prefix="/tags", tags=["tags"], dependencies=[Depends(get_current_user)] +) @router.post( diff --git a/backend/app/core/guard.py b/backend/app/core/guard.py new file mode 100644 index 0000000..85be048 --- /dev/null +++ b/backend/app/core/guard.py @@ -0,0 +1,17 @@ +from fastapi import Depends, HTTPException + +from app.settings import get_settings + + +def ensure_not_demo(settings=Depends(get_settings)): + if settings.is_demo: + raise HTTPException( + status_code=403, detail="This action is not allowed in demo mode." + ) + + +def ensure_dev(settings=Depends(get_settings)): + if not settings.is_dev: + raise HTTPException( + status_code=403, detail="This action is allowed only in development." + ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/factory.py b/backend/app/factory.py index 1fc94fd..7e3d0f7 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -1,11 +1,26 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import sessionmaker -from app.api.v1.routes import admin, events, fields, generic, tags +from app.api.v1.routes import admin, auth, events, fields, generic, tags +from app.modules.auth.schemas import UserCreate +from app.modules.auth.service import create_user_if_not_exists from app.settings import Settings -def create_app(settings: Settings) -> FastAPI: +def create_app(settings: Settings, SessionLocal: sessionmaker) -> FastAPI: + + @asynccontextmanager + async def lifespan(app: FastAPI): + if settings.is_demo: + with SessionLocal() as db: + create_user_if_not_exists( + db, UserCreate(email="demo@evsy.dev", password="bestructured") + ) + yield + app = FastAPI( title="Evsy API", description="Evsy is a service for managing and tracking product events.", @@ -30,6 +45,7 @@ def create_app(settings: Settings) -> FastAPI: ], debug=settings.is_dev, root_path="/api", + lifespan=lifespan, ) app.state.settings = settings @@ -59,6 +75,7 @@ def create_app(settings: Settings) -> FastAPI: app.include_router(fields.router, prefix="/v1", tags=["fields"]) app.include_router(generic.router, prefix="/v1", tags=["generic"]) app.include_router(admin.router, prefix="/v1", tags=["admin"]) + app.include_router(auth.router, prefix="/v1", tags=["auth"]) @app.get("/") def read_root(): diff --git a/backend/app/main.py b/backend/app/main.py index 7cbea77..2469c92 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,4 +13,4 @@ sys.exit(1) engine, SessionLocal = init_db(settings) -app = create_app(settings) +app = create_app(settings, SessionLocal) diff --git a/backend/app/modules/admin/io/router.py b/backend/app/modules/admin/io/router.py index 7f25560..85a91aa 100644 --- a/backend/app/modules/admin/io/router.py +++ b/backend/app/modules/admin/io/router.py @@ -2,6 +2,7 @@ from sqlalchemy.orm import Session from app.core.database import get_db +from app.core.guard import ensure_not_demo from .schemas import ExportBundle from .service import ExportTarget, ImportSource, export_to, import_from @@ -48,6 +49,7 @@ def export_data( 405: {"description": "Import not allowed on non-empty database"}, 501: {"description": "Import method not implemented"}, }, + dependencies=[Depends(ensure_not_demo)], ) async def import_data( request: Request, diff --git a/backend/app/modules/admin/reset/router.py b/backend/app/modules/admin/reset/router.py index 8f8e654..a415e5b 100644 --- a/backend/app/modules/admin/reset/router.py +++ b/backend/app/modules/admin/reset/router.py @@ -2,6 +2,7 @@ from sqlalchemy.orm import Session from app.core.database import get_db +from app.core.guard import ensure_not_demo from .service import count_entities, reset_database @@ -19,6 +20,7 @@ responses={ 200: {"description": "Reset performed or dry-run preview returned"}, }, + dependencies=[Depends(ensure_not_demo)], ) def reset_all_data( dry_run: bool = Query( diff --git a/backend/app/modules/admin/seed/router.py b/backend/app/modules/admin/seed/router.py index 5666c08..3917944 100644 --- a/backend/app/modules/admin/seed/router.py +++ b/backend/app/modules/admin/seed/router.py @@ -2,6 +2,7 @@ from sqlalchemy.orm import Session from app.core.database import get_db +from app.core.guard import ensure_not_demo from .service import seed_all @@ -17,6 +18,7 @@ 201: {"description": "Seeding completed successfully"}, 405: {"description": "Seeding not allowed on non-empty database"}, }, + dependencies=[Depends(ensure_not_demo)], ) def seed_data(db: Session = Depends(get_db)): seed_all(db) diff --git a/backend/app/modules/auth/crud.py b/backend/app/modules/auth/crud.py new file mode 100644 index 0000000..a2c38a5 --- /dev/null +++ b/backend/app/modules/auth/crud.py @@ -0,0 +1,29 @@ +from sqlalchemy.orm import Session + +from app.modules.auth.models import User + + +def get_user_by_email(db: Session, email: str) -> User | None: + return db.query(User).filter(User.email == email).first() + + +def create_user(db: Session, *, email: str, hashed_pw: str) -> User: + user = User(email=email, hashed_password=hashed_pw, is_oauth=False) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def get_or_create_oauth_user(db: Session, email: str, provider: str) -> User: + user = get_user_by_email(db, email) + if user: + user.oauth_provider = provider + db.commit() + db.refresh(user) + return user + user = User(email=email, is_oauth=True, oauth_provider=provider) + db.add(user) + db.commit() + db.refresh(user) + return user diff --git a/backend/app/modules/auth/models.py b/backend/app/modules/auth/models.py new file mode 100644 index 0000000..7346d54 --- /dev/null +++ b/backend/app/modules/auth/models.py @@ -0,0 +1,14 @@ +from sqlalchemy import Boolean, Column, Integer, String, UniqueConstraint + +from app.core.database import Base + + +class User(Base): + __tablename__ = "users" + __table_args__ = (UniqueConstraint("email", name="uq_user_email"),) + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, nullable=False, unique=True) + hashed_password = Column(String, nullable=True) + is_oauth = Column(Boolean, default=False) + oauth_provider = Column(String, nullable=True) diff --git a/backend/app/modules/auth/oauth.py b/backend/app/modules/auth/oauth.py new file mode 100644 index 0000000..f4037dd --- /dev/null +++ b/backend/app/modules/auth/oauth.py @@ -0,0 +1,133 @@ +from urllib.parse import urlencode + +import httpx +from fastapi import HTTPException + +from app.modules.auth.schemas import OAuthLogin +from app.settings import Settings + +settings = Settings() + +# --- Provider-specific logic --- + + +def get_email_from_github(token: str) -> str: + headers = {"Authorization": f"Bearer {token}"} + try: + resp = httpx.get( + "https://api.github.com/user/emails", headers=headers, timeout=5.0 + ) + except httpx.ReadTimeout as err: + raise HTTPException(status_code=504, detail="GitHub API timed out") from err + + if resp.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to fetch GitHub email") + + emails = resp.json() + primary = next((e for e in emails if e.get("primary") and e.get("verified")), None) + if not primary: + raise HTTPException(status_code=400, detail="No verified primary GitHub email") + return primary["email"] + + +def get_email_from_google(token: str) -> str: + headers = {"Authorization": f"Bearer {token}"} + try: + resp = httpx.get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers=headers, + timeout=5.0, + ) + except httpx.ReadTimeout as err: + raise HTTPException(status_code=504, detail="Google API timed out") from err + + if resp.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to fetch Google user info") + + data = resp.json() + if not data.get("email") or not data.get("email_verified"): + raise HTTPException(status_code=400, detail="Email not verified by Google") + return data["email"] + + +# --- Provider config registry --- + +OAUTH_PROVIDERS = { + "github": { + "client_id": settings.github_client_id, + "client_secret": settings.github_client_secret, + "auth_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "scope": "user:email", + "email_fetcher": get_email_from_github, + "headers": {"Accept": "application/json"}, + }, + "google": { + "client_id": settings.google_client_id, + "client_secret": settings.google_client_secret, + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scope": "openid email profile", + "email_fetcher": get_email_from_google, + "headers": {"Content-Type": "application/x-www-form-urlencoded"}, + "extra_auth_params": {"access_type": "offline", "prompt": "consent"}, + }, +} + +# --- Generic Logic --- + + +def build_oauth_redirect(provider: str, redirect_uri: str, state: str) -> str: + if provider not in OAUTH_PROVIDERS: + raise HTTPException(status_code=400, detail="Unsupported provider") + + cfg = OAUTH_PROVIDERS[provider] + query = { + "client_id": cfg["client_id"], + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": cfg["scope"], + "state": state, + } + query.update(cfg.get("extra_auth_params", {})) + + return f"{cfg['auth_url']}?{urlencode(query)}" + + +def _post_token_request(url: str, data: dict, headers: dict) -> dict: + resp = httpx.post(url, headers=headers, data=data, timeout=5.0) + resp.raise_for_status() + return resp.json() + + +def exchange_code_for_email(provider: str, code: str, redirect_uri: str) -> str: + if provider not in OAUTH_PROVIDERS: + raise HTTPException(status_code=400, detail="Unsupported provider") + + cfg = OAUTH_PROVIDERS[provider] + try: + data = { + "code": code, + "client_id": cfg["client_id"], + "client_secret": cfg["client_secret"], + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + } + token_resp = _post_token_request(cfg["token_url"], data, cfg["headers"]) + access_token = token_resp.get("access_token") + if not access_token: + raise HTTPException( + status_code=400, + detail=f"{provider.title()} did not return access token", + ) + + return cfg["email_fetcher"](access_token) + except Exception as e: + raise HTTPException( + status_code=400, detail=f"{provider.title()} token exchange failed" + ) from e + + +def get_email_from_oauth(login: OAuthLogin) -> str: + redirect_uri = "http://localhost:8000/api/v1/auth/oauth/callback" + return exchange_code_for_email(login.provider, login.token, redirect_uri) diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py new file mode 100644 index 0000000..89fd2ba --- /dev/null +++ b/backend/app/modules/auth/router.py @@ -0,0 +1,121 @@ +import base64 +import json + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import RedirectResponse +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.guard import ensure_not_demo +from app.modules.auth import crud, oauth, schemas, service +from app.modules.auth.models import User +from app.modules.auth.schemas import ( + OAuthLogin, + ProviderName, + TokenOut, + UserCreate, + UserLogin, +) +from app.modules.auth.service import is_safe_redirect +from app.modules.auth.token import create_access_token, get_current_user +from app.settings import get_settings + +router = APIRouter() + + +@router.post( + "/signup", + response_model=TokenOut, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(ensure_not_demo)], +) +def signup(user_in: UserCreate, db: Session = Depends(get_db)): + user = service.create_user(db, user_in) + token = create_access_token({"sub": user.email}) + return {"access_token": token, "token_type": "bearer"} + + +@router.post("/login", response_model=TokenOut) +def login(user_in: UserLogin, db: Session = Depends(get_db)): + user = crud.get_user_by_email(db, user_in.email) + if ( + not user + or not user.hashed_password + or not service.verify_password(user_in.password, user.hashed_password) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials" + ) + token = create_access_token({"sub": user.email}) + return {"access_token": token, "token_type": "bearer"} + + +@router.post("/oauth", response_model=TokenOut, dependencies=[Depends(ensure_not_demo)]) +def login_oauth(payload: OAuthLogin, db: Session = Depends(get_db)): + email = oauth.get_email_from_oauth(payload) + user = service.get_or_create_oauth_user(db, email=email, provider=payload.provider) + token = create_access_token({"sub": user.email}) + return {"access_token": token, "token_type": "bearer"} + + +@router.get("/me", response_model=schemas.UserOut) +def read_current_user(current_user: User = Depends(get_current_user)): + return current_user + + +@router.post("/token") +def login_for_access_token( + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db), +): + user = crud.get_user_by_email(db, form_data.username) + if not user or not user.hashed_password: + raise HTTPException(status_code=400, detail="Invalid credentials") + if not service.verify_password(form_data.password, user.hashed_password): + raise HTTPException(status_code=400, detail="Invalid credentials") + + token = create_access_token({"sub": user.email}) + return {"access_token": token, "token_type": "bearer"} + + +@router.get("/oauth/init/{provider}", dependencies=[Depends(ensure_not_demo)]) +def start_oauth_login( + provider: ProviderName, + request: Request, + redirect: str = Query("/events"), +): + redirect_uri = request.url_for("oauth_callback") + + state_payload = { + "provider": provider, + "redirect": redirect, + } + state = base64.urlsafe_b64encode(json.dumps(state_payload).encode()).decode() + + url = oauth.build_oauth_redirect(provider, redirect_uri, state) + return RedirectResponse(url=url) + + +@router.get( + "/oauth/callback", name="oauth_callback", dependencies=[Depends(ensure_not_demo)] +) +def handle_oauth_callback( + code: str = Query(...), state: str = Query(...), settings=Depends(get_settings) +): + try: + decoded = json.loads(base64.b64decode(state).decode()) + redirect = decoded.get("redirect", "/events") + except Exception as err: + raise HTTPException(status_code=400, detail="Invalid state parameter") from err + + if not is_safe_redirect(redirect, settings.frontend_url): + raise HTTPException(status_code=400, detail="Unsafe redirect path") + + final_url = f"{settings.frontend_url}/oauth/callback?code={code}&state={state}" + return RedirectResponse(url=final_url) + + +@router.get("/providers", tags=["auth"]) +def list_oauth_providers(settings=Depends(get_settings)): + return {"providers": settings.available_oauth_providers} diff --git a/backend/app/modules/auth/schemas.py b/backend/app/modules/auth/schemas.py new file mode 100644 index 0000000..5ec752a --- /dev/null +++ b/backend/app/modules/auth/schemas.py @@ -0,0 +1,37 @@ +from typing import Annotated, Literal, Optional + +from pydantic import BaseModel, ConfigDict, EmailStr, StringConstraints + +PasswordStr = Annotated[str, StringConstraints(min_length=6)] +ProviderName = Literal["github", "google"] + + +class UserBase(BaseModel): + email: EmailStr + + +class UserCreate(BaseModel): + email: EmailStr + password: PasswordStr + + +class UserLogin(UserBase): + password: str + + +class OAuthLogin(BaseModel): + provider: ProviderName + token: str + + +class UserOut(UserBase): + id: int + is_oauth: bool + oauth_provider: Optional[ProviderName] + + model_config = ConfigDict(from_attributes=True, validate_by_name=True) + + +class TokenOut(BaseModel): + access_token: str + token_type: Literal["bearer"] diff --git a/backend/app/modules/auth/service.py b/backend/app/modules/auth/service.py new file mode 100644 index 0000000..0ba3325 --- /dev/null +++ b/backend/app/modules/auth/service.py @@ -0,0 +1,52 @@ +from urllib.parse import urlparse + +from fastapi import HTTPException +from passlib.context import CryptContext +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.modules.auth import crud +from app.modules.auth.schemas import UserCreate + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def create_user(db: Session, user_in: UserCreate): + hashed_pw = hash_password(user_in.password) + try: + return crud.create_user(db, email=user_in.email, hashed_pw=hashed_pw) + except IntegrityError as err: + db.rollback() + raise HTTPException( + status_code=400, detail="User with this email already exists." + ) from err + + +def get_or_create_oauth_user(db: Session, email: str, provider: str): + return crud.get_or_create_oauth_user(db, email=email, provider=provider) + + +def create_user_if_not_exists(db: Session, user_in: UserCreate): + user = crud.get_user_by_email(db, user_in.email) + if not user: + create_user(db, user_in) + + +def is_safe_redirect(redirect: str, frontend_url: str) -> bool: + if redirect.startswith("/"): + return not redirect.startswith("//") + + try: + parsed = urlparse(redirect) + allowed = urlparse(frontend_url) + return parsed.scheme in ("http", "https") and parsed.netloc == allowed.netloc + except Exception: + return False diff --git a/backend/app/modules/auth/token.py b/backend/app/modules/auth/token.py new file mode 100644 index 0000000..ce4a26c --- /dev/null +++ b/backend/app/modules/auth/token.py @@ -0,0 +1,53 @@ +from datetime import UTC, datetime, timedelta +from typing import Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth import crud +from app.modules.auth.models import User +from app.settings import Settings + +settings = Settings() + +# Secret and settings +SECRET_KEY = settings.secret_key +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 + +# OAuth2 scheme for token dependency +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/token") + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + expire = datetime.now(UTC) + ( + expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +def get_current_user( + token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + email: str = payload.get("sub") + if email is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = crud.get_user_by_email(db, email) + if user is None: + raise credentials_exception + return user diff --git a/backend/app/settings.py b/backend/app/settings.py index ad69e40..88db622 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -1,4 +1,5 @@ import os +from functools import lru_cache from pathlib import Path from typing import Literal, Optional @@ -17,7 +18,13 @@ class Settings(BaseSettings): database_url: str = "sqlite:///./test.db" frontend_url: Optional[str] = None - secret_key: Optional[str] = None + secret_key: str = "your_secret_key_here" + + github_client_id: Optional[str] = None + github_client_secret: Optional[str] = None + + google_client_id: Optional[str] = None + google_client_secret: Optional[str] = None model_config = ConfigDict( env_file=resolve_env_file(), @@ -35,3 +42,17 @@ def is_prod(self): @property def is_demo(self): return self.env == "demo" + + @property + def available_oauth_providers(self) -> list[str]: + providers = [] + if self.github_client_id and self.github_client_secret: + providers.append("github") + if self.google_client_id and self.google_client_secret: + providers.append("google") + return providers + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/backend/migrations/env.py b/backend/migrations/env.py index 3869de9..7377d84 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -8,6 +8,11 @@ from app.core.database import Base from app.settings import Settings +from app.modules.auth import models as auth_models +from app.modules.fields import models as field_models +from app.modules.tags import models as tag_models +from app.modules.events import models as event_models + config = context.config if config.config_file_name is not None: diff --git a/backend/migrations/versions/23a12684fe55_add_auth.py b/backend/migrations/versions/23a12684fe55_add_auth.py new file mode 100644 index 0000000..884dc20 --- /dev/null +++ b/backend/migrations/versions/23a12684fe55_add_auth.py @@ -0,0 +1,43 @@ +"""add auth + +Revision ID: 23a12684fe55 +Revises: 6436e6c56133 +Create Date: 2025-05-26 08:25:42.592726 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '23a12684fe55' +down_revision: Union[str, None] = '6436e6c56133' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=True), + sa.Column('is_oauth', sa.Boolean(), nullable=True), + sa.Column('oauth_provider', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('email', name='uq_user_email') + ) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/backend/poetry.lock b/backend/poetry.lock index 331595a..3d39345 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "alembic" @@ -75,6 +75,71 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] +[[package]] +name = "bcrypt" +version = "4.3.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d"}, + {file = "bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4"}, + {file = "bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669"}, + {file = "bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304"}, + {file = "bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51"}, + {file = "bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62"}, + {file = "bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505"}, + {file = "bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a"}, + {file = "bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938"}, + {file = "bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "black" version = "25.1.0" @@ -134,6 +199,87 @@ files = [ {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.4.0" @@ -253,6 +399,56 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "cryptography" +version = "43.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "distlib" version = "0.3.9" @@ -286,6 +482,25 @@ idna = ["idna (>=3.7)"] trio = ["trio (>=0.23)"] wmi = ["wmi (>=1.5.1)"] +[[package]] +name = "ecdsa" +version = "0.19.1" +description = "ECDSA cryptographic signature library (pure python)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" +groups = ["main"] +files = [ + {file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"}, + {file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + [[package]] name = "email-validator" version = "2.2.0" @@ -970,6 +1185,27 @@ files = [ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] + +[package.dependencies] +bcrypt = {version = ">=3.1.0", optional = true, markers = "extra == \"bcrypt\""} + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] +totp = ["cryptography"] + [[package]] name = "pathspec" version = "0.12.1" @@ -1112,6 +1348,18 @@ files = [ {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, ] +[[package]] +name = "pyasn1" +version = "0.4.8" +description = "ASN.1 types and codecs" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, + {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, +] + [[package]] name = "pycodestyle" version = "2.13.0" @@ -1124,6 +1372,19 @@ files = [ {file = "pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae"}, ] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.11.4" @@ -1390,6 +1651,30 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-jose" +version = "3.4.0" +description = "JOSE implementation in Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "python-jose-3.4.0.tar.gz", hash = "sha256:9a9a40f418ced8ecaf7e3b28d69887ceaa76adad3bcaa6dae0d9e596fec1d680"}, + {file = "python_jose-3.4.0-py2.py3-none-any.whl", hash = "sha256:9c9f616819652d109bd889ecd1e15e9a162b9b94d682534c9c2146092945b78f"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryptography\""} +ecdsa = "!=0.15" +pyasn1 = ">=0.4.1,<0.5.0" +rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"] +test = ["pytest", "pytest-cov"] + [[package]] name = "python-multipart" version = "0.0.20" @@ -1502,6 +1787,21 @@ click = ">=8.1.7" rich = ">=13.7.1" typing-extensions = ">=4.12.2" +[[package]] +name = "rsa" +version = "4.9.1" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "<4,>=3.6" +groups = ["main"] +files = [ + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "shellingham" version = "1.5.4" @@ -1514,6 +1814,18 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -2095,5 +2407,5 @@ files = [ [metadata] lock-version = "2.1" -python-versions = ">=3.9" -content-hash = "34bf76e05c2087d383e3b6709e7d75f130001c23fd59be2b95b9e33ab2f72d5b" +python-versions = ">=3.9,<4" +content-hash = "7297c0e1dc7606c2e5f436245c8409fd160018df453a711ac608184fd6a3335f" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f751a92..d2aa649 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,7 +7,7 @@ authors = [ ] license = "MIT" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.9,<4" dependencies = [ "fastapi[all] (>=0.115.12,<0.116.0)", "sqlalchemy (>=2.0.40,<3.0.0)", @@ -17,7 +17,10 @@ dependencies = [ "python-dotenv (>=1.1.0,<2.0.0)", "pydantic-settings (>=2.9.1,<3.0.0)", "alembic (>=1.15.2,<2.0.0)", - "faker (>=37.3.0,<38.0.0)" + "faker (>=37.3.0,<38.0.0)", + "passlib[bcrypt] (>=1.7.4,<2.0.0)", + "rsa (>=4.9.1)", + "python-jose[cryptography] (>=3.4.0,<4.0.0)", ] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 75bdc46..7252432 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,9 +1,12 @@ +import bcrypt import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.core.database import Base, get_db, init_db from app.factory import create_app +from app.modules.auth.crud import create_user +from app.modules.auth.token import create_access_token from app.settings import Settings # Load test settings from .env.test @@ -13,7 +16,7 @@ test_engine, TestingSessionLocal = init_db(test_settings) # Create the test app -app = create_app(test_settings) +app = create_app(test_settings, TestingSessionLocal) # Override FastAPI's get_db dependency @@ -38,8 +41,32 @@ def setup_database(override_get_db): Base.metadata.drop_all(bind=test_engine) +@pytest.fixture(scope="session") +def test_user(override_get_db): + """Create a test user in the DB""" + db = next(override_get_db()) + user = create_user( + db, + email="test@example.com", + hashed_pw=bcrypt.hashpw(b"password123", bcrypt.gensalt()).decode("utf-8"), + ) + + return user + + +@pytest.fixture(scope="session") +def access_token(test_user): + return create_access_token({"sub": str(test_user.email)}) + + # FastAPI test client @pytest.fixture(scope="module") def client(): with TestClient(app) as c: yield c + + +@pytest.fixture +def auth_client(client, access_token): + client.headers.update({"Authorization": f"Bearer {access_token}"}) + return client diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..f166ec2 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,74 @@ +import pytest + + +@pytest.fixture +def auth_data(): + return { + "email": "user@example.com", + "password": "securepassword123", + } + + +def test_signup_returns_token(client, auth_data): + response = client.post("/v1/auth/signup", json=auth_data) + assert response.status_code == 201 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + +def test_signup_duplicate_email(client, auth_data): + # Repeat signup to trigger duplicate error + response = client.post("/v1/auth/signup", json=auth_data) + assert response.status_code == 400 + assert "already exists" in response.json()["detail"].lower() + + +def test_login_with_valid_credentials(client, auth_data): + response = client.post("/v1/auth/login", json=auth_data) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + +def test_login_with_invalid_password(client, auth_data): + response = client.post( + "/v1/auth/login", + json={"email": auth_data["email"], "password": "wrongpassword"}, + ) + assert response.status_code == 401 + assert "invalid credentials" in response.json()["detail"].lower() + + +def test_login_with_unknown_email(client): + response = client.post( + "/v1/auth/login", + json={"email": "unknown@example.com", "password": "irrelevant"}, + ) + assert response.status_code == 401 + assert "invalid credentials" in response.json()["detail"].lower() + + +def test_me_requires_auth(client): + response = client.get("/v1/auth/me") + assert response.status_code == 401 + + +def test_me_with_valid_token(auth_client, auth_data): + response = auth_client.get("/v1/auth/me") + assert response.status_code == 200 + assert response.json()["email"] == "test@example.com" + assert "id" in response.json() + + +def test_login_for_access_token(client, auth_data): + form_data = { + "username": auth_data["email"], + "password": auth_data["password"], + } + response = client.post("/v1/auth/token", data=form_data) + assert response.status_code == 200 + token_data = response.json() + assert "access_token" in token_data + assert token_data["token_type"] == "bearer" diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py index 8290902..077e1a4 100644 --- a/backend/tests/test_events.py +++ b/backend/tests/test_events.py @@ -14,20 +14,20 @@ def sample_event(): ) -def test_create_event(client, sample_event): - response = client.post("/v1/events/", json=sample_event.model_dump()) +def test_create_event(auth_client, sample_event): + response = auth_client.post("/v1/events/", json=sample_event.model_dump()) assert response.status_code == 201 assert response.json()["name"] == sample_event.name -def test_get_event(client): - response = client.get("/v1/events/1") +def test_get_event(auth_client): + response = auth_client.get("/v1/events/1") assert response.status_code == 200 assert response.json()["id"] == 1 -def test_create_event_with_invalid_field(client): - response = client.post( +def test_create_event_with_invalid_field(auth_client): + response = auth_client.post( "/v1/events/", json={ "name": "Event with a bad field", @@ -40,8 +40,8 @@ def test_create_event_with_invalid_field(client): assert "fields" in response.json()["detail"].lower() -def test_create_event_with_new_tag(client): - response = client.post( +def test_create_event_with_new_tag(auth_client): + response = auth_client.post( "/v1/events/", json={ "name": "Event with a new tag", @@ -52,13 +52,13 @@ def test_create_event_with_new_tag(client): ) assert response.status_code == 201 - tag_response = client.get("/v1/tags/new-tag") + tag_response = auth_client.get("/v1/tags/new-tag") assert tag_response.status_code == 200 assert tag_response.json()["id"] == "new-tag" -def test_update_event_with_new_tag(client): - create_response = client.post( +def test_update_event_with_new_tag(auth_client): + create_response = auth_client.post( "/v1/events/", json={ "name": "Event without optional fields", @@ -69,7 +69,7 @@ def test_update_event_with_new_tag(client): ) event_id = create_response.json()["id"] - update_response = client.put( + update_response = auth_client.put( f"/v1/events/{event_id}", json={ "name": "Updated Event, now with a tag!", @@ -80,6 +80,6 @@ def test_update_event_with_new_tag(client): ) assert update_response.status_code == 200 - tag_response = client.get("/v1/tags/updated-tag") + tag_response = auth_client.get("/v1/tags/updated-tag") assert tag_response.status_code == 200 assert tag_response.json()["id"] == "updated-tag" diff --git a/backend/tests/test_fields.py b/backend/tests/test_fields.py index ffcc0af..142085f 100644 --- a/backend/tests/test_fields.py +++ b/backend/tests/test_fields.py @@ -11,15 +11,15 @@ def sample_field(): ) -def test_create_field(client, sample_field): +def test_create_field(auth_client, sample_field): """Тест на создание события""" - response = client.post("/v1/fields/", json=sample_field.model_dump()) + response = auth_client.post("/v1/fields/", json=sample_field.model_dump()) assert response.status_code == 201 assert response.json()["id"] == 1 -def test_get_field(client, sample_field): +def test_get_field(auth_client, sample_field): """Тест на получение события""" - response = client.get("/v1/fields/1") + response = auth_client.get("/v1/fields/1") assert response.status_code == 200 assert response.json()["id"] == 1 diff --git a/backend/tests/test_tags.py b/backend/tests/test_tags.py index 147b2c9..e5e01a2 100644 --- a/backend/tests/test_tags.py +++ b/backend/tests/test_tags.py @@ -12,15 +12,15 @@ def sample_tag(): ) -def test_create_tag(client, sample_tag): +def test_create_tag(auth_client, sample_tag): """Тест на создание события""" - response = client.post("/v1/tags/", json=sample_tag.model_dump()) + response = auth_client.post("/v1/tags/", json=sample_tag.model_dump()) assert response.status_code == 201 assert response.json()["id"] == sample_tag.id -def test_get_tag(client, sample_tag): +def test_get_tag(auth_client, sample_tag): """Тест на получение события""" - response = client.get("/v1/tags/release") + response = auth_client.get("/v1/tags/release") assert response.status_code == 200 assert response.json()["id"] == sample_tag.id diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b3df123..0066f43 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,6 +2,25 @@ import { RouterView } from 'vue-router' import MainLayout from '@/shared/components/layout/MainLayout.vue' import { Toaster } from '@/shared/ui/sonner' +import { useAuthStore } from '@/modules/auth/stores/useAuthStore' +import router from './router' + +const auth = useAuthStore() + +if (auth.token) { + auth.fetchCurrentUser().catch(() => { + auth.logout() + }) +} + +window.addEventListener('message', async event => { + const { token, redirect } = event.data + if (token) { + auth.token = token + await auth.fetchCurrentUser() + router.replace(redirect || '/events') + } +})