diff --git a/Dockerfile.web b/Dockerfile.web index 1e6fb46..a26e547 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -1,3 +1,14 @@ -FROM nginx:alpine -COPY . /usr/share/nginx/html -EXPOSE 80 \ No newline at end of file +FROM python:3.11-slim + +WORKDIR /app + +# Устанавливаем flet (uvicorn уже входит в зависимости flet) +RUN pip install --no-cache-dir flet "uvicorn[standard]" httpx + +# Копируем исходники веб-приложения +COPY . . + +EXPOSE 80 + +# Запускаем Flet как ASGI-приложение через uvicorn +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"] \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d467383 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,306 @@ +# Безопасность проекта — объяснение для чайников + +Этот документ объясняет простым языком, **что и зачем** было сделано для защиты API. + +--- + +## Оглавление + +1. [Что такое «небезопасный» API и почему это плохо](#1-что-такое-небезопасный-api) +2. [Что было сделано — краткий список](#2-что-было-сделано) +3. [Аутентификация — кто ты такой?](#3-аутентификация) +4. [JWT-токены — цифровой пропуск](#4-jwt-токены) +5. [Хеширование паролей](#5-хеширование-паролей) +6. [Защита маршрутов (роутов)](#6-защита-маршрутов) +7. [Admin Key — ключ администратора](#7-admin-key) +8. [Защита документации `/docs`](#8-защита-документации-docs) +9. [CORS — кто может обращаться к API из браузера](#9-cors) +10. [Переменные окружения — секреты не в коде](#10-переменные-окружения) +11. [Как всё настроить — пошаговая инструкция](#11-как-настроить) +12. [Как использовать API — примеры](#12-примеры-использования) + +--- + +## 1. Что такое «небезопасный» API + +Представь, что у тебя есть склад. **Небезопасный API** — это когда дверь склада открыта для всех: любой может зайти, взять что хочет или сломать что хочет. + +Конкретно, без защиты: +- любой человек в интернете мог удалить любого пользователя +- любой мог посмотреть чужие данные +- пароли хранились бы в открытом виде — украли базу = украли все пароли +- документация (Swagger) была видна всем, давая полную карту API для взломщика + +--- + +## 2. Что было сделано + +| Что | Зачем | +|-----|-------| +| JWT-токены | Пользователь должен войти, чтобы делать что-то важное | +| Хеширование паролей (bcrypt) | Даже если украдут БД — пароли не читаемы | +| Проверка владельца при PUT/DELETE | Ты можешь менять только свои данные | +| Admin Key | Опасные операции (инициализация БД) — только для администратора | +| HTTP Basic Auth на `/docs` | Документация API скрыта за паролем | +| CORS | Браузерные запросы принимаются только с разрешённых сайтов | +| Секреты в переменных окружения | Пароли и ключи не хранятся в коде | + +--- + +## 3. Аутентификация + +**Аутентификация** = подтверждение того, кто ты есть. + +Как это работает в нашем API: + +``` +1. Ты отправляешь логин и пароль на POST /auth/login +2. Сервер проверяет: правильный ли пароль? +3. Если да — сервер выдаёт тебе "токен" (длинную строку-пропуск) +4. При каждом следующем запросе ты добавляешь этот токен в заголовок +5. Сервер смотрит на токен и понимает, кто ты +``` + +Аналогия: как билет в кино. Купил один раз — показываешь при входе, не нужно снова платить. + +**Регистрация** — `POST /auth/register`: +```json +{ + "username": "ivan", + "password": "мойпароль123", + "first_name": "Иван", + "last_name": "Иванов" +} +``` + +**Вход** — `POST /auth/login` (формат form-data): +``` +username=ivan +password=мойпароль123 +``` + +Ответ: +```json +{ + "access_token": "eyJhbGci...(длинная строка)...", + "token_type": "bearer" +} +``` + +--- + +## 4. JWT-токены + +**JWT** (JSON Web Token) — это как паспорт, только цифровой. + +Токен состоит из трёх частей, разделённых точкой: + +``` +eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiIxMjMifQ . SflKxwRJSMeKKF2QT4 + заголовок данные подпись +``` + +- **Заголовок**: тип токена и алгоритм шифрования +- **Данные (payload)**: ID пользователя, время истечения токена +- **Подпись**: криптографическая подпись — без секретного ключа сервера её нельзя подделать + +Аналогия: токен — это как печать на паспорте. Печать нельзя нарисовать самому (без настоящей печати), поэтому её нельзя подделать. + +**Как использовать токен в запросах:** + +В заголовке HTTP: +``` +Authorization: Bearer eyJhbGci...(твой токен)... +``` + +**Срок жизни токена** задаётся переменной `ACCESS_TOKEN_EXPIRE_MINUTES` (по умолчанию 60 минут). + +--- + +## 5. Хеширование паролей + +Пароли **никогда** не хранятся в открытом виде. Вместо этого хранится их хеш. + +``` +Пароль: "мойпароль123" + ↓ bcrypt +Хеш: "$2b$12$eImiTXuWVxfM37uY4JANjQ..." +``` + +**Почему это безопасно:** +- Хеш нельзя "расшифровать" обратно в пароль +- Даже если база данных будет украдена — злоумышленник увидит только хеши +- При проверке он не расшифровывает, а снова хеширует введённый пароль и сравнивает хеши + +Алгоритм **bcrypt** специально медленный — это делает перебор паролей (brute force) очень долгим. + +--- + +## 6. Защита маршрутов + +Некоторые действия требуют авторизации, другие открыты для всех. + +| Маршрут | Открыт? | Объяснение | +|---------|---------|-----------| +| `POST /auth/register` | ✅ Да | Регистрация открыта всем | +| `POST /auth/login` | ✅ Да | Вход открыт всем | +| `GET /groups/` | ✅ Да | Просмотр групп — публичное | +| `POST /groups/` | 🔒 Нет | Создать группу — нужен токен | +| `PUT /users/{id}` | 🔒 Нет | Изменить профиль — только свой | +| `DELETE /users/{id}` | 🔒 Нет | Удалить — только свой аккаунт | +| `POST /init_database` | 🔒 Admin | Только с Admin Key | + +**Проверка владельца**: при попытке изменить или удалить чужие данные сервер вернёт ошибку `403 Forbidden`. + +--- + +## 7. Admin Key + +Некоторые операции очень опасны (например, инициализация базы данных). Для них нужен специальный ключ администратора. + +**Как использовать:** +``` +Заголовок запроса: X-Admin-Key: твой_секретный_admin_ключ +``` + +Если `ADMIN_KEY` не задан в переменных окружения — такие операции полностью отключены (сервер вернёт `503`). + +--- + +## 8. Защита документации `/docs` + +Swagger UI (`/docs`) — это интерактивная документация API. Она показывает все маршруты, параметры, позволяет делать запросы прямо из браузера. Это очень удобно для разработчика, но и очень удобно для взломщика. + +**Решение:** HTTP Basic Auth — браузер сам показывает окошко с запросом пароля. + +``` +Открываешь http://твой-сервер/docs + ↓ +Браузер показывает окошко: "Введите логин и пароль" + ↓ +Вводишь DOCS_USERNAME и DOCS_PASSWORD + ↓ +Видишь документацию +``` + +То же самое работает для `/redoc` и `/openapi.json`. + +--- + +## 9. CORS + +**CORS** (Cross-Origin Resource Sharing) — механизм браузера, который контролирует, с каких сайтов можно делать запросы к API. + +Аналогия: CORS — это как охранник на входе, который проверяет твой бейдж (адрес сайта). Если бейдж не из списка — не пропустит. + +Разрешённые источники в нашем проекте: +``` +http://localhost:3000 +http://127.0.0.1:3000 +``` + +Это значит, что только фронтенд, запущенный локально на порту 3000, может обращаться к API из браузера. + +> ⚠️ **Важно для production**: если у тебя есть публичный домен, добавь его в список `allow_origins` в `main.py`. + +--- + +## 10. Переменные окружения + +**Секреты никогда не должны быть в коде.** Если код попадёт на GitHub — все пароли станут публичными. + +Все секреты хранятся в переменных окружения (в `docker-compose.yml` или в `.env` файле). + +| Переменная | Что это | Как создать | +|-----------|---------|------------| +| `SECRET_KEY` | Ключ для подписи JWT-токенов | `python -c "import secrets; print(secrets.token_hex(32))"` | +| `ADMIN_KEY` | Пароль для admin-операций | Придумай длинную случайную строку | +| `DOCS_USERNAME` | Логин для доступа к `/docs` | Например: `admin` | +| `DOCS_PASSWORD` | Пароль для доступа к `/docs` | Придумай надёжный пароль | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | Сколько минут живёт токен | По умолчанию 60 | + +--- + +## 11. Как настроить + +### Шаг 1. Сгенерируй SECRET_KEY + +Открой терминал и запусти: +```bash +python -c "import secrets; print(secrets.token_hex(32))" +``` +Скопируй результат — это твой `SECRET_KEY`. + +Сделай то же самое для `ADMIN_KEY`. + +### Шаг 2. Задай пароль для документации + +Придумай любой логин и пароль для `DOCS_USERNAME` и `DOCS_PASSWORD`. + +### Шаг 3. Обнови docker-compose.yml + +Найди блок `environment` в `docker-compose.yml` и замени все `REPLACE_ME_...` на реальные значения: + +```yaml +environment: + - SECRET_KEY=abcdef1234... # результат из шага 1 + - ADMIN_KEY=supersecret456 # результат из шага 1 + - DOCS_USERNAME=admin + - DOCS_PASSWORD=мойпарольдокументации +``` + +### Шаг 4. Запусти проект + +```bash +docker-compose up --build +``` + +### Шаг 5. Проверь + +Открой в браузере `http://localhost:8000/docs` — должно появиться окошко с запросом пароля. + +--- + +## 12. Примеры использования + +### Зарегистрироваться +```bash +curl -X POST http://localhost:8000/auth/register \ + -H "Content-Type: application/json" \ + -d '{"username":"ivan","password":"пароль123","first_name":"Иван","last_name":"Иванов"}' +``` + +### Войти и получить токен +```bash +curl -X POST http://localhost:8000/auth/login \ + -d "username=ivan&password=пароль123" +``` + +### Использовать токен в запросе +```bash +TOKEN="eyJhbGci..." # твой токен из предыдущего шага + +curl -X GET http://localhost:8000/users/me \ + -H "Authorization: Bearer $TOKEN" +``` + +### Создать группу (нужен токен) +```bash +curl -X POST http://localhost:8000/groups/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name_group":"Группа 1","user_id":"uuid-пользователя"}' +``` + +### Открыть документацию в браузере +Просто зайди на `http://localhost:8000/docs` — браузер спросит логин/пароль. + +--- + +## Что ещё можно улучшить (на будущее) + +- **Rate limiting** — ограничение количества запросов с одного IP (защита от брутфорса) +- **Refresh tokens** — долгоживущие токены для обновления access token без повторного входа +- **HTTPS** — шифрование соединения (критично в production, настраивается на уровне nginx/proxy) +- **Логирование** — запись подозрительных действий (много неудачных попыток входа) +- **Email-верификация** — подтверждение email при регистрации diff --git a/bd/tables/users.py b/bd/tables/users.py index e2e1fdb..bbb31a0 100644 --- a/bd/tables/users.py +++ b/bd/tables/users.py @@ -9,6 +9,8 @@ class User(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) first_name = Column(String(150), nullable=False) last_name = Column(String(150), nullable=False) + username = Column(String(150), nullable=False, unique=True) + hashed_password = Column(String(256), nullable=False) __all__ = ["Base", "User"] diff --git a/docker-compose.yml b/docker-compose.yml index f454995..a80a7e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,13 @@ services: - REDIS_PORT=6379 - REDIS_DB=0 - REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn + # JWT — замените на длинную случайную строку (python -c "import secrets; print(secrets.token_hex(32))") + - SECRET_KEY=e8fe23297b4664a78ba8e8ebe34bac158398ac4d7dc6b664fd8d52b0094ee67b + # Ключ для admin-операций + - ADMIN_KEY=b00ac081344a99bf50dffeb942e7f4f1 + # Логин и пароль для доступа к /docs и /redoc + - DOCS_USERNAME=admin + - DOCS_PASSWORD=2cbae90b3d9f6710cf51354f volumes: - ./data:/app/data web: @@ -26,8 +33,8 @@ services: context: ./web dockerfile: ../Dockerfile.web ports: - - "3000:80" + - "80:80" + environment: + - API_URL=http://api:8000 depends_on: - api - volumes: - - ./web:/usr/share/nginx/html:ro diff --git a/main.py b/main.py index 4d74a51..a0c69f3 100644 --- a/main.py +++ b/main.py @@ -1,14 +1,17 @@ import os +import secrets import tomllib from pathlib import Path -from fastapi import FastAPI +from fastapi import Depends, FastAPI, HTTPException, status from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.security import HTTPBasic, HTTPBasicCredentials from route import base, holland_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud +from route import auth import uvicorn # Читаем версию из pyproject.toml try: - # Получаем абсолютный путь к pyproject.toml pyproject_path = Path(__file__).parent / "pyproject.toml" with open(pyproject_path, "rb") as f: pyproject = tomllib.load(f) @@ -17,18 +20,45 @@ except Exception as e: print(f"Warning: Could not read version from pyproject.toml: {e}") VERSION = "0.1.0" -# Читаем переменные окружения APP_PATH = os.getenv("APP_PATH", "/home/user/api-copp") -app = FastAPI(title="api", version=VERSION) +# --------------------------------------------------------------------------- +# Защита документации HTTP Basic Auth +# --------------------------------------------------------------------------- +_basic = HTTPBasic() + + +def _verify_docs(credentials: HTTPBasicCredentials = Depends(_basic)): + """Проверяет логин/пароль для доступа к /docs и /redoc.""" + docs_user = os.getenv("DOCS_USERNAME", "") + docs_pass = os.getenv("DOCS_PASSWORD", "") + if not docs_user or not docs_pass: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="API docs are disabled: DOCS_USERNAME / DOCS_PASSWORD not configured", + ) + # secrets.compare_digest защищает от timing-атак (перебора по времени ответа) + ok_user = secrets.compare_digest(credentials.username.encode(), docs_user.encode()) + ok_pass = secrets.compare_digest(credentials.password.encode(), docs_pass.encode()) + if not (ok_user and ok_pass): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + + +# docs_url/openapi_url/redoc_url отключены — добавляем защищённые маршруты вручную +app = FastAPI(title="api", version=VERSION, docs_url=None, openapi_url=None, redoc_url=None) # Разрешаем CORS для фронтенда (dev). При необходимости сузить список origins. app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_origins=["http://localhost", "http://127.0.0.1"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +app.include_router(auth.router) app.include_router(base.router) app.include_router(init_data_base.router) app.include_router(groups_crud.router) @@ -41,8 +71,24 @@ app.include_router(poll_crud.router) app.include_router(holland_crud.router) +# --------------------------------------------------------------------------- +# Защищённые маршруты документации +# --------------------------------------------------------------------------- +@app.get("/docs", include_in_schema=False) +def get_docs(_: None = Depends(_verify_docs)): + return get_swagger_ui_html(openapi_url="/openapi.json", title="API Docs") + + +@app.get("/redoc", include_in_schema=False) +def get_redoc(_: None = Depends(_verify_docs)): + return get_redoc_html(openapi_url="/openapi.json", title="API Docs") + + +@app.get("/openapi.json", include_in_schema=False) +def get_openapi_schema(_: None = Depends(_verify_docs)): + return app.openapi() + -# Доступна переменная APP_PATH для использования в приложении print(f"Application path: {APP_PATH}") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 60f0796..7ee223a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ dependencies = [ "sqlalchemy>=2.0.47", "psycopg>=3.3.3", "flet>=0.82.2", + "jose>=1.0.0", + "passlib>=1.7.4", + "python-jose>=3.5.0", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 4cd1ed1..6537eba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,10 +8,14 @@ idna==3.11 pydantic==2.12.5 pydantic_core==2.41.5 python-dotenv==1.2.1 +python-multipart==0.0.20 redis==5.0.0 starlette==0.50.0 typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.40.0 sqlalchemy==2.0.32 -pg8000==1.29.0 \ No newline at end of file +pg8000==1.29.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 \ No newline at end of file diff --git a/route/auth.py b/route/auth.py new file mode 100644 index 0000000..a4d45f5 --- /dev/null +++ b/route/auth.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel, field_validator + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from bd.tables.users import User +from route.auth_utils import hash_password, verify_password, create_access_token + +router = APIRouter(tags=["auth"], prefix="/auth") + + +def _get_session(): + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + return sessionmaker(bind=engine, future=True) + + +class RegisterIn(BaseModel): + username: str + password: str + first_name: str + last_name: str + + @field_validator("password") + @classmethod + def password_strength(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Password must be at least 8 characters") + return v + + @field_validator("username") + @classmethod + def username_clean(cls, v: str) -> str: + v = v.strip() + if len(v) < 3: + raise ValueError("Username must be at least 3 characters") + return v + + +@router.post("/register", status_code=201) +def register(payload: RegisterIn): + Session = _get_session() + with Session() as session: + existing = session.query(User).filter(User.username == payload.username).first() + if existing: + raise HTTPException(status_code=400, detail="Username already taken") + user = User( + first_name=payload.first_name.strip(), + last_name=payload.last_name.strip(), + username=payload.username, + hashed_password=hash_password(payload.password), + ) + session.add(user) + session.commit() + session.refresh(user) + return {"id": str(user.id), "username": user.username} + + +@router.post("/login") +def login(form: OAuth2PasswordRequestForm = Depends()): + Session = _get_session() + with Session() as session: + user = session.query(User).filter(User.username == form.username).first() + if not user or not verify_password(form.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + token = create_access_token({"sub": str(user.id)}) + return {"access_token": token, "token_type": "bearer"} diff --git a/route/auth_utils.py b/route/auth_utils.py new file mode 100644 index 0000000..5e4664a --- /dev/null +++ b/route/auth_utils.py @@ -0,0 +1,99 @@ +import hashlib +import os +import uuid +from datetime import datetime, timedelta, timezone +from typing import Optional + +from fastapi import Depends, Header, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +SECRET_KEY: str = os.getenv("SECRET_KEY", "") +if not SECRET_KEY: + import secrets + SECRET_KEY = secrets.token_hex(32) + print("WARNING: SECRET_KEY not set in environment. Using a random key — tokens will be invalidated on restart!") + +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + + +def _prehash(password: str) -> str: + """SHA-256 пред-хеш чтобы обойти 72-байтовый лимит bcrypt.""" + return hashlib.sha256(password.encode()).hexdigest() + + +def hash_password(password: str) -> str: + return pwd_context.hash(_prehash(password)) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(_prehash(plain), hashed) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode["exp"] = expire + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +def _get_session(): + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + return sessionmaker(bind=engine, future=True) + + +def get_current_user(token: str = Depends(oauth2_scheme)): + """FastAPI dependency: валидирует Bearer-токен и возвращает объект User.""" + credentials_exc = 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]) + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exc + except JWTError: + raise credentials_exc + + from bd.tables.users import User + + try: + uid = uuid.UUID(user_id) + except Exception: + raise credentials_exc + + Session = _get_session() + with Session() as session: + user = session.get(User, uid) + if user is None: + raise credentials_exc + # detach from session so object can be used outside + session.expunge(user) + return user + + +def require_admin_key(x_admin_key: Optional[str] = Header(default=None)): + """FastAPI dependency: проверяет секретный ключ администратора (заголовок X-Admin-Key).""" + admin_key = os.getenv("ADMIN_KEY", "") + if not admin_key: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Admin access is disabled: ADMIN_KEY not configured", + ) + if x_admin_key != admin_key: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid admin key", + ) diff --git a/route/choice_crud.py b/route/choice_crud.py index c3332d7..7317d23 100644 --- a/route/choice_crud.py +++ b/route/choice_crud.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional import uuid @@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker from bd.tables.choice import Choice from bd.tables.question import Question +from route.auth_utils import get_current_user router = APIRouter(tags=["choices"], prefix="/choices") @@ -31,7 +32,7 @@ def get_session(): @router.post("/", status_code=201) -def create_choice(payload: ChoiceCreate): +def create_choice(payload: ChoiceCreate, _: object = Depends(get_current_user)): Session = get_session() try: qid = uuid.UUID(payload.question_id) @@ -49,7 +50,7 @@ def create_choice(payload: ChoiceCreate): @router.put("/{choice_id}") -def update_choice(choice_id: str, payload: ChoiceUpdate): +def update_choice(choice_id: str, payload: ChoiceUpdate, _: object = Depends(get_current_user)): Session = get_session() try: cid = uuid.UUID(choice_id) @@ -70,7 +71,7 @@ def update_choice(choice_id: str, payload: ChoiceUpdate): @router.delete("/{choice_id}", status_code=204) -def delete_choice(choice_id: str): +def delete_choice(choice_id: str, _: object = Depends(get_current_user)): Session = get_session() try: cid = uuid.UUID(choice_id) diff --git a/route/groups_crud.py b/route/groups_crud.py index 3255f24..98385d0 100644 --- a/route/groups_crud.py +++ b/route/groups_crud.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional import uuid @@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker from bd.tables.group import Group from bd.tables.users import User +from route.auth_utils import get_current_user router = APIRouter(tags=["groups"], prefix="/groups") @@ -30,7 +31,7 @@ def get_session(): @router.post("/", status_code=201) -def create_group(payload: GroupCreate): +def create_group(payload: GroupCreate, _: object = Depends(get_current_user)): Session = get_session() try: uid = uuid.UUID(payload.user_id) @@ -70,7 +71,7 @@ def get_group(group_id: str): @router.put("/{group_id}") -def update_group(group_id: str, payload: GroupUpdate): +def update_group(group_id: str, payload: GroupUpdate, _: object = Depends(get_current_user)): Session = get_session() try: gid = uuid.UUID(group_id) @@ -98,7 +99,7 @@ def update_group(group_id: str, payload: GroupUpdate): @router.delete("/{group_id}", status_code=204) -def delete_group(group_id: str): +def delete_group(group_id: str, _: object = Depends(get_current_user)): Session = get_session() try: gid = uuid.UUID(group_id) diff --git a/route/init_data_base.py b/route/init_data_base.py index d387e82..e6f2c25 100644 --- a/route/init_data_base.py +++ b/route/init_data_base.py @@ -1,14 +1,16 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException import pkgutil import importlib from pathlib import Path from typing import List from bd import Settings -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.exc import SQLAlchemyError from pydantic import BaseModel +from route.auth_utils import require_admin_key + router = APIRouter(tags=["db"]) @@ -40,7 +42,7 @@ def collect_metadatas(): return metadatas -@router.post("/db/create-tables") +@router.post("/db/create-tables", dependencies=[Depends(require_admin_key)]) async def create_tables(): """Создаёт все таблицы, описанные в модулях `db.tables`. @@ -70,11 +72,29 @@ async def create_tables(): raise HTTPException(status_code=500, detail=str(e)) +@router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)]) +async def migrate_users(): + """Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют.""" + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + try: + with engine.connect() as conn: + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS username VARCHAR(150), + ADD COLUMN IF NOT EXISTS hashed_password VARCHAR(256) + """)) + conn.commit() + return {"status": "ok", "detail": "Columns username and hashed_password ensured in users table"} + except SQLAlchemyError as e: + raise HTTPException(status_code=500, detail=str(e)) + + class ClearDBIn(BaseModel): confirm: bool -@router.post("/db/clear") +@router.post("/db/clear", dependencies=[Depends(require_admin_key)]) async def clear_tables(payload: ClearDBIn): """Полная очистка всех таблиц, описанных в `bd.tables`. diff --git a/route/organizations_crud.py b/route/organizations_crud.py index ded5493..4f98123 100644 --- a/route/organizations_crud.py +++ b/route/organizations_crud.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional import uuid @@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker from bd.tables.organization import Organization from bd.tables.group import Group +from route.auth_utils import get_current_user router = APIRouter(tags=["organizations"], prefix="/organizations") @@ -30,7 +31,7 @@ def get_session(): @router.post("/", status_code=201) -def create_organization(payload: OrganizationCreate): +def create_organization(payload: OrganizationCreate, _: object = Depends(get_current_user)): Session = get_session() try: gid = uuid.UUID(payload.group_id) @@ -70,7 +71,7 @@ def get_organization(org_id: str): @router.put("/{org_id}") -def update_organization(org_id: str, payload: OrganizationUpdate): +def update_organization(org_id: str, payload: OrganizationUpdate, _: object = Depends(get_current_user)): Session = get_session() try: oid = uuid.UUID(org_id) @@ -98,7 +99,7 @@ def update_organization(org_id: str, payload: OrganizationUpdate): @router.delete("/{org_id}", status_code=204) -def delete_organization(org_id: str): +def delete_organization(org_id: str, _: object = Depends(get_current_user)): Session = get_session() try: oid = uuid.UUID(org_id) diff --git a/route/poll_crud.py b/route/poll_crud.py index 189007d..f8a2aa8 100644 --- a/route/poll_crud.py +++ b/route/poll_crud.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import List, Optional import uuid @@ -10,6 +10,7 @@ from sqlalchemy.orm import sessionmaker from bd.tables.poll import Poll from bd.tables.question import Question from bd.tables.choice import Choice +from route.auth_utils import get_current_user router = APIRouter(tags=["polls"], prefix="/polls") @@ -39,7 +40,7 @@ def get_session(): @router.post("/", status_code=201) -def create_poll(payload: PollIn): +def create_poll(payload: PollIn, _: object = Depends(get_current_user)): Session = get_session() with Session() as session: poll = Poll(title=payload.title, description=payload.description) diff --git a/route/question_crud.py b/route/question_crud.py index f545e63..fb837eb 100644 --- a/route/question_crud.py +++ b/route/question_crud.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional import uuid @@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker from bd.tables.question import Question from bd.tables.poll import Poll +from route.auth_utils import get_current_user router = APIRouter(tags=["questions"], prefix="/questions") @@ -33,7 +34,7 @@ def get_session(): @router.post("/", status_code=201) -def create_question(payload: QuestionCreate): +def create_question(payload: QuestionCreate, _: object = Depends(get_current_user)): Session = get_session() try: pid = uuid.UUID(payload.poll_id) @@ -51,7 +52,7 @@ def create_question(payload: QuestionCreate): @router.put("/{question_id}") -def update_question(question_id: str, payload: QuestionUpdate): +def update_question(question_id: str, payload: QuestionUpdate, _: object = Depends(get_current_user)): Session = get_session() try: qid = uuid.UUID(question_id) @@ -74,7 +75,7 @@ def update_question(question_id: str, payload: QuestionUpdate): @router.delete("/{question_id}", status_code=204) -def delete_question(question_id: str): +def delete_question(question_id: str, _: object = Depends(get_current_user)): Session = get_session() try: qid = uuid.UUID(question_id) diff --git a/route/users_crud.py b/route/users_crud.py index 0919de1..2a79e57 100644 --- a/route/users_crud.py +++ b/route/users_crud.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, field_validator from typing import Optional import uuid @@ -8,18 +8,36 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from bd.tables.users import User +from route.auth_utils import get_current_user, require_admin_key, hash_password router = APIRouter(tags=["users"], prefix="/users") class UserCreate(BaseModel): + username: str + password: str first_name: str last_name: str + @field_validator("username") + @classmethod + def username_clean(cls, v: str) -> str: + v = v.strip() + if len(v) < 3: + raise ValueError("Username must be at least 3 characters") + return v + + @field_validator("password") + @classmethod + def password_strength(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Password must be at least 8 characters") + return v + class UserUpdate(BaseModel): - first_name: Optional[str] - last_name: Optional[str] + first_name: Optional[str] = None + last_name: Optional[str] = None def get_session(): @@ -28,24 +46,34 @@ def get_session(): return sessionmaker(bind=engine, future=True) -@router.post("/", status_code=201) +@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) def create_user(payload: UserCreate): Session = get_session() with Session() as session: - user = User(first_name=payload.first_name, last_name=payload.last_name) + existing = session.query(User).filter(User.username == payload.username).first() + if existing: + raise HTTPException(status_code=400, detail="Username already taken") + user = User( + first_name=payload.first_name.strip(), + last_name=payload.last_name.strip(), + username=payload.username, + hashed_password=hash_password(payload.password), + ) session.add(user) session.commit() session.refresh(user) - return {"id": str(user.id), "first_name": user.first_name, "last_name": user.last_name} + return {"id": str(user.id), "username": user.username} @router.put("/{user_id}") -def update_user(user_id: str, payload: UserUpdate): +def update_user(user_id: str, payload: UserUpdate, current_user: User = Depends(get_current_user)): Session = get_session() try: uid = uuid.UUID(user_id) except Exception: raise HTTPException(status_code=400, detail="Invalid UUID") + if uid != current_user.id: + raise HTTPException(status_code=403, detail="You can only update your own profile") with Session() as session: user = session.get(User, uid) if not user: @@ -61,12 +89,14 @@ def update_user(user_id: str, payload: UserUpdate): @router.delete("/{user_id}", status_code=204) -def delete_user(user_id: str): +def delete_user(user_id: str, current_user: User = Depends(get_current_user)): Session = get_session() try: uid = uuid.UUID(user_id) except Exception: raise HTTPException(status_code=400, detail="Invalid UUID") + if uid != current_user.id: + raise HTTPException(status_code=403, detail="You can only delete your own account") with Session() as session: user = session.get(User, uid) if not user: @@ -76,16 +106,13 @@ def delete_user(user_id: str): return {} -@router.get("/") -def list_users(): - Session = get_session() - with Session() as session: - rows = session.query(User).all() - return [{"id": str(u.id), "first_name": u.first_name, "last_name": u.last_name} for u in rows] +@router.get("/me") +def get_me(current_user: User = Depends(get_current_user)): + return {"id": str(current_user.id), "username": current_user.username, "first_name": current_user.first_name, "last_name": current_user.last_name} @router.get("/{user_id}") -def get_user(user_id: str): +def get_user(user_id: str, current_user: User = Depends(get_current_user)): Session = get_session() try: uid = uuid.UUID(user_id) diff --git a/uv.lock b/uv.lock index 4d5a7f3..c4ec101 100644 --- a/uv.lock +++ b/uv.lock @@ -39,9 +39,12 @@ source = { editable = "." } dependencies = [ { name = "fastapi" }, { name = "flet" }, + { name = "jose" }, + { name = "passlib" }, { name = "psycopg" }, { name = "pydantic" }, { name = "python-dotenv" }, + { name = "python-jose" }, { name = "redis" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -60,11 +63,14 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "fastapi", specifier = ">=0.128.0" }, { name = "flet", specifier = ">=0.82.2" }, + { name = "jose", specifier = ">=1.0.0" }, + { name = "passlib", specifier = ">=1.7.4" }, { name = "psycopg", specifier = ">=3.3.3" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "python-jose", specifier = ">=3.5.0" }, { name = "redis", specifier = ">=7.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "sqlalchemy", specifier = ">=2.0.47" }, @@ -124,6 +130,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + [[package]] name = "fastapi" version = "0.128.0" @@ -249,6 +267,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jose" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/3d/832caa69cd0d3be2d608d8290be2221072669aa88e87690837f6b31c480f/jose-1.0.0.tar.gz", hash = "sha256:8436c3617cd94e1ba97828fbb1ce27c129f66c78fb855b4bb47e122b5f345fba", size = 9153, upload-time = "2015-11-13T10:52:21.506Z" } + [[package]] name = "msgpack" version = "1.1.2" @@ -302,6 +326,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + [[package]] name = "pathspec" version = "1.0.4" @@ -341,6 +374,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -441,6 +483,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -507,6 +563,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/ed/92e9b8a3ffc562f21df14ef2538f54e911df29730e1f0d79130a4edc86e7/repath-0.9.0-py3-none-any.whl", hash = "sha256:ee079d6c91faeb843274d22d8f786094ee01316ecfe293a1eb6546312bb6a318", size = 4738, upload-time = "2019-10-08T00:25:20.842Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.14.14" diff --git a/web/assets/favicon.png b/web/assets/favicon.png new file mode 100644 index 0000000..8ecde27 Binary files /dev/null and b/web/assets/favicon.png differ diff --git a/web/assets/icons/IMG_4533.png b/web/assets/icons/IMG_4533.png new file mode 100644 index 0000000..7c9703b Binary files /dev/null and b/web/assets/icons/IMG_4533.png differ diff --git a/web/assets/icons/README b/web/assets/icons/README new file mode 100644 index 0000000..709aa22 --- /dev/null +++ b/web/assets/icons/README @@ -0,0 +1,3 @@ +# Положи сюда своё изображение с именем loading-animation.png +# Это заменит мигающий логотип Flet при загрузке страницы. +# Рекомендуемый размер: PNG, минимум 200x200 px. diff --git a/web/assets/icons/loading-animation.png b/web/assets/icons/loading-animation.png new file mode 100644 index 0000000..91c0a0c Binary files /dev/null and b/web/assets/icons/loading-animation.png differ diff --git a/web/designer.py b/web/designer.py index 841b649..f0c036f 100644 --- a/web/designer.py +++ b/web/designer.py @@ -9,10 +9,31 @@ class clolors: -class TextField_input(ft.TextField): - def __init__(self): +class CastomTextField_input(ft.TextField): + def __init__(self, on_change, on_submit, label="Ваше имя", hint_text="Введите текст...", prefix_icon=None, suffix_icon=None): super().__init__( - + label=label, + hint_text=hint_text, + filled=True, # заливка фона + bgcolor=clolors.ferst, # цвет заливки + border_color=clolors.laer2, # цвет границы + border_radius=12, # скругление углов + label_style=ft.TextStyle( + color=clolors.laer3, + weight=ft.FontWeight.BOLD, + ), + hint_style=ft.TextStyle( + color=clolors.laer1, + italic=True, + ), + text_style=ft.TextStyle( + color=clolors.laer3, + size=16, + ), + prefix_icon=prefix_icon, + suffix_icon=suffix_icon, + on_change=on_change, + on_submit=on_submit, ) class CastomButton(ft.Button): diff --git a/web/info.py b/web/info.py index 392e687..23d2fed 100644 --- a/web/info.py +++ b/web/info.py @@ -1,7 +1,49 @@ +import os +import httpx import flet as ft -class user(): - def __init__(self, page: ft.Page): - pass +API_URL = os.getenv("API_URL", "http://api:8000") +_TOKEN_KEY = "auth_token" -#page.client_storage.set("token", "your_jwt_token_here") \ No newline at end of file + +class AuthService: + """Сервис авторизации: логин, логаут, проверка токена.""" + + def __init__(self, page: ft.Page): + self._page = page + + async def login(self, username: str, password: str) -> str | None: + """ + Отправляет запрос на POST /auth/login. + Возвращает None при успехе, строку с ошибкой при неудаче. + """ + try: + async with httpx.AsyncClient(base_url=API_URL, timeout=10) as client: + resp = await client.post( + "/auth/login", + data={"username": username, "password": password}, + ) + if resp.status_code == 200: + token = resp.json()["access_token"] + self._page.session.set(_TOKEN_KEY, token) + return None + elif resp.status_code == 401: + return "Неверный логин или пароль" + else: + return f"Ошибка сервера: {resp.status_code}" + except httpx.ConnectError: + return "Не удалось подключиться к серверу" + except Exception as e: + return f"Ошибка: {e}" + + async def logout(self): + """Удаляет токен — пользователь разлогинен.""" + self._page.session.remove(_TOKEN_KEY) + + async def get_token(self) -> str | None: + """Возвращает сохранённый токен или None если не авторизован.""" + return self._page.session.get(_TOKEN_KEY) + + async def is_authenticated(self) -> bool: + token = await self.get_token() + return token is not None and token != "" diff --git a/web/main.py b/web/main.py index 09bf7e2..ecf2adf 100644 --- a/web/main.py +++ b/web/main.py @@ -1,19 +1,36 @@ import flet as ft from view import ViewsHendler +import designer as ds +from info import AuthService async def main(page: ft.Page): page.title = "COPP" + page.bgcolor = ds.clolors.background + + auth = AuthService(page) + _PUBLIC_ROUTES = {"/login"} def route_change(e: ft.RouteChangeEvent): - print("Route changed to:", page.route) + page.run_task(_guarded_route_change, page.route) + + async def _guarded_route_change(route: str): + if route not in _PUBLIC_ROUTES and not await auth.is_authenticated(): + await page.push_route("/login") + return page.views.clear() - - view = ViewsHendler(page).get(page.route) - if view: # проверяем, что view получен + view = ViewsHendler(page).get(route) + if view: page.views.append(view) + else: + await page.push_route("/main") page.update() page.on_route_change = route_change + + await page.push_route("/main") - -ft.run(main, port=80345, assets_dir="assets") \ No newline at end of file + + +# export_asgi_app=True — Flet отдаёт ASGI-объект вместо запуска встроенного сервера. +# Uvicorn из CMD в Dockerfile подхватывает этот объект через «main:app». +app = ft.run(main, export_asgi_app=True, assets_dir="assets") \ No newline at end of file diff --git a/web/view.py b/web/view.py index ec77868..417d02c 100644 --- a/web/view.py +++ b/web/view.py @@ -1,9 +1,10 @@ import flet as ft -from views import main_view +from views import main_view, login_view def ViewsHendler(page: ft.Page): return { "/main": main_view.MainView(), + "/login": login_view.LoginView(page), "/about": ft.View(route="/about", controls=[ft.Text("О нас",color="#008000", size=20)]), "/contact": ft.View(route="/contact", controls=[ft.Text("Контакты",color="#FF0000", size=20)]) } \ No newline at end of file diff --git a/web/views/login_view.py b/web/views/login_view.py new file mode 100644 index 0000000..cd74dd6 --- /dev/null +++ b/web/views/login_view.py @@ -0,0 +1,96 @@ +import flet as ft +import designer as ds +from info import AuthService + + +class LoginView(ft.View): + def __init__(self, page: ft.Page): + self._auth = AuthService(page) + + self._username = ft.TextField( + label="Логин", + hint_text="Введите логин...", + filled=True, + bgcolor=ds.clolors.ferst, + border_color=ds.clolors.laer2, + border_radius=12, + prefix_icon=ft.Icons.PERSON, + label_style=ft.TextStyle(color=ds.clolors.laer3, weight=ft.FontWeight.BOLD), + text_style=ft.TextStyle(color=ds.clolors.laer3, size=16), + ) + + self._password = ft.TextField( + label="Пароль", + hint_text="Введите пароль...", + filled=True, + bgcolor=ds.clolors.ferst, + border_color=ds.clolors.laer2, + border_radius=12, + prefix_icon=ft.Icons.LOCK, + password=True, + can_reveal_password=True, + label_style=ft.TextStyle(color=ds.clolors.laer3, weight=ft.FontWeight.BOLD), + text_style=ft.TextStyle(color=ds.clolors.laer3, size=16), + on_submit=lambda e: page.run_task(self._do_login), + ) + + self._error = ft.Text("", color=ft.Colors.RED_400, size=14, visible=False) + + self._btn = ds.CastomButton( + text="Войти", + on_click=lambda e: page.run_task(self._do_login), + ) + + super().__init__( + route="/login", + bgcolor=ds.clolors.background, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + vertical_alignment=ft.MainAxisAlignment.CENTER, + ) + self.controls = [ + ft.Text("COPP", color=ds.clolors.ferst, size=30, weight=ft.FontWeight.BOLD), + ft.Container(height=8), + ft.Text("Вход в систему", color=ds.clolors.ferst, size=16), + ft.Container(height=24), + ft.Container( + width=360, + padding=ft.padding.all(24), + bgcolor=ds.clolors.laer3, + border_radius=16, + content=ft.Column( + spacing=16, + controls=[ + self._username, + self._password, + self._error, + self._btn, + ], + ), + ), + ] + + async def _do_login(self): + username = self._username.value.strip() + password = self._password.value + + if not username or not password: + self._show_error("Заполните логин и пароль") + return + + self._btn.disabled = True + self._error.visible = False + self.update() + + error = await self._auth.login(username, password) + + if error: + self._btn.disabled = False + self._show_error(error) + else: + # Успешный вход — переходим на главную + await self.page.push_route("/main") + + def _show_error(self, message: str): + self._error.value = message + self._error.visible = True + self.update()