add user? new page? new web
This commit is contained in:
@@ -1,3 +1,14 @@
|
|||||||
FROM nginx:alpine
|
FROM python:3.11-slim
|
||||||
COPY . /usr/share/nginx/html
|
|
||||||
EXPOSE 80
|
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)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
first_name = Column(String(150), nullable=False)
|
first_name = Column(String(150), nullable=False)
|
||||||
last_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"]
|
__all__ = ["Base", "User"]
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ services:
|
|||||||
- REDIS_PORT=6379
|
- REDIS_PORT=6379
|
||||||
- REDIS_DB=0
|
- REDIS_DB=0
|
||||||
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
|
- 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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
web:
|
web:
|
||||||
@@ -26,8 +33,8 @@ services:
|
|||||||
context: ./web
|
context: ./web
|
||||||
dockerfile: ../Dockerfile.web
|
dockerfile: ../Dockerfile.web
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "80:80"
|
||||||
|
environment:
|
||||||
|
- API_URL=http://api:8000
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
volumes:
|
|
||||||
- ./web:/usr/share/nginx/html:ro
|
|
||||||
|
|||||||
58
main.py
58
main.py
@@ -1,14 +1,17 @@
|
|||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI
|
from fastapi import Depends, FastAPI, HTTPException, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 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
|
import uvicorn
|
||||||
|
|
||||||
# Читаем версию из pyproject.toml
|
# Читаем версию из pyproject.toml
|
||||||
try:
|
try:
|
||||||
# Получаем абсолютный путь к pyproject.toml
|
|
||||||
pyproject_path = Path(__file__).parent / "pyproject.toml"
|
pyproject_path = Path(__file__).parent / "pyproject.toml"
|
||||||
with open(pyproject_path, "rb") as f:
|
with open(pyproject_path, "rb") as f:
|
||||||
pyproject = tomllib.load(f)
|
pyproject = tomllib.load(f)
|
||||||
@@ -17,18 +20,45 @@ except Exception as e:
|
|||||||
print(f"Warning: Could not read version from pyproject.toml: {e}")
|
print(f"Warning: Could not read version from pyproject.toml: {e}")
|
||||||
VERSION = "0.1.0"
|
VERSION = "0.1.0"
|
||||||
|
|
||||||
# Читаем переменные окружения
|
|
||||||
APP_PATH = os.getenv("APP_PATH", "/home/user/api-copp")
|
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.
|
# Разрешаем CORS для фронтенда (dev). При необходимости сузить список origins.
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
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_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.include_router(auth.router)
|
||||||
app.include_router(base.router)
|
app.include_router(base.router)
|
||||||
app.include_router(init_data_base.router)
|
app.include_router(init_data_base.router)
|
||||||
app.include_router(groups_crud.router)
|
app.include_router(groups_crud.router)
|
||||||
@@ -41,8 +71,24 @@ app.include_router(poll_crud.router)
|
|||||||
app.include_router(holland_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}")
|
print(f"Application path: {APP_PATH}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ dependencies = [
|
|||||||
"sqlalchemy>=2.0.47",
|
"sqlalchemy>=2.0.47",
|
||||||
"psycopg>=3.3.3",
|
"psycopg>=3.3.3",
|
||||||
"flet>=0.82.2",
|
"flet>=0.82.2",
|
||||||
|
"jose>=1.0.0",
|
||||||
|
"passlib>=1.7.4",
|
||||||
|
"python-jose>=3.5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ idna==3.11
|
|||||||
pydantic==2.12.5
|
pydantic==2.12.5
|
||||||
pydantic_core==2.41.5
|
pydantic_core==2.41.5
|
||||||
python-dotenv==1.2.1
|
python-dotenv==1.2.1
|
||||||
|
python-multipart==0.0.20
|
||||||
redis==5.0.0
|
redis==5.0.0
|
||||||
starlette==0.50.0
|
starlette==0.50.0
|
||||||
typing-inspection==0.4.2
|
typing-inspection==0.4.2
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
uvicorn==0.40.0
|
uvicorn==0.40.0
|
||||||
sqlalchemy==2.0.32
|
sqlalchemy==2.0.32
|
||||||
pg8000==1.29.0
|
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 pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from bd.tables.choice import Choice
|
from bd.tables.choice import Choice
|
||||||
from bd.tables.question import Question
|
from bd.tables.question import Question
|
||||||
|
from route.auth_utils import get_current_user
|
||||||
|
|
||||||
router = APIRouter(tags=["choices"], prefix="/choices")
|
router = APIRouter(tags=["choices"], prefix="/choices")
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ def get_session():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/", status_code=201)
|
@router.post("/", status_code=201)
|
||||||
def create_choice(payload: ChoiceCreate):
|
def create_choice(payload: ChoiceCreate, _: object = Depends(get_current_user)):
|
||||||
Session = get_session()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
qid = uuid.UUID(payload.question_id)
|
qid = uuid.UUID(payload.question_id)
|
||||||
@@ -49,7 +50,7 @@ def create_choice(payload: ChoiceCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{choice_id}")
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
cid = uuid.UUID(choice_id)
|
cid = uuid.UUID(choice_id)
|
||||||
@@ -70,7 +71,7 @@ def update_choice(choice_id: str, payload: ChoiceUpdate):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{choice_id}", status_code=204)
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
cid = uuid.UUID(choice_id)
|
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 pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from bd.tables.group import Group
|
from bd.tables.group import Group
|
||||||
from bd.tables.users import User
|
from bd.tables.users import User
|
||||||
|
from route.auth_utils import get_current_user
|
||||||
|
|
||||||
router = APIRouter(tags=["groups"], prefix="/groups")
|
router = APIRouter(tags=["groups"], prefix="/groups")
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ def get_session():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/", status_code=201)
|
@router.post("/", status_code=201)
|
||||||
def create_group(payload: GroupCreate):
|
def create_group(payload: GroupCreate, _: object = Depends(get_current_user)):
|
||||||
Session = get_session()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
uid = uuid.UUID(payload.user_id)
|
uid = uuid.UUID(payload.user_id)
|
||||||
@@ -70,7 +71,7 @@ def get_group(group_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{group_id}")
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
gid = uuid.UUID(group_id)
|
gid = uuid.UUID(group_id)
|
||||||
@@ -98,7 +99,7 @@ def update_group(group_id: str, payload: GroupUpdate):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{group_id}", status_code=204)
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
gid = uuid.UUID(group_id)
|
gid = uuid.UUID(group_id)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
import pkgutil
|
import pkgutil
|
||||||
import importlib
|
import importlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from bd import Settings
|
from bd import Settings
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from route.auth_utils import require_admin_key
|
||||||
|
|
||||||
router = APIRouter(tags=["db"])
|
router = APIRouter(tags=["db"])
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ def collect_metadatas():
|
|||||||
return metadatas
|
return metadatas
|
||||||
|
|
||||||
|
|
||||||
@router.post("/db/create-tables")
|
@router.post("/db/create-tables", dependencies=[Depends(require_admin_key)])
|
||||||
async def create_tables():
|
async def create_tables():
|
||||||
"""Создаёт все таблицы, описанные в модулях `db.tables`.
|
"""Создаёт все таблицы, описанные в модулях `db.tables`.
|
||||||
|
|
||||||
@@ -70,11 +72,29 @@ async def create_tables():
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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):
|
class ClearDBIn(BaseModel):
|
||||||
confirm: bool
|
confirm: bool
|
||||||
|
|
||||||
|
|
||||||
@router.post("/db/clear")
|
@router.post("/db/clear", dependencies=[Depends(require_admin_key)])
|
||||||
async def clear_tables(payload: ClearDBIn):
|
async def clear_tables(payload: ClearDBIn):
|
||||||
"""Полная очистка всех таблиц, описанных в `bd.tables`.
|
"""Полная очистка всех таблиц, описанных в `bd.tables`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from bd.tables.organization import Organization
|
from bd.tables.organization import Organization
|
||||||
from bd.tables.group import Group
|
from bd.tables.group import Group
|
||||||
|
from route.auth_utils import get_current_user
|
||||||
|
|
||||||
router = APIRouter(tags=["organizations"], prefix="/organizations")
|
router = APIRouter(tags=["organizations"], prefix="/organizations")
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ def get_session():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/", status_code=201)
|
@router.post("/", status_code=201)
|
||||||
def create_organization(payload: OrganizationCreate):
|
def create_organization(payload: OrganizationCreate, _: object = Depends(get_current_user)):
|
||||||
Session = get_session()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
gid = uuid.UUID(payload.group_id)
|
gid = uuid.UUID(payload.group_id)
|
||||||
@@ -70,7 +71,7 @@ def get_organization(org_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{org_id}")
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
oid = uuid.UUID(org_id)
|
oid = uuid.UUID(org_id)
|
||||||
@@ -98,7 +99,7 @@ def update_organization(org_id: str, payload: OrganizationUpdate):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{org_id}", status_code=204)
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
oid = uuid.UUID(org_id)
|
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 pydantic import BaseModel
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import uuid
|
import uuid
|
||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from bd.tables.poll import Poll
|
from bd.tables.poll import Poll
|
||||||
from bd.tables.question import Question
|
from bd.tables.question import Question
|
||||||
from bd.tables.choice import Choice
|
from bd.tables.choice import Choice
|
||||||
|
from route.auth_utils import get_current_user
|
||||||
|
|
||||||
router = APIRouter(tags=["polls"], prefix="/polls")
|
router = APIRouter(tags=["polls"], prefix="/polls")
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ def get_session():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/", status_code=201)
|
@router.post("/", status_code=201)
|
||||||
def create_poll(payload: PollIn):
|
def create_poll(payload: PollIn, _: object = Depends(get_current_user)):
|
||||||
Session = get_session()
|
Session = get_session()
|
||||||
with Session() as session:
|
with Session() as session:
|
||||||
poll = Poll(title=payload.title, description=payload.description)
|
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 pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from bd.tables.question import Question
|
from bd.tables.question import Question
|
||||||
from bd.tables.poll import Poll
|
from bd.tables.poll import Poll
|
||||||
|
from route.auth_utils import get_current_user
|
||||||
|
|
||||||
router = APIRouter(tags=["questions"], prefix="/questions")
|
router = APIRouter(tags=["questions"], prefix="/questions")
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ def get_session():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/", status_code=201)
|
@router.post("/", status_code=201)
|
||||||
def create_question(payload: QuestionCreate):
|
def create_question(payload: QuestionCreate, _: object = Depends(get_current_user)):
|
||||||
Session = get_session()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
pid = uuid.UUID(payload.poll_id)
|
pid = uuid.UUID(payload.poll_id)
|
||||||
@@ -51,7 +52,7 @@ def create_question(payload: QuestionCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{question_id}")
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
qid = uuid.UUID(question_id)
|
qid = uuid.UUID(question_id)
|
||||||
@@ -74,7 +75,7 @@ def update_question(question_id: str, payload: QuestionUpdate):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{question_id}", status_code=204)
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
qid = uuid.UUID(question_id)
|
qid = uuid.UUID(question_id)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -8,18 +8,36 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from bd.tables.users import User
|
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")
|
router = APIRouter(tags=["users"], prefix="/users")
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
first_name: str
|
first_name: str
|
||||||
last_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):
|
class UserUpdate(BaseModel):
|
||||||
first_name: Optional[str]
|
first_name: Optional[str] = None
|
||||||
last_name: Optional[str]
|
last_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
def get_session():
|
||||||
@@ -28,24 +46,34 @@ def get_session():
|
|||||||
return sessionmaker(bind=engine, future=True)
|
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):
|
def create_user(payload: UserCreate):
|
||||||
Session = get_session()
|
Session = get_session()
|
||||||
with Session() as 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.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
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}")
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
uid = uuid.UUID(user_id)
|
uid = uuid.UUID(user_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=400, detail="Invalid UUID")
|
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:
|
with Session() as session:
|
||||||
user = session.get(User, uid)
|
user = session.get(User, uid)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -61,12 +89,14 @@ def update_user(user_id: str, payload: UserUpdate):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", status_code=204)
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
uid = uuid.UUID(user_id)
|
uid = uuid.UUID(user_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=400, detail="Invalid UUID")
|
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:
|
with Session() as session:
|
||||||
user = session.get(User, uid)
|
user = session.get(User, uid)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -76,16 +106,13 @@ def delete_user(user_id: str):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/me")
|
||||||
def list_users():
|
def get_me(current_user: User = Depends(get_current_user)):
|
||||||
Session = get_session()
|
return {"id": str(current_user.id), "username": current_user.username, "first_name": current_user.first_name, "last_name": current_user.last_name}
|
||||||
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("/{user_id}")
|
@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()
|
Session = get_session()
|
||||||
try:
|
try:
|
||||||
uid = uuid.UUID(user_id)
|
uid = uuid.UUID(user_id)
|
||||||
|
|||||||
68
uv.lock
generated
68
uv.lock
generated
@@ -39,9 +39,12 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "flet" },
|
{ name = "flet" },
|
||||||
|
{ name = "jose" },
|
||||||
|
{ name = "passlib" },
|
||||||
{ name = "psycopg" },
|
{ name = "psycopg" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "python-jose" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
@@ -60,11 +63,14 @@ requires-dist = [
|
|||||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
|
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.128.0" },
|
{ name = "fastapi", specifier = ">=0.128.0" },
|
||||||
{ name = "flet", specifier = ">=0.82.2" },
|
{ 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 = "psycopg", specifier = ">=3.3.3" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||||
|
{ name = "python-jose", specifier = ">=3.5.0" },
|
||||||
{ name = "redis", specifier = ">=7.1.0" },
|
{ name = "redis", specifier = ">=7.1.0" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.47" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.128.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "msgpack"
|
name = "msgpack"
|
||||||
version = "1.1.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pathspec"
|
name = "pathspec"
|
||||||
version = "1.0.4"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pytokens"
|
name = "pytokens"
|
||||||
version = "0.4.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.14.14"
|
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):
|
class CastomTextField_input(ft.TextField):
|
||||||
def __init__(self):
|
def __init__(self, on_change, on_submit, label="Ваше имя", hint_text="Введите текст...", prefix_icon=None, suffix_icon=None):
|
||||||
super().__init__(
|
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):
|
class CastomButton(ft.Button):
|
||||||
|
|||||||
50
web/info.py
50
web/info.py
@@ -1,7 +1,49 @@
|
|||||||
|
import os
|
||||||
|
import httpx
|
||||||
import flet as ft
|
import flet as ft
|
||||||
|
|
||||||
class user():
|
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||||
def __init__(self, page: ft.Page):
|
_TOKEN_KEY = "auth_token"
|
||||||
pass
|
|
||||||
|
|
||||||
#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 != ""
|
||||||
|
|||||||
29
web/main.py
29
web/main.py
@@ -1,19 +1,36 @@
|
|||||||
import flet as ft
|
import flet as ft
|
||||||
from view import ViewsHendler
|
from view import ViewsHendler
|
||||||
|
import designer as ds
|
||||||
|
from info import AuthService
|
||||||
|
|
||||||
async def main(page: ft.Page):
|
async def main(page: ft.Page):
|
||||||
page.title = "COPP"
|
page.title = "COPP"
|
||||||
|
page.bgcolor = ds.clolors.background
|
||||||
|
|
||||||
|
auth = AuthService(page)
|
||||||
|
_PUBLIC_ROUTES = {"/login"}
|
||||||
|
|
||||||
def route_change(e: ft.RouteChangeEvent):
|
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()
|
page.views.clear()
|
||||||
|
view = ViewsHendler(page).get(route)
|
||||||
view = ViewsHendler(page).get(page.route)
|
if view:
|
||||||
if view: # проверяем, что view получен
|
|
||||||
page.views.append(view)
|
page.views.append(view)
|
||||||
|
else:
|
||||||
|
await page.push_route("/main")
|
||||||
page.update()
|
page.update()
|
||||||
|
|
||||||
page.on_route_change = route_change
|
page.on_route_change = route_change
|
||||||
|
|
||||||
|
|
||||||
await page.push_route("/main")
|
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
|
import flet as ft
|
||||||
from views import main_view
|
from views import main_view, login_view
|
||||||
|
|
||||||
def ViewsHendler(page: ft.Page):
|
def ViewsHendler(page: ft.Page):
|
||||||
return {
|
return {
|
||||||
"/main": main_view.MainView(),
|
"/main": main_view.MainView(),
|
||||||
|
"/login": login_view.LoginView(page),
|
||||||
"/about": ft.View(route="/about", controls=[ft.Text("О нас",color="#008000", size=20)]),
|
"/about": ft.View(route="/about", controls=[ft.Text("О нас",color="#008000", size=20)]),
|
||||||
"/contact": ft.View(route="/contact", controls=[ft.Text("Контакты",color="#FF0000", 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