From a8a18abf302d1f38aa655a841f1cd483bf571417 Mon Sep 17 00:00:00 2001 From: Anton Laktionov Date: Thu, 16 Jul 2026 16:06:22 +0300 Subject: [PATCH] create project --- app/auth.py | 80 ++++++++++++++++++++++++++++++++++++++++++++ app/core/config.py | 12 +++++++ app/db.py | 24 +++++++++++++ app/deps.py | 9 +++++ app/main.py | 78 ++++++++++++++++++++++++++++++++++++++---- app/models.py | 13 +++++++ app/schemas.py | 23 +++++++++++++ app/schemas/user.py | 18 ++++++++++ app/test.db | Bin 0 -> 16384 bytes requirements.txt | 7 +++- 10 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 app/auth.py create mode 100644 app/core/config.py create mode 100644 app/db.py create mode 100644 app/deps.py create mode 100644 app/models.py create mode 100644 app/schemas.py create mode 100644 app/schemas/user.py create mode 100644 app/test.db diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..ec141ca --- /dev/null +++ b/app/auth.py @@ -0,0 +1,80 @@ +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import Depends, HTTPException, status +from jose import jwt, JWTError +from fastapi.security import OAuth2PasswordBearer +import bcrypt + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.models import User +from app.schemas import Token +from app.db import get_db +import os + +# Загружаем переменные окружения +SECRET_KEY = os.getenv("SECRET_KEY") +ALGORITHM = os.getenv("ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) + +# --------------------------------------------------------- +# Хэширование пароля +# --------------------------------------------------------- +def hash_password(password: str) -> str: + pwd_bytes = password.encode('utf-8') + salt = bcrypt.gensalt() + return bcrypt.hashpw(pwd_bytes, salt).decode('utf-8') + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return bcrypt.checkpw( + plain_password.encode('utf-8'), + hashed_password.encode('utf-8') + ) + +# --------------------------------------------------------- +# Создание JWT-токена +# --------------------------------------------------------- +def create_access_token(data: dict, expires_delta: Optional[int] = None): + to_encode = data.copy() + + expire = datetime.utcnow() + timedelta( + minutes=expires_delta or ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode.update({"exp": expire}) + + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +# --------------------------------------------------------- +# Получение текущего пользователя по токену +# --------------------------------------------------------- +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +): + """ Достаём токен из Authorization Bearer """ + if not token or not isinstance(token, str): + raise HTTPException(status_code=401, detail="Missing token") + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + email: str = payload.get("sub") + + if email is None: + raise HTTPException(status_code=401, detail="Invalid token payload") + + except JWTError: + raise HTTPException(status_code=401, detail="Invalid or expired token") + + query = select(User).where(User.email == email) + result = await db.execute(query) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + return user \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..29948ab --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,12 @@ +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + # Секретный ключ — в реальном проекте брать из .env! + SECRET_KEY: str = "your-super-secret-key-change-this-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + class Config: + env_file = ".env" + +settings = Settings() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..39c5b8b --- /dev/null +++ b/app/db.py @@ -0,0 +1,24 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker, declarative_base +import os +from dotenv import load_dotenv + + +load_dotenv() # загрузить .env при старте + + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./test.db") + + +engine = create_async_engine(DATABASE_URL, echo=False, future=True) +AsyncSessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) + + +Base = declarative_base() + + +# Для удобства: зависимость (async) для получения сессии +async def get_db(): + async with AsyncSessionLocal() as session: + yield session + diff --git a/app/deps.py b/app/deps.py new file mode 100644 index 0000000..8e0238e --- /dev/null +++ b/app/deps.py @@ -0,0 +1,9 @@ +from fastapi import Depends, HTTPException +from app.auth import get_current_user +from app.models import User + + +async def require_admin(user: User = Depends(get_current_user)): + if user.role != "admin": + raise HTTPException(status_code=403, detail="Admin access required") + return user \ No newline at end of file diff --git a/app/main.py b/app/main.py index d5f940f..23a58c1 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,73 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select -# Создаем само приложение -app = FastAPI() +from app.db import engine, Base, get_db +from app.models import User +from app.schemas import UserCreate, UserOut, Token +from app.auth import hash_password, verify_password, create_access_token -# Вешаем обработчик на главную страницу -@app.get("/") -def read_root(): - return {"message": "Hello, world!"} +app = FastAPI(title="FastAPI JWT Auth Example") + + +# --------------------------------------------------------- +# Создать таблицы при запуске (для SQLite) +# --------------------------------------------------------- +@app.on_event("startup") +async def startup(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +# --------------------------------------------------------- +# Регистрация +# --------------------------------------------------------- +@app.post("/register", response_model=UserOut) +async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)): + query = select(User).where(User.email == user_in.email) + result = await db.execute(query) + existing_user = result.scalar_one_or_none() + + if existing_user: + raise HTTPException(status_code=400, detail="Email already exists") + + user = User( + email=user_in.email, + hashed_password=hash_password(user_in.password), + role=user_in.role + ) + db.add(user) + await db.commit() + await db.refresh(user) + + return user + + +# --------------------------------------------------------- +# Логин +# --------------------------------------------------------- +@app.post("/login", response_model=Token) +async def login(form: UserCreate, db: AsyncSession = Depends(get_db)): + query = select(User).where(User.email == form.email) + result = await db.execute(query) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=401, detail="Invalid credentials") + + if not verify_password(form.password, user.hashed_password): + raise HTTPException(status_code=401, detail="Invalid credentials") + + token = create_access_token(data={"sub": user.email}) + + return {"access_token": token, "token_type": "bearer"} + + +# --------------------------------------------------------- +# Защищённый маршрут +# --------------------------------------------------------- +from app.auth import get_current_user + +@app.get("/me", response_model=UserOut) +async def read_me(user=Depends(get_current_user)): + return user diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..768ad72 --- /dev/null +++ b/app/models.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String, Boolean +from app.db import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + is_active = Column(Boolean(), default=True) + role = Column(String, default="user") # roles: "user", "admin" + diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..efd5a65 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, EmailStr + + +class UserCreate(BaseModel): + email: EmailStr + password: str + role: str = "user" + + +class UserOut(BaseModel): + id: int + email: EmailStr + is_active: bool + role: str + + + class Config: + orm_mode = True + + +class Token(BaseModel): + access_token: str + token_type: str \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..17b7223 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, EmailStr + +class UserBase(BaseModel): + username: str + email: EmailStr + +class UserCreate(UserBase): + password: str + +class User(UserBase): + id: int + is_active: bool = True + + class Config: + from_attributes = True + +class UserInDB(User): + hashed_password: str diff --git a/app/test.db b/app/test.db new file mode 100644 index 0000000000000000000000000000000000000000..3bc00363184e26803f2885f6841a03820b0c6594 GIT binary patch literal 16384 zcmeI$O;g%H90u@&RT!A5=}c>L?9S9fnW;!X3)~D4l@fUgkrxkT0tqERL=qJ2%^v$j zdg*uYTl74K_>S7^$WLvto&rS{K}Qv;er4JAOHafKmY;|fB*y_009U* zDv%{X{vFS@%p={HoAdUGW7tmH)ct-br%0kI(LyONHR-KcS}sw)g1$HPs1gYBQJ!_~ zW*C-cj{G8W6LU~1R1fZ@`E|Df{c-d>!-jaCXTMdaT4!YVIsPqh6Q+t;Sz0V25;k>r zm8!HWDYR5pY3V?gKhQ|{I=n6_xjpgjOkZ>QhTcBb9A`GRb^1_^>9n=()I2q4wp^Aa zvE)VCdOKz!w+Fjwon zwAI}#+JlXFuP7u=PqylXqFz_USnW{h?>2^mogI0{8KwHAmc2P_Xu4${HP{fd?nbYA z(GMi28;YDb?27hamMGRNXEGb9pJLV|ks0pg1t%GcH#_OA-bBiu&4m3_F5c;lYhG5w z{c?o6V7LqJ7l-}-llYIZNC-dx0uX=z1Rwwb2tWV=5O{V1Z-~I&Kg}SkWP`nT2oZQi z*2DjvA6)PMuNdwR_nW(Vc7Awa2tWV=5P$##AOHafKmY;|fWUt#uuNE%5bquc5E2gm E1<