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 ( <>
OFF-Tech - + {session && ( + <> + +
+ {session.email} + +
+ + )}
- } /> - } /> - } /> - Страница не найдена

} /> + } /> + } /> + }> + } /> + } /> + } /> + Страница не найдена

} /> +
); } + +function RequireAuth() { + const session = useSession(); + const location = useLocation(); + + if (!session) return ; + return ; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b1cda21..eb8f3ae 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,3 +1,5 @@ +import { getToken, setSession } from "./session"; + export type Status = "normal" | "warning" | "critical"; export interface Equipment { @@ -56,21 +58,53 @@ export interface Alert { message: string; } +export interface User { + id: number; + email: string; +} + +export interface AuthResponse { + access_token: string; + token_type: string; + user: User; +} + +export class ApiError extends Error { + status: number; + detail: string; + + constructor(status: number, detail: string) { + super(`${status}: ${detail}`); + this.status = status; + this.detail = detail; + } +} + const BASE = "/api"; async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(BASE + path, init); + const token = getToken(); + const headers = new Headers(init?.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + const res = await fetch(BASE + path, { ...init, headers }); if (!res.ok) { + // Токен просрочен или пользователь удалён: выходим, RequireAuth откроет страницу входа. + if (res.status === 401 && token && token === getToken()) setSession(null); let detail = res.statusText; try { const body = await res.json(); if (typeof body.detail === "string") detail = body.detail; + else if (Array.isArray(body.detail)) detail = body.detail.map((d: { msg: string }) => d.msg).join("; "); } catch {} - throw new Error(`${res.status}: ${detail}`); + throw new ApiError(res.status, detail); } return res.json() as Promise; } +function postJson(body: unknown): RequestInit { + return { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; +} + export const api = { listEquipment: () => request("/equipment"), getEquipment: (id: number) => request(`/equipment/${id}`), @@ -79,10 +113,8 @@ export const api = { getPhysics: (id: number, limit = 500) => request(`/equipment/${id}/physics?limit=${limit}`), listAlerts: (limit = 100) => request(`/alerts?limit=${limit}`), alertsByEquipment: (id: number, limit = 50) => request(`/alerts/${id}?limit=${limit}`), - predict: (id: number) => - request("/predict", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ equipment_id: id }), - }), + predict: (id: number) => request("/predict", postJson({ equipment_id: id })), + login: (email: string, password: string) => request("/auth/login", postJson({ email, password })), + register: (email: string, password: string) => + request("/auth/register", postJson({ email, password })), }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 843f5c4..0df2f77 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -237,3 +237,53 @@ h2 { color: #2563eb; font-weight: 500; } + +.user { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + color: #d1d5db; + font-size: 14px; +} +.user-email { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auth { + max-width: 380px; + margin: 48px auto; + padding: 24px; +} +.auth form { + display: flex; + flex-direction: column; + gap: 14px; + margin: 20px 0 16px; +} +.auth label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 14px; + font-weight: 500; +} +.auth input { + padding: 8px 10px; + border: 1px solid #d1d5db; + border-radius: 6px; + font: inherit; +} +.auth input:focus { + outline: 2px solid #93c5fd; + border-color: #2563eb; +} +.auth .error { + margin: 0; +} +.auth .muted { + text-align: center; +} diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx new file mode 100644 index 0000000..fc51e61 --- /dev/null +++ b/frontend/src/pages/Auth.tsx @@ -0,0 +1,97 @@ +import { type FormEvent, useState } from "react"; +import { Link, Navigate, useLocation } from "react-router-dom"; + +import { ApiError, api } from "../api"; +import { setSession, useSession } from "../session"; + +const PASSWORD_MIN = 8; +const PASSWORD_MAX_BYTES = 72; + +const MODES = { + login: { + title: "Вход", + submit: "Войти", + send: api.login, + other: { question: "Нет аккаунта?", link: "Зарегистрироваться", to: "/register" }, + }, + register: { + title: "Регистрация", + submit: "Зарегистрироваться", + send: api.register, + other: { question: "Уже есть аккаунт?", link: "Войти", to: "/login" }, + }, +}; + +export function AuthPage({ mode }: { mode: keyof typeof MODES }) { + const session = useSession(); + const location = useLocation(); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const page = MODES[mode]; + const register = mode === "register"; + + if (session) { + const from = (location.state as { from?: string } | null)?.from ?? "/"; + return ; + } + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + const form = new FormData(e.currentTarget); + const email = String(form.get("email")); + const password = String(form.get("password")); + if (register && password !== form.get("confirm")) return setError("Пароли не совпадают"); + if (register && new TextEncoder().encode(password).length > PASSWORD_MAX_BYTES) { + return setError(`Пароль слишком длинный: не больше ${PASSWORD_MAX_BYTES} байт`); + } + + setBusy(true); + setError(null); + try { + const res = await page.send(email, password); + setSession({ token: res.access_token, email: res.user.email }); + } catch (err) { + setError(err instanceof ApiError ? err.detail : "Нет связи с сервером"); + setBusy(false); + } + } + + return ( +
+

{page.title}

+
+ + + {register && ( + + )} + {error &&

{error}

} + +
+

+ {page.other.question}{" "} + + {page.other.link} + +

+
+ ); +} diff --git a/frontend/src/session.ts b/frontend/src/session.ts new file mode 100644 index 0000000..bd7fa76 --- /dev/null +++ b/frontend/src/session.ts @@ -0,0 +1,56 @@ +import { useSyncExternalStore } from "react"; + +export interface Session { + token: string; + email: string; +} + +const KEY = "offtech.session"; +const listeners = new Set<() => void>(); + +function read(): Session | null { + try { + const value = JSON.parse(localStorage.getItem(KEY) ?? "null"); + return typeof value?.token === "string" && typeof value?.email === "string" ? value : null; + } catch { + return null; + } +} + +let session = read(); + +function notify(): void { + listeners.forEach((l) => l()); +} + +export function getToken(): string | null { + return session?.token ?? null; +} + +export function setSession(next: Session | null): void { + session = next; + try { + if (next) localStorage.setItem(KEY, JSON.stringify(next)); + else localStorage.removeItem(KEY); + } catch { + } + notify(); +} + +// Вход и выход в соседней вкладке. +window.addEventListener("storage", (e) => { + if (e.key !== KEY && e.key !== null) return; + session = read(); + notify(); +}); + +export function useSession(): Session | null { + return useSyncExternalStore( + (cb) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + () => session, + () => null, + ); +} diff --git a/scripts/api_smoke.py b/scripts/api_smoke.py index 20f3a2e..5f029b2 100644 --- a/scripts/api_smoke.py +++ b/scripts/api_smoke.py @@ -1,5 +1,6 @@ import json import re +import secrets import sys import time import urllib.error @@ -17,11 +18,20 @@ ("GET", "/alerts"), ("GET", "/alerts/{id}"), ("POST", "/predict"), + ("POST", "/auth/register"), + ("POST", "/auth/login"), + ("GET", "/auth/me"), ] -def call(method, path): - req = urllib.request.Request(BASE + path, method=method, headers={"Accept": "application/json"}) +def call(method, path, body=None, token=None): + headers = {"Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(BASE + path, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=15) as r: return r.status, json.loads(r.read().decode() or "null") @@ -65,8 +75,20 @@ def main(): fail("в API нет эндпоинтов из контракта README:\n " + "\n ".join(missing)) print(f"OK контракт README: {len(CONTRACT)} эндпоинтов найдено (префикс {prefix or '—'!r})") + code, _ = call("GET", prefix + "/equipment") + if code != 401: + fail(f"без токена GET {prefix}/equipment: ожидали 401, получили {code}") + print("OK без токена -> 401") + + credentials = {"email": f"smoke-{int(time.time())}@example.com", "password": secrets.token_urlsafe(16)} + code, session = call("POST", prefix + "/auth/register", credentials) + if code != 201 or not isinstance(session, dict) or not session.get("access_token"): + fail(f"регистрация: ожидали 201 с токеном, получили {code}") + token = session["access_token"] + print(f"OK POST {prefix}/auth/register -> 201, токен получен") + items = wait_for(lambda: (lambda r: r[1] if r[0] == 200 and isinstance(r[1], list) and r[1] else None)( - call("GET", prefix + "/equipment")), "непустого списка оборудования (работают ли симулятор и воркер?)") + call("GET", prefix + "/equipment", token=token)), "непустого списка оборудования (работают ли симулятор и воркер?)") if not isinstance(items[0], dict) or "id" not in items[0]: fail("у элементов /equipment нет поля id") eq_id = items[0]["id"] @@ -76,10 +98,10 @@ def main(): if method != "GET" or path == "/equipment": continue real = prefix + path.replace("{id}", str(eq_id)) - wait_for(lambda real=real: True if call("GET", real)[0] == 200 else None, f"ответа 200 от GET {real}") + wait_for(lambda real=real: True if call("GET", real, token=token)[0] == 200 else None, f"ответа 200 от GET {real}") print(f"OK GET {real}") - code, _ = call("GET", f"{prefix}/equipment/999999999") + code, _ = call("GET", f"{prefix}/equipment/999999999", token=token) if code != 404: fail(f"несуществующее оборудование: ожидали 404, получили {code}") print("OK несуществующее оборудование -> 404")