From 7290c11cafd9c8910e18e547eb7ed5389aaa1b0d Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Mon, 26 May 2025 12:06:04 +0300 Subject: [PATCH 01/21] feat(backend): auth and oauth endpoints --- Makefile | 6 +- backend/app/api/v1/routes/auth.py | 7 + backend/app/factory.py | 3 +- backend/app/modules/auth/crud.py | 29 ++ backend/app/modules/auth/models.py | 14 + backend/app/modules/auth/oauth.py | 56 +++ backend/app/modules/auth/router.py | 61 ++++ backend/app/modules/auth/schemas.py | 36 ++ backend/app/modules/auth/service.py | 24 ++ backend/app/modules/auth/token.py | 50 +++ backend/migrations/env.py | 5 + backend/poetry.lock | 318 +++++++++++++++++- backend/pyproject.toml | 7 +- .../modules/fields/components/FieldForm.vue | 32 +- 14 files changed, 623 insertions(+), 25 deletions(-) create mode 100644 backend/app/api/v1/routes/auth.py create mode 100644 backend/app/modules/auth/crud.py create mode 100644 backend/app/modules/auth/models.py create mode 100644 backend/app/modules/auth/oauth.py create mode 100644 backend/app/modules/auth/router.py create mode 100644 backend/app/modules/auth/schemas.py create mode 100644 backend/app/modules/auth/service.py create mode 100644 backend/app/modules/auth/token.py 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/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/factory.py b/backend/app/factory.py index 1fc94fd..c1df2f5 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -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.settings import Settings @@ -59,6 +59,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/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..dba9952 --- /dev/null +++ b/backend/app/modules/auth/oauth.py @@ -0,0 +1,56 @@ +import httpx +from fastapi import HTTPException + +from app.modules.auth.schemas import OAuthLogin + +# GitHub config +GITHUB_CLIENT_ID = "your_github_client_id" +GITHUB_CLIENT_SECRET = "your_github_client_secret" +GITHUB_USER_API = "https://api.github.com/user" +GITHUB_EMAILS_API = "https://api.github.com/user/emails" + +# Google config +GOOGLE_USERINFO_API = "https://www.googleapis.com/oauth2/v3/userinfo" + + +def get_email_from_github(token: str) -> str: + headers = {"Authorization": f"Bearer {token}"} + try: + with httpx.Client() as client: + email_resp = client.get(GITHUB_EMAILS_API, headers=headers, timeout=5.0) + except httpx.ReadTimeout: + raise HTTPException(status_code=504, detail="GitHub API timed out") + + if email_resp.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to fetch GitHub email") + emails = email_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: + with httpx.Client() as client: + resp = client.get(GOOGLE_USERINFO_API, headers=headers, timeout=5.0) + except httpx.ReadTimeout: + raise HTTPException(status_code=504, detail="Google API timed out") + + if resp.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to fetch Google user info") + data = resp.json() + email = data.get("email") + verified = data.get("email_verified") + if not email or not verified: + raise HTTPException(status_code=400, detail="Email not verified by Google") + return email + + +def get_email_from_oauth(login: OAuthLogin) -> str: + if login.provider == "github": + return get_email_from_github(login.token) + elif login.provider == "google": + return get_email_from_google(login.token) + raise HTTPException(status_code=400, detail="Unsupported provider") diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py new file mode 100644 index 0000000..cfe025b --- /dev/null +++ b/backend/app/modules/auth/router.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth import oauth, schemas, service, crud +from app.modules.auth.models import User +from app.modules.auth.schemas import OAuthLogin, TokenOut, UserCreate, UserLogin +from app.modules.auth.token import create_access_token, get_current_user + +router = APIRouter() + + +@router.post("/signup", response_model=TokenOut, status_code=status.HTTP_201_CREATED) +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) +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"} \ No newline at end of file diff --git a/backend/app/modules/auth/schemas.py b/backend/app/modules/auth/schemas.py new file mode 100644 index 0000000..d5c7d88 --- /dev/null +++ b/backend/app/modules/auth/schemas.py @@ -0,0 +1,36 @@ +from typing import Annotated, Literal, Optional + +from pydantic import BaseModel, ConfigDict, EmailStr, StringConstraints + +PasswordStr = Annotated[str, StringConstraints(min_length=6)] + + +class UserBase(BaseModel): + email: EmailStr + + +class UserCreate(BaseModel): + email: EmailStr + password: PasswordStr + + +class UserLogin(UserBase): + password: str + + +class OAuthLogin(BaseModel): + provider: Literal["github", "google"] + token: str + + +class UserOut(UserBase): + id: int + is_oauth: bool + oauth_provider: Optional[str] + + 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..8700382 --- /dev/null +++ b/backend/app/modules/auth/service.py @@ -0,0 +1,24 @@ +from passlib.context import CryptContext +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) + return crud.create_user(db, email=user_in.email, hashed_pw=hashed_pw) + + +def get_or_create_oauth_user(db: Session, email: str, provider: str): + return crud.get_or_create_oauth_user(db, email=email, provider=provider) diff --git a/backend/app/modules/auth/token.py b/backend/app/modules/auth/token.py new file mode 100644 index 0000000..60b558d --- /dev/null +++ b/backend/app/modules/auth/token.py @@ -0,0 +1,50 @@ +from datetime import datetime, timedelta, UTC +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 + +# Secret and settings +SECRET_KEY = "your_secret_key_here" +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/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/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/frontend/src/modules/fields/components/FieldForm.vue b/frontend/src/modules/fields/components/FieldForm.vue index 8575e0b..c116297 100644 --- a/frontend/src/modules/fields/components/FieldForm.vue +++ b/frontend/src/modules/fields/components/FieldForm.vue @@ -61,22 +61,6 @@ const fieldTypeOptions = Object.entries(FieldType).map(([key, value]) => ({ - - - - Description - - - - - - - @@ -101,6 +85,22 @@ const fieldTypeOptions = Object.entries(FieldType).map(([key, value]) => ({ + + + + Description + + + + + + +
From ece917dfcafafe751feea3a6ce4354dc6c85478c Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Mon, 26 May 2025 14:15:25 +0300 Subject: [PATCH 02/21] feat(frontend): login signup forms and routing --- backend/app/modules/auth/router.py | 4 +- backend/app/modules/auth/token.py | 2 +- frontend/src/main.ts | 6 +- .../modules/auth/components/LoginButton.vue | 16 ++++ .../src/modules/auth/components/LoginForm.vue | 78 +++++++++++++++++++ .../modules/auth/components/LogoutButton.vue | 21 +++++ .../modules/auth/components/SignupForm.vue | 58 ++++++++++++++ frontend/src/modules/auth/pages/LoginPage.vue | 17 ++++ .../src/modules/auth/pages/SignupPage.vue | 17 ++++ frontend/src/modules/auth/router.ts | 16 ++++ .../src/modules/auth/stores/useAuthStore.ts | 38 +++++++++ frontend/src/router/index.ts | 37 +++++---- frontend/src/router/routes.ts | 19 +++++ .../shared/components/layout/MainLayout.vue | 6 ++ frontend/src/shared/utils/api.ts | 28 +++++-- 15 files changed, 336 insertions(+), 27 deletions(-) create mode 100644 frontend/src/modules/auth/components/LoginButton.vue create mode 100644 frontend/src/modules/auth/components/LoginForm.vue create mode 100644 frontend/src/modules/auth/components/LogoutButton.vue create mode 100644 frontend/src/modules/auth/components/SignupForm.vue create mode 100644 frontend/src/modules/auth/pages/LoginPage.vue create mode 100644 frontend/src/modules/auth/pages/SignupPage.vue create mode 100644 frontend/src/modules/auth/router.ts create mode 100644 frontend/src/modules/auth/stores/useAuthStore.ts create mode 100644 frontend/src/router/routes.ts diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py index cfe025b..1999f6d 100644 --- a/backend/app/modules/auth/router.py +++ b/backend/app/modules/auth/router.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from app.core.database import get_db -from app.modules.auth import oauth, schemas, service, crud +from app.modules.auth import crud, oauth, schemas, service from app.modules.auth.models import User from app.modules.auth.schemas import OAuthLogin, TokenOut, UserCreate, UserLogin from app.modules.auth.token import create_access_token, get_current_user @@ -58,4 +58,4 @@ def login_for_access_token( raise HTTPException(status_code=400, detail="Invalid credentials") token = create_access_token({"sub": user.email}) - return {"access_token": token, "token_type": "bearer"} \ No newline at end of file + return {"access_token": token, "token_type": "bearer"} diff --git a/backend/app/modules/auth/token.py b/backend/app/modules/auth/token.py index 60b558d..a619961 100644 --- a/backend/app/modules/auth/token.py +++ b/backend/app/modules/auth/token.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, UTC +from datetime import UTC, datetime, timedelta from typing import Optional from fastapi import Depends, HTTPException, status diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 64af46a..822239d 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,8 +1,12 @@ import { createApp } from 'vue' -import { router } from './router' +import { createPinia } from 'pinia' +import router from './router' import './index.css' import App from '@/App.vue' +const pinia = createPinia() const app = createApp(App) + app.use(router) +app.use(pinia) app.mount('#app') diff --git a/frontend/src/modules/auth/components/LoginButton.vue b/frontend/src/modules/auth/components/LoginButton.vue new file mode 100644 index 0000000..2829ef0 --- /dev/null +++ b/frontend/src/modules/auth/components/LoginButton.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/modules/auth/components/LoginForm.vue b/frontend/src/modules/auth/components/LoginForm.vue new file mode 100644 index 0000000..557d38c --- /dev/null +++ b/frontend/src/modules/auth/components/LoginForm.vue @@ -0,0 +1,78 @@ + + + diff --git a/frontend/src/modules/auth/components/LogoutButton.vue b/frontend/src/modules/auth/components/LogoutButton.vue new file mode 100644 index 0000000..41c92a3 --- /dev/null +++ b/frontend/src/modules/auth/components/LogoutButton.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/modules/auth/components/SignupForm.vue b/frontend/src/modules/auth/components/SignupForm.vue new file mode 100644 index 0000000..111befc --- /dev/null +++ b/frontend/src/modules/auth/components/SignupForm.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/modules/auth/pages/LoginPage.vue b/frontend/src/modules/auth/pages/LoginPage.vue new file mode 100644 index 0000000..19fdcd6 --- /dev/null +++ b/frontend/src/modules/auth/pages/LoginPage.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/modules/auth/pages/SignupPage.vue b/frontend/src/modules/auth/pages/SignupPage.vue new file mode 100644 index 0000000..8560938 --- /dev/null +++ b/frontend/src/modules/auth/pages/SignupPage.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/modules/auth/router.ts b/frontend/src/modules/auth/router.ts new file mode 100644 index 0000000..ffa4310 --- /dev/null +++ b/frontend/src/modules/auth/router.ts @@ -0,0 +1,16 @@ +import type { RouteRecordRaw } from 'vue-router' +import LoginPage from './pages/LoginPage.vue' +import SignupPage from './pages/SignupPage.vue' + +export const authRoutes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'Login', + component: LoginPage, + }, + { + path: '/signup', + name: 'Signup', + component: SignupPage, + }, +] diff --git a/frontend/src/modules/auth/stores/useAuthStore.ts b/frontend/src/modules/auth/stores/useAuthStore.ts new file mode 100644 index 0000000..bfe9280 --- /dev/null +++ b/frontend/src/modules/auth/stores/useAuthStore.ts @@ -0,0 +1,38 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '@/shared/utils/api' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(null) + const email = ref(null) + + const login = async (emailInput: string, password: string) => { + const response = await api.post('/auth/login', { + email: emailInput, + password, + }) + token.value = response.data.access_token + email.value = emailInput + } + + const signup = async (emailInput: string, password: string) => { + const response = await api.post('/auth/signup', { + email: emailInput, + password, + }) + token.value = response.data.access_token + email.value = emailInput + } + + const logout = () => { + token.value = null + email.value = null + } + + const fetchCurrentUser = async () => { + const response = await api.get('/auth/me') + email.value = response.data.email + } + + return { token, email, login, signup, logout, fetchCurrentUser } +}) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 62a00b8..05281bf 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,21 +1,24 @@ import { createRouter, createWebHistory } from 'vue-router' -import { fieldsRoutes } from '@/modules/fields/router' -import { eventsRoutes } from '@/modules/events/router' -import { tagsRoutes } from '@/modules/tags/router' -import { switchboardRoutes } from '@/modules/switchboard/router' +import { useAuthStore } from '@/modules/auth/stores/useAuthStore' +import { routes } from './routes' -export const router = createRouter({ +// const publicPages = ['/login', '/signup', '/landing'] + +const router = createRouter({ history: createWebHistory(), - routes: [ - { path: '/', redirect: '/events' }, - { - path: '/:pathMatch(.*)*', - name: 'NotFound', - component: () => import('@/shared/pages/NotFoundPage.vue'), - }, - ...eventsRoutes, - ...fieldsRoutes, - ...tagsRoutes, - ...switchboardRoutes, - ], + routes: routes, }) + +router.beforeEach((to, from, next) => { + const auth = useAuthStore() + // const isPublic = publicPages.includes(to.path) + const isPublic = true + + if (!isPublic && !auth.token) { + next({ path: '/login', query: { redirect: to.fullPath } }) + } else { + next() + } +}) + +export default router diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts new file mode 100644 index 0000000..6b7f980 --- /dev/null +++ b/frontend/src/router/routes.ts @@ -0,0 +1,19 @@ +import { fieldsRoutes } from '@/modules/fields/router' +import { eventsRoutes } from '@/modules/events/router' +import { tagsRoutes } from '@/modules/tags/router' +import { switchboardRoutes } from '@/modules/switchboard/router' +import { authRoutes } from '@/modules/auth/router' + +export const routes = [ + { path: '/', redirect: '/events' }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/shared/pages/NotFoundPage.vue'), + }, + ...eventsRoutes, + ...fieldsRoutes, + ...tagsRoutes, + ...switchboardRoutes, + ...authRoutes, +] diff --git a/frontend/src/shared/components/layout/MainLayout.vue b/frontend/src/shared/components/layout/MainLayout.vue index 745bcd3..300e7da 100644 --- a/frontend/src/shared/components/layout/MainLayout.vue +++ b/frontend/src/shared/components/layout/MainLayout.vue @@ -4,6 +4,8 @@ import NavigationMenu from '@/shared/components/layout/NavigationMenu.vue' import DropdownMenu from '@/shared/components/layout/DropdownMenu.vue' import LoveFooter from './LoveFooter.vue' import { useAppConfig } from '@/shared/composables/useAppConfig' +import LogoutButton from '@/modules/auth/components/LogoutButton.vue' +import LoginButton from '@/modules/auth/components/LoginButton.vue' const { isDemo, isDev } = useAppConfig() @@ -21,6 +23,10 @@ const { isDemo, isDev } = useAppConfig()
{{ isDev ? 'DEV' : 'DEMO' }}
+ + + + diff --git a/frontend/src/shared/utils/api.ts b/frontend/src/shared/utils/api.ts index f68062d..0ac47e8 100644 --- a/frontend/src/shared/utils/api.ts +++ b/frontend/src/shared/utils/api.ts @@ -1,17 +1,33 @@ import axios from 'axios' - -if (!import.meta.env.VITE_API_URL) { - throw new Error('VITE_API_URL is not defined in your environment variables') -} +import { useAuthStore } from '@/modules/auth/stores/useAuthStore' +import router from '@/router' export const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL, + baseURL: import.meta.env.VITE_API_URL ?? '/api/v1', headers: { 'Content-Type': 'application/json', }, }) +api.interceptors.request.use(config => { + const auth = useAuthStore() + if (auth.token) { + config.headers.Authorization = `Bearer ${auth.token}` + } + return config +}) + api.interceptors.response.use( response => response, - error => Promise.reject(error) + error => { + if (error.response?.status === 401) { + const auth = useAuthStore() + auth.logout() + + if (router.currentRoute.value.name !== 'Login') { + router.push({ name: 'Login' }) + } + } + return Promise.reject(error) + } ) From 74bb32545f775c2f10e2b50166a4cbdd709039fc Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Tue, 27 May 2025 15:59:26 +0300 Subject: [PATCH 03/21] feat: oauth2 --- backend/app/modules/auth/oauth.py | 119 ++++++++++++++++-- backend/app/modules/auth/router.py | 34 ++++- backend/app/modules/auth/token.py | 5 +- backend/app/settings.py | 8 +- frontend/src/App.vue | 19 +++ frontend/src/modules/auth/api.ts | 4 + .../src/modules/auth/components/LoginForm.vue | 22 +--- .../modules/auth/components/LogoutButton.vue | 2 +- .../modules/auth/components/SignupForm.vue | 12 +- .../src/modules/auth/oauth/OAuthCallback.vue | 43 +++++++ .../src/modules/auth/oauth/OauthButton.vue | 32 +++++ .../src/modules/auth/stores/useAuthStore.ts | 47 +++++-- frontend/src/modules/auth/types.ts | 4 + .../layout/SwitchboardSectionLayout.vue | 2 +- .../modules/user/components/LogoutPanel.vue | 16 +++ .../modules/user/components/UserInfoPanel.vue | 19 +++ .../modules/user/layout/UserSectionLayout.vue | 37 ++++++ .../modules/user/pages/UserSettingsPage.vue | 17 +++ frontend/src/modules/user/router.ts | 10 ++ frontend/src/router/routes.ts | 7 ++ .../shared/components/layout/DropdownMenu.vue | 8 ++ .../shared/components/layout/MainLayout.vue | 3 - 22 files changed, 421 insertions(+), 49 deletions(-) create mode 100644 frontend/src/modules/auth/api.ts create mode 100644 frontend/src/modules/auth/oauth/OAuthCallback.vue create mode 100644 frontend/src/modules/auth/oauth/OauthButton.vue create mode 100644 frontend/src/modules/auth/types.ts create mode 100644 frontend/src/modules/user/components/LogoutPanel.vue create mode 100644 frontend/src/modules/user/components/UserInfoPanel.vue create mode 100644 frontend/src/modules/user/layout/UserSectionLayout.vue create mode 100644 frontend/src/modules/user/pages/UserSettingsPage.vue create mode 100644 frontend/src/modules/user/router.ts diff --git a/backend/app/modules/auth/oauth.py b/backend/app/modules/auth/oauth.py index dba9952..c8d4201 100644 --- a/backend/app/modules/auth/oauth.py +++ b/backend/app/modules/auth/oauth.py @@ -1,16 +1,28 @@ +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() # GitHub config -GITHUB_CLIENT_ID = "your_github_client_id" -GITHUB_CLIENT_SECRET = "your_github_client_secret" +GITHUB_CLIENT_ID = settings.github_client_id +GITHUB_CLIENT_SECRET = settings.github_client_secret + GITHUB_USER_API = "https://api.github.com/user" GITHUB_EMAILS_API = "https://api.github.com/user/emails" +GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize" +GITHUB_SCOPE = "user:email" # Google config +GOOGLE_CLIENT_ID = settings.google_client_id +GOOGLE_CLIENT_SECRET = settings.google_client_secret GOOGLE_USERINFO_API = "https://www.googleapis.com/oauth2/v3/userinfo" +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_SCOPE = "openid email profile" def get_email_from_github(token: str) -> str: @@ -18,11 +30,15 @@ def get_email_from_github(token: str) -> str: try: with httpx.Client() as client: email_resp = client.get(GITHUB_EMAILS_API, headers=headers, timeout=5.0) - except httpx.ReadTimeout: - raise HTTPException(status_code=504, detail="GitHub API timed out") + print("[GITHUB EMAIL LOOKUP] status:", email_resp.status_code) + print("[GITHUB EMAIL LOOKUP] response:", email_resp.text) + + except httpx.ReadTimeout as err: + raise HTTPException(status_code=504, detail="GitHub API timed out") from err if email_resp.status_code != 200: raise HTTPException(status_code=400, detail="Failed to fetch GitHub email") + emails = email_resp.json() primary = next((e for e in emails if e.get("primary") and e.get("verified")), None) if not primary: @@ -35,11 +51,12 @@ def get_email_from_google(token: str) -> str: try: with httpx.Client() as client: resp = client.get(GOOGLE_USERINFO_API, headers=headers, timeout=5.0) - except httpx.ReadTimeout: - raise HTTPException(status_code=504, detail="Google API timed out") + 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() email = data.get("email") verified = data.get("email_verified") @@ -49,8 +66,90 @@ def get_email_from_google(token: str) -> str: def get_email_from_oauth(login: OAuthLogin) -> str: - if login.provider == "github": - return get_email_from_github(login.token) - elif login.provider == "google": - return get_email_from_google(login.token) + # redirect_uri = f"{settings.api_url}/auth/oauth/callback?provider={login.provider}" + redirect_uri = ( + f"http://localhost:8000/api/v1/auth/oauth/callback?provider={login.provider}" + ) + + return exchange_code_for_email(login.provider, login.token, redirect_uri) + + +def build_oauth_redirect(provider: str, redirect_uri: str, state: str) -> str: + if provider == "github": + query = urlencode( + { + "client_id": GITHUB_CLIENT_ID, + "redirect_uri": redirect_uri, + "scope": GITHUB_SCOPE, + "state": state, + "response_type": "code", + } + ) + return f"{GITHUB_AUTH_URL}?{query}" + + elif provider == "google": + query = urlencode( + { + "client_id": GOOGLE_CLIENT_ID, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": GOOGLE_SCOPE, + "state": state, + "access_type": "offline", + "prompt": "consent", + } + ) + return f"{GOOGLE_AUTH_URL}?{query}" + + raise HTTPException(status_code=400, detail="Unsupported provider") + + +def exchange_code_for_email(provider: str, code: str, redirect_uri: str) -> str: + if provider == "github": + try: + token_resp = httpx.post( + "https://github.com/login/oauth/access_token", + headers={"Accept": "application/json"}, + data={ + "client_id": GITHUB_CLIENT_ID, + "client_secret": GITHUB_CLIENT_SECRET, + "code": code, + "redirect_uri": redirect_uri, + }, + timeout=5.0, + ) + print("[GITHUB TOKEN EXCHANGE] status:", token_resp.status_code) + print("[GITHUB TOKEN EXCHANGE] response:", token_resp.text) + + token_resp.raise_for_status() + access_token = token_resp.json().get("access_token") + if not access_token: + raise HTTPException( + status_code=400, detail="GitHub did not return access token" + ) + return get_email_from_github(access_token) + + except Exception as e: + import traceback + + print("[GITHUB OAUTH ERROR]", traceback.format_exc()) + raise HTTPException(status_code=400, detail="GitHub token exchange failed") from e + + elif provider == "google": + token_resp = httpx.post( + "https://oauth2.googleapis.com/token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "code": code, + "client_id": GOOGLE_CLIENT_ID, + "client_secret": GOOGLE_CLIENT_SECRET, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + }, + timeout=5.0, + ) + token_resp.raise_for_status() + access_token = token_resp.json().get("access_token") + return get_email_from_google(access_token) + raise HTTPException(status_code=400, detail="Unsupported provider") diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py index 1999f6d..b231619 100644 --- a/backend/app/modules/auth/router.py +++ b/backend/app/modules/auth/router.py @@ -1,4 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException, status +import base64 + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import RedirectResponse from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session @@ -7,6 +10,9 @@ from app.modules.auth.models import User from app.modules.auth.schemas import OAuthLogin, TokenOut, UserCreate, UserLogin from app.modules.auth.token import create_access_token, get_current_user +from app.settings import Settings + +settings = Settings() router = APIRouter() @@ -59,3 +65,29 @@ def login_for_access_token( token = create_access_token({"sub": user.email}) return {"access_token": token, "token_type": "bearer"} + + +@router.get("/oauth/init/{provider}") +def start_oauth_login( + provider: str, + request: Request, + redirect: str = Query("/events"), +): + redirect_uri = request.url_for("oauth_callback") + state = base64.urlsafe_b64encode(redirect.encode()).decode() + + callback_with_provider = f"{redirect_uri}?provider={provider}" + + url = oauth.build_oauth_redirect(provider, callback_with_provider, state) + return RedirectResponse(url=url) + + +@router.get("/oauth/callback", name="oauth_callback") +def handle_oauth_callback( + request: Request, + code: str = Query(...), + state: str = Query("", description="Base64-encoded redirect path"), + provider: str = Query(...), +): + final_url = f"{settings.frontend_url}/oauth/callback?code={code}&provider={provider}&state={state}" + return RedirectResponse(url=final_url) diff --git a/backend/app/modules/auth/token.py b/backend/app/modules/auth/token.py index a619961..ce4a26c 100644 --- a/backend/app/modules/auth/token.py +++ b/backend/app/modules/auth/token.py @@ -9,9 +9,12 @@ 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 = "your_secret_key_here" +SECRET_KEY = settings.secret_key ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 diff --git a/backend/app/settings.py b/backend/app/settings.py index ad69e40..c979b48 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -17,7 +17,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(), 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') + } +})