Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ WEATHER_LATITUDE=55.7558
WEATHER_LONGITUDE=37.6173
BOT_TOKEN=
CHAT_ID=
JWT_EXPIRE_MINUTES=1440
JWT_SECRET=
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ api/v1 (эндпоинты, валидация) → services (бизнес-ло

Все эндпоинты — с префиксом `/api/v1`. Документация OpenAPI: `/docs`.

### Авторизация

Все эндпоинты, кроме `/auth/register`, `/auth/login` и `/health`, требуют заголовок `Authorization: Bearer <токен>`, без него отвечают `401`. Токен — JWT, его выдают регистрация и вход; живёт `JWT_EXPIRE_MINUTES` минут (по умолчанию сутки). Подписывается ключом `JWT_SECRET` из `.env`: сгенерировать — `python -c "import secrets; print(secrets.token_urlsafe(32))"`. Если ключ не задан, backend создаёт временный, и после его перезапуска войти придётся заново. Пароли хранятся только как bcrypt-хэш.

| Метод | Эндпоинт | Описание | Ожидаемый latency |
| ----- | ---------------- | ------------------------------------------------------------- | ----------------- |
| POST | `/auth/register` | Регистрация по email и паролю (от 8 символов), сразу выдаёт токен; `409`, если email занят | < 500 мс |
| POST | `/auth/login` | Вход: токен; `401` при неверном email или пароле | < 500 мс |
| GET | `/auth/me` | Текущий пользователь | < 100 мс |

### Оборудование

| Метод | Эндпоинт | Описание | Ожидаемый latency |
Expand Down Expand Up @@ -228,9 +238,16 @@ erDiagram
varchar severity "info warning critical"
text message
}

users {
int id PK
text email "уникальный, в нижнем регистре"
text password_hash "bcrypt"
timestamp created_at
}
```

Ограничения и индексы: `UNIQUE (dataset, unit_number)` в `equipment`; `UNIQUE (equipment_id, cycle)` в `sensor_readings`, `physics_predictions`, `ml_predictions`; индекс `alerts (equipment_id, triggered_at DESC)`. Показания хранятся широкой таблицей: набор из 14 датчиков фиксирован, так запросы истории не требуют `PIVOT`. Для других наборов (FD002–FD004) добавляются колонки миграцией; полный список датчиков и признак `is_used` лежат в `sensor_catalog`.
Ограничения и индексы: `UNIQUE (dataset, unit_number)` в `equipment`; `UNIQUE (equipment_id, cycle)` в `sensor_readings`, `physics_predictions`, `ml_predictions`; индекс `alerts (equipment_id, triggered_at DESC)`; `UNIQUE (email)` в `users`. Показания хранятся широкой таблицей: набор из 14 датчиков фиксирован, так запросы истории не требуют `PIVOT`. Для других наборов (FD002–FD004) добавляются колонки миграцией; полный список датчиков и признак `is_used` лежат в `sensor_catalog`.

При записи (40 записей/сек) все вставки идут в разные таблицы от фоновых процессов. 40 INSERT/сек — незначительная нагрузка для PostgreSQL.
При чтении (~10-20 RPS) PostgreSQL будет находить строки за O(log n). Проблем не возникнет.
Expand Down
38 changes: 38 additions & 0 deletions backend/alembic/versions/1395cb8114bd_add_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""add users

Revision ID: 1395cb8114bd
Revises: e74d5734bc65
Create Date: 2026-11-10 23:10:55.971571

"""
from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = '1395cb8114bd'
down_revision: str | Sequence[str] | None = 'e74d5734bc65'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.Text(), nullable=False),
sa.Column('password_hash', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
# ### end Alembic commands ###
53 changes: 53 additions & 0 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.core.db import DB
from app.core.security import create_token, user_id_from_token
from app.schemas.auth import AuthOut, LoginIn, RegisterIn, UserOut
from app.services import auth_service

router = APIRouter(prefix="/auth", tags=["Авторизация"])

bearer = HTTPBearer(auto_error=False, description="Токен из /auth/login или /auth/register")

UNAUTHORIZED = {401: {"description": "Нет токена, токен неверен или просрочен"}}


def _unauthorized(detail: str) -> HTTPException:
return HTTPException(status.HTTP_401_UNAUTHORIZED, detail, headers={"WWW-Authenticate": "Bearer"})


def get_current_user(db: DB, credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> dict:
user_id = user_id_from_token(credentials.credentials) if credentials else None
user = auth_service.get_user(db, user_id) if user_id is not None else None
if user is None:
raise _unauthorized("Требуется вход в систему")
return user


def _session(user: dict) -> dict:
return {"access_token": create_token(user["id"]), "token_type": "bearer", "user": user}


@router.post("/register", response_model=AuthOut, status_code=status.HTTP_201_CREATED,
responses={409: {"description": "Email уже зарегистрирован"}}, summary="Регистрация")
def register(body: RegisterIn, db: DB):
user = auth_service.create_user(db, body.email, body.password)
if user is None:
raise HTTPException(status.HTTP_409_CONFLICT, "Пользователь с таким email уже зарегистрирован")
return _session(user)


@router.post("/login", response_model=AuthOut, responses=UNAUTHORIZED, summary="Вход по email и паролю")
def login(body: LoginIn, db: DB):
user = auth_service.authenticate(db, body.email, body.password)
if user is None:
raise _unauthorized("Неверный email или пароль")
return _session(user)


@router.get("/me", response_model=UserOut, responses=UNAUTHORIZED, summary="Текущий пользователь")
def me(user: Annotated[dict, Depends(get_current_user)]):
return user
3 changes: 3 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@
RUL_WARNING = int(os.getenv("RUL_WARNING"))
RUL_CRITICAL = int(os.getenv("RUL_CRITICAL"))
RUL_CLIP = int(os.getenv("RUL_CLIP"))

JWT_SECRET = os.getenv("JWT_SECRET", "")
JWT_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES", "1440"))
40 changes: 40 additions & 0 deletions backend/app/core/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import logging
import secrets
from datetime import UTC, datetime, timedelta

import bcrypt
import jwt

from app.core.config import JWT_EXPIRE_MINUTES, JWT_SECRET

logger = logging.getLogger("security")

ALGORITHM = "HS256"

if JWT_SECRET:
_secret = JWT_SECRET
else:
_secret = secrets.token_urlsafe(32)
logger.warning("JWT_SECRET не задан: сгенерирован временный ключ, после перезапуска backend войти придётся заново")


def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()


def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode(), password_hash.encode())


def create_token(user_id: int) -> str:
expires = datetime.now(UTC) + timedelta(minutes=JWT_EXPIRE_MINUTES)
return jwt.encode({"sub": str(user_id), "exp": expires}, _secret, algorithm=ALGORITHM)


def user_id_from_token(token: str) -> int | None:
"""id пользователя из токена; None, если токен неверен или просрочен."""
try:
payload = jwt.decode(token, _secret, algorithms=[ALGORITHM], options={"require": ["exp", "sub"]})
return int(payload["sub"])
except (jwt.InvalidTokenError, ValueError):
return None
4 changes: 3 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .physics_predictions import PhysicsPrediction
from .sensor_catalog import SensorCatalog
from .sensor_readings import SensorReading
from .users import User

__all__ = [
"Alert",
Expand All @@ -12,4 +13,5 @@
"PhysicsPrediction",
"SensorCatalog",
"SensorReading",
]
"User",
]
12 changes: 12 additions & 0 deletions backend/app/models/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from sqlalchemy import Column, DateTime, Integer, Text, func

from app.core.db import Base


class User(Base):
__tablename__ = "users"

id = Column(Integer, primary_key=True)
email = Column(Text, nullable=False, unique=True)
password_hash = Column(Text, nullable=False)
created_at = Column(DateTime, nullable=False, server_default=func.now())
35 changes: 35 additions & 0 deletions backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from pydantic import BaseModel, Field, field_validator

PASSWORD_MAX_BYTES = 72 # bcrypt не принимает пароли длиннее


class LoginIn(BaseModel):
email: str = Field(max_length=254, pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$", examples=["operator@example.com"])
password: str = Field(min_length=1)

@field_validator("email", mode="before")
@classmethod
def normalize_email(cls, value):
return value.strip().lower() if isinstance(value, str) else value

@field_validator("password")
@classmethod
def fits_bcrypt(cls, value: str) -> str:
if len(value.encode()) > PASSWORD_MAX_BYTES:
raise ValueError(f"пароль длиннее {PASSWORD_MAX_BYTES} байт")
return value


class RegisterIn(LoginIn):
password: str = Field(min_length=8, description="От 8 символов, не длиннее 72 байт в UTF-8")


class UserOut(BaseModel):
id: int
email: str


class AuthOut(BaseModel):
access_token: str = Field(description="JWT для заголовка Authorization: Bearer")
token_type: str = "bearer"
user: UserOut
31 changes: 31 additions & 0 deletions backend/app/services/auth_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from sqlalchemy import Connection, select
from sqlalchemy.dialects.postgresql import insert

from app.core.security import hash_password, verify_password
from app.models.users import User


def create_user(db: Connection, email: str, password: str) -> dict | None:
"""Новый пользователь; None, если email уже занят."""
query = (
insert(User)
.values(email=email, password_hash=hash_password(password))
.on_conflict_do_nothing(index_elements=[User.email])
.returning(User.id, User.email)
)
row = db.execute(query).mappings().first()
db.commit()
return dict(row) if row else None


def authenticate(db: Connection, email: str, password: str) -> dict | None:
query = select(User.id, User.email, User.password_hash).where(User.email == email)
row = db.execute(query).mappings().first()
if row is None or not verify_password(password, row["password_hash"]):
return None
return {"id": row["id"], "email": row["email"]}


def get_user(db: Connection, user_id: int) -> dict | None:
row = db.execute(select(User.id, User.email).where(User.id == user_id)).mappings().first()
return dict(row) if row else None
15 changes: 10 additions & 5 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from app.api import alerts, equipment, predict
from app.api import alerts, auth, equipment, predict
from app.api.auth import UNAUTHORIZED, get_current_user
from app.core.config import CORS_ORIGINS
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(title="OFF-Tech: оценка состояния оборудования", version="0.1.0")

app.add_middleware(CORSMiddleware, allow_origins=CORS_ORIGINS,
allow_methods=["*"], allow_headers=["*"])

app.include_router(equipment.router)
app.include_router(alerts.router)
app.include_router(predict.router)
# Данные — только с токеном; /auth/register и /auth/login открыты.
PROTECTED = [Depends(get_current_user)]

app.include_router(auth.router)
app.include_router(equipment.router, dependencies=PROTECTED, responses=UNAUTHORIZED)
app.include_router(alerts.router, dependencies=PROTECTED, responses=UNAUTHORIZED)
app.include_router(predict.router, dependencies=PROTECTED, responses=UNAUTHORIZED)
4 changes: 3 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ httpx==0.27.2
alembic==1.20.0
psycopg[binary]==3.3.6
alembic==1.20.0
python-dotenv==1.0.1
python-dotenv==1.0.1
PyJWT==2.15.1
bcrypt==5.0.0
19 changes: 17 additions & 2 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import secrets
from datetime import date

import pytest
Expand All @@ -11,8 +12,9 @@
os.environ["RUL_WARNING"] = "75"
os.environ["RUL_CRITICAL"] = "30"
os.environ["RUL_CLIP"] = "125"
os.environ["JWT_SECRET"] = secrets.token_urlsafe(32)

TABLES = "alerts, ml_predictions, physics_predictions, sensor_readings, equipment"
TABLES = "alerts, ml_predictions, physics_predictions, sensor_readings, equipment, users"


@pytest.fixture
Expand All @@ -26,12 +28,25 @@ def db():


@pytest.fixture
def client(db):
def anon_client(db):
from main import app

return TestClient(app)


@pytest.fixture
def client(db):
"""Клиент вошедшего пользователя: все эндпоинты, кроме /auth, требуют токен."""
from app.core.security import create_token
from app.models import User
from main import app

statement = insert(User).values(email="operator@example.com", password_hash="-").returning(User.id)
user_id = db.execute(statement).scalar_one()
db.commit()
return TestClient(app, headers={"Authorization": f"Bearer {create_token(user_id)}"})


@pytest.fixture
def add_equipment(db):
from app.models import Equipment
Expand Down
Loading
Loading