diff --git a/.env.example b/.env.example
index ecde501..a8da963 100644
--- a/.env.example
+++ b/.env.example
@@ -14,3 +14,5 @@ WEATHER_LATITUDE=55.7558
WEATHER_LONGITUDE=37.6173
BOT_TOKEN=
CHAT_ID=
+JWT_EXPIRE_MINUTES=1440
+JWT_SECRET=
diff --git a/README.md b/README.md
index 2661cab..7bf8ee5 100644
--- a/README.md
+++ b/README.md
@@ -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 |
@@ -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). Проблем не возникнет.
diff --git a/backend/alembic/versions/1395cb8114bd_add_users.py b/backend/alembic/versions/1395cb8114bd_add_users.py
new file mode 100644
index 0000000..ba08f3c
--- /dev/null
+++ b/backend/alembic/versions/1395cb8114bd_add_users.py
@@ -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 ###
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
new file mode 100644
index 0000000..f1b411c
--- /dev/null
+++ b/backend/app/api/auth.py
@@ -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
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 2c4cbd0..739b497 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -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"))
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
new file mode 100644
index 0000000..eef5e28
--- /dev/null
+++ b/backend/app/core/security.py
@@ -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
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 9041430..f153bad 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -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",
@@ -12,4 +13,5 @@
"PhysicsPrediction",
"SensorCatalog",
"SensorReading",
-]
\ No newline at end of file
+ "User",
+]
diff --git a/backend/app/models/users.py b/backend/app/models/users.py
new file mode 100644
index 0000000..dd90531
--- /dev/null
+++ b/backend/app/models/users.py
@@ -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())
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
new file mode 100644
index 0000000..8506d6d
--- /dev/null
+++ b/backend/app/schemas/auth.py
@@ -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
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
new file mode 100644
index 0000000..b826dbf
--- /dev/null
+++ b/backend/app/services/auth_service.py
@@ -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
diff --git a/backend/main.py b/backend/main.py
index b3376de..b687a2a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,6 +1,7 @@
-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")
@@ -8,6 +9,10 @@
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)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 8b88dc1..4388894 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -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
\ No newline at end of file
+python-dotenv==1.0.1
+PyJWT==2.15.1
+bcrypt==5.0.0
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 13285c2..ecdc160 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -1,4 +1,5 @@
import os
+import secrets
from datetime import date
import pytest
@@ -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
@@ -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
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
new file mode 100644
index 0000000..86d7645
--- /dev/null
+++ b/backend/tests/test_auth.py
@@ -0,0 +1,132 @@
+import re
+import secrets
+from datetime import UTC, datetime, timedelta
+
+import bcrypt
+import jwt
+import pytest
+from fastapi.routing import APIRoute
+from sqlalchemy import text
+
+OPEN_PATHS = {"/auth/register", "/auth/login", "/health"}
+CREDENTIALS = {"email": "operator@example.com", "password": "correct-horse"}
+
+
+def protected_routes() -> list[tuple[str, str]]:
+ from main import app
+
+ return sorted(
+ (method, re.sub(r"\{[^}]+\}", "1", route.path))
+ for route in app.routes
+ if isinstance(route, APIRoute) and route.path not in OPEN_PATHS
+ for method in route.methods
+ )
+
+
+def bearer(token: str) -> dict[str, str]:
+ return {"Authorization": f"Bearer {token}"}
+
+
+def test_register_returns_working_token(anon_client):
+ response = anon_client.post("/auth/register", json={**CREDENTIALS, "email": " Operator@Example.COM "})
+
+ assert response.status_code == 201
+ body = response.json()
+ assert body["token_type"] == "bearer"
+ assert body["user"]["email"] == "operator@example.com"
+
+ me = anon_client.get("/auth/me", headers=bearer(body["access_token"]))
+ assert me.status_code == 200
+ assert me.json() == body["user"]
+
+
+def test_password_is_stored_as_bcrypt_hash(anon_client, db):
+ anon_client.post("/auth/register", json=CREDENTIALS)
+
+ stored = db.execute(text("SELECT password_hash FROM users")).scalar_one()
+
+ assert CREDENTIALS["password"] not in stored
+ assert bcrypt.checkpw(CREDENTIALS["password"].encode(), stored.encode())
+
+
+def test_register_same_email_conflicts(anon_client):
+ assert anon_client.post("/auth/register", json=CREDENTIALS).status_code == 201
+
+ response = anon_client.post("/auth/register", json={**CREDENTIALS, "email": "OPERATOR@example.com"})
+
+ assert response.status_code == 409
+
+
+@pytest.mark.parametrize("payload", [
+ {"email": "not-an-email", "password": "correct-horse"},
+ {"email": "operator@example.com", "password": "short"},
+ {"email": "operator@example.com", "password": "я" * 37}, # 74 байта — больше лимита bcrypt
+ {"email": "operator@example.com"},
+])
+def test_register_validates_input(anon_client, payload):
+ assert anon_client.post("/auth/register", json=payload).status_code == 422
+
+
+def test_login_returns_working_token(anon_client):
+ anon_client.post("/auth/register", json=CREDENTIALS)
+
+ response = anon_client.post("/auth/login", json={**CREDENTIALS, "email": "Operator@example.com"})
+
+ assert response.status_code == 200
+ assert anon_client.get("/equipment", headers=bearer(response.json()["access_token"])).status_code == 200
+
+
+@pytest.mark.parametrize("credentials", [
+ {**CREDENTIALS, "password": "wrong-password"},
+ {**CREDENTIALS, "email": "nobody@example.com"},
+])
+def test_login_rejects_wrong_credentials(anon_client, credentials):
+ anon_client.post("/auth/register", json=CREDENTIALS)
+
+ response = anon_client.post("/auth/login", json=credentials)
+
+ assert response.status_code == 401
+ assert response.json()["detail"] == "Неверный email или пароль"
+
+
+@pytest.mark.parametrize(("method", "path"), protected_routes())
+def test_api_requires_token(anon_client, method, path):
+ response = anon_client.request(method, path)
+
+ assert response.status_code == 401
+ assert response.headers["WWW-Authenticate"] == "Bearer"
+
+
+def test_protected_routes_cover_api():
+ routes = protected_routes()
+
+ assert ("GET", "/equipment") in routes
+ assert ("GET", "/auth/me") in routes
+
+
+def test_signed_in_client_reaches_api(client):
+ assert client.get("/equipment").status_code == 200
+ assert client.get("/auth/me").json()["email"] == "operator@example.com"
+
+
+def _token(secret: str, sub: str = "1", minutes: int = 5) -> str:
+ expires = datetime.now(UTC) + timedelta(minutes=minutes)
+ return jwt.encode({"sub": sub, "exp": expires}, secret, algorithm="HS256")
+
+
+def test_rejects_bad_tokens(client):
+ from app.core.config import JWT_SECRET
+
+ user_token = client.headers["Authorization"].removeprefix("Bearer ")
+ user_id = jwt.decode(user_token, JWT_SECRET, algorithms=["HS256"])["sub"]
+ bad = {
+ "garbage": bearer("garbage"),
+ "foreign secret": bearer(_token(secrets.token_urlsafe(32), user_id)),
+ "expired": bearer(_token(JWT_SECRET, user_id, minutes=-1)),
+ "unknown user": bearer(_token(JWT_SECRET, "999")),
+ "basic scheme": {"Authorization": f"Basic {user_token}"},
+ }
+
+ for name, headers in bad.items():
+ response = client.get("/equipment", headers=headers)
+ assert response.status_code == 401, name
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index cb460a0..564e879 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,30 +1,56 @@
-import { NavLink, Route, Routes } from "react-router-dom";
+import { Navigate, NavLink, Outlet, Route, Routes, useLocation } from "react-router-dom";
import { Alerts } from "./pages/Alerts";
+import { AuthPage } from "./pages/Auth";
import { EquipmentCard } from "./pages/EquipmentCard";
import { EquipmentList } from "./pages/EquipmentList";
+import { setSession, useSession } from "./session";
export function App() {
+ const session = useSession();
+
return (
<>
+ {page.other.question}{" "} + + {page.other.link} + +
+