diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..06d7405 Binary files /dev/null and b/app/__init__.py differ diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..d861049 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,77 @@ +import secrets +from datetime import datetime, timedelta + +import bcrypt +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.orm import Session as DbSession + +from app.config import settings +from app.database import get_db +from app.models import Session, User + +SESSION_COOKIE = "session_token" + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode(), hashed.encode()) + + +def create_session_token() -> str: + return secrets.token_urlsafe(32) + + +def create_user_session(db: DbSession, user: User) -> Session: + token = create_session_token() + expires_at = datetime.utcnow() + timedelta(hours=settings.session_lifetime_hours) + session = Session(user_id=user.id, token=token, expires_at=expires_at) + db.add(session) + db.commit() + db.refresh(session) + return session + + +def get_session_by_token(db: DbSession, token: str) -> Session | None: + session = db.query(Session).filter(Session.token == token, Session.is_active.is_(True)).first() + if not session: + return None + if session.expires_at < datetime.utcnow(): + session.is_active = False + db.commit() + return None + return session + + +def get_current_user(request: Request, db: DbSession = Depends(get_db)) -> User: + token = request.cookies.get(SESSION_COOKIE) + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + session = get_session_by_token(db, token) + if not session: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired") + user = db.query(User).filter(User.id == session.user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user + + +def get_current_user_optional(request: Request, db: DbSession = Depends(get_db)) -> User | None: + token = request.cookies.get(SESSION_COOKIE) + if not token: + return None + session = get_session_by_token(db, token) + if not session: + return None + return db.query(User).filter(User.id == session.user_id).first() + + +def verify_api_key(request: Request) -> None: + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + key = auth.removeprefix("Bearer ").strip() + if not secrets.compare_digest(key, settings.api_key): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid API key") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..496c8a1 --- /dev/null +++ b/app/config.py @@ -0,0 +1,14 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + secret_key: str = "change-me-in-production-use-env-var" + database_url: str = "sqlite:///./robot_management.db" + session_lifetime_hours: int = 24 + api_key: str = "demo-api-key-change-in-production" + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..f797dd9 --- /dev/null +++ b/app/database.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from app.config import settings + +engine = create_engine( + settings.database_url, + connect_args={"check_same_thread": False}, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..eeb7ac8 --- /dev/null +++ b/app/main.py @@ -0,0 +1,25 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.database import Base, engine +from app.routers import api, web + + +@asynccontextmanager +async def lifespan(app: FastAPI): + Base.metadata.create_all(bind=engine) + yield + + +app = FastAPI( + title="Robot Management System", + description="Прототип веб-системы управления роботами — личный кабинет и API", + version="1.0.0", + lifespan=lifespan, +) + +app.mount("/static", StaticFiles(directory="static"), name="static") +app.include_router(web.router) +app.include_router(api.router)