add user? new page? new web
This commit is contained in:
@@ -1,3 +1,14 @@
|
||||
FROM nginx:alpine
|
||||
COPY . /usr/share/nginx/html
|
||||
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"]
|
||||
306
SECURITY.md
Normal file
306
SECURITY.md
Normal file
@@ -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 при регистрации
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
58
main.py
58
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__":
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -15,3 +16,6 @@ typing_extensions==4.15.0
|
||||
uvicorn==0.40.0
|
||||
sqlalchemy==2.0.32
|
||||
pg8000==1.29.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
74
route/auth.py
Normal file
74
route/auth.py
Normal file
@@ -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"}
|
||||
99
route/auth_utils.py
Normal file
99
route/auth_utils.py
Normal file
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
68
uv.lock
generated
68
uv.lock
generated
@@ -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"
|
||||
|
||||
BIN
web/assets/favicon.png
Normal file
BIN
web/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 851 B |
BIN
web/assets/icons/IMG_4533.png
Normal file
BIN
web/assets/icons/IMG_4533.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
3
web/assets/icons/README
Normal file
3
web/assets/icons/README
Normal file
@@ -0,0 +1,3 @@
|
||||
# Положи сюда своё изображение с именем loading-animation.png
|
||||
# Это заменит мигающий логотип Flet при загрузке страницы.
|
||||
# Рекомендуемый размер: PNG, минимум 200x200 px.
|
||||
BIN
web/assets/icons/loading-animation.png
Normal file
BIN
web/assets/icons/loading-animation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -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):
|
||||
|
||||
50
web/info.py
50
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")
|
||||
|
||||
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 != ""
|
||||
|
||||
27
web/main.py
27
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.views.clear()
|
||||
page.run_task(_guarded_route_change, page.route)
|
||||
|
||||
view = ViewsHendler(page).get(page.route)
|
||||
if view: # проверяем, что view получен
|
||||
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(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")
|
||||
|
||||
# export_asgi_app=True — Flet отдаёт ASGI-объект вместо запуска встроенного сервера.
|
||||
# Uvicorn из CMD в Dockerfile подхватывает этот объект через «main:app».
|
||||
app = ft.run(main, export_asgi_app=True, assets_dir="assets")
|
||||
@@ -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)])
|
||||
}
|
||||
96
web/views/login_view.py
Normal file
96
web/views/login_view.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user