Compare commits
22 commits
fix/tradei
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a916ceea52 | |||
| 1364cd8e7a | |||
|
|
df943ea1c7 | ||
|
|
f915e1ee4d | ||
|
|
2e05a16a14 | ||
|
|
e7a658c7fa | ||
| 4128564341 | |||
|
|
4ac3971061 | ||
| acba900f21 | |||
|
|
e6d68c349e | ||
|
|
712c56f456 | ||
|
|
15d506b7dd | ||
| d3e0aa296c | |||
|
|
4ecc3d689c | ||
|
|
0835266516 | ||
| 876538c57f | |||
| e703d64a7f | |||
|
|
3f1b86f16a | ||
|
|
0c5246913a | ||
| 600717f44c | |||
|
|
db2cde287a | ||
| 0ac2793f29 |
23 changed files with 4103 additions and 66 deletions
156
tradein-mvp/backend/app/api/v1/auth.py
Normal file
156
tradein-mvp/backend/app/api/v1/auth.py
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
"""POST /api/v1/auth/login + /logout — DB-backed session auth (#2552, эпик #2549).
|
||||||
|
|
||||||
|
Переходный механизм, параллельный legacy Caddy trusted-header auth (roles.yaml).
|
||||||
|
См. `app.core.rbac.rbac_guard` (dual-mode resolver) и `app.services.auth_session`
|
||||||
|
(session CRUD). Mounted at `/api/v1/auth`; через Caddy `uri strip_prefix /trade-in`
|
||||||
|
это `/trade-in/api/v1/auth/*` снаружи.
|
||||||
|
|
||||||
|
Security:
|
||||||
|
- Неверные creds (неизвестный username / неактивен / password_hash NULL /
|
||||||
|
неверный пароль) → ОДИНАКОВЫЙ 401 с generic сообщением — не раскрываем,
|
||||||
|
существует ли username (user-enumeration защита).
|
||||||
|
- #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно
|
||||||
|
один раз — для несуществующего username / NULL password_hash сверяем
|
||||||
|
против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один
|
||||||
|
раз на импорте модуля), результат игнорируется. Без этого короткое
|
||||||
|
замыкание (`user is None → сразу 401`) давало наблюдаемую разницу во
|
||||||
|
времени ответа (~1мс без bcrypt vs ~100-300мс с ним) — классический
|
||||||
|
timing-oracle для user-enumeration, даже при одинаковом detail-сообщении.
|
||||||
|
- Rate-limit по (username, IP) — ЖЁСТЧЕ общего `RateLimitMiddleware`
|
||||||
|
(`/api/*`), т.к. login — типичная brute-force поверхность. Использует
|
||||||
|
`SlidingWindowLimiter` (тот же примитив, что и общий rate-limit). Ключ
|
||||||
|
length-prefixed (`len(username):username:ip`) — без этого произвольный
|
||||||
|
username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip)
|
||||||
|
парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель
|
||||||
|
в username недостаточно — паразитная граница возможна с обеих сторон).
|
||||||
|
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload —
|
||||||
|
только username/ip/user_agent/path/method (см. schedule_event ниже).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.password import hash_password, verify_password
|
||||||
|
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||||
|
from app.services.auth_session import create_session, get_user_by_username, revoke_session
|
||||||
|
from app.services.user_events import schedule_event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Отдельный, более узкий бюджет чем общий per-user/per-IP `/api/*` лимит
|
||||||
|
# (см. app.core.ratelimit.SlidingWindowLimiter docstring — designed именно для
|
||||||
|
# такого случая). Ключ = username+IP: не даёт распределённому brute-force по
|
||||||
|
# ОДНОМУ аккаунту с разных IP уйти от лимита целиком (per-IP было бы недостаточно),
|
||||||
|
# и не блокирует ВЕСЬ IP из-за перебора чужих логинов одним же клиентом.
|
||||||
|
_LOGIN_LIMITER = SlidingWindowLimiter(
|
||||||
|
limit=settings.login_rate_limit,
|
||||||
|
window_s=settings.login_rate_limit_window_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля,
|
||||||
|
# сгенерированный ОДИН РАЗ на импорте модуля — используется вместо
|
||||||
|
# password_hash, когда юзер не найден/деактивирован/без пароля, чтобы
|
||||||
|
# `verify_password` (доминирующая по времени операция, ~100-300мс) всегда
|
||||||
|
# отрабатывала полный bcrypt-компар, независимо от того, существует ли аккаунт.
|
||||||
|
_DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||||
|
|
||||||
|
_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль"
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
async def login(
|
||||||
|
body: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> LoginResponse:
|
||||||
|
ip = _client_ip(request)
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
rate_key = f"{len(body.username)}:{body.username}:{ip}"
|
||||||
|
|
||||||
|
retry_after = _LOGIN_LIMITER.check(rate_key)
|
||||||
|
if retry_after is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="слишком много попыток входа, попробуйте позже",
|
||||||
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
|
)
|
||||||
|
|
||||||
|
user = get_user_by_username(db, body.username)
|
||||||
|
hash_to_check = (
|
||||||
|
user["password_hash"]
|
||||||
|
if user is not None and user["password_hash"] is not None
|
||||||
|
else _DUMMY_PASSWORD_HASH
|
||||||
|
)
|
||||||
|
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
|
||||||
|
# держит время ответа одинаковым независимо от существования аккаунта.
|
||||||
|
password_ok = verify_password(body.password, hash_to_check)
|
||||||
|
credentials_ok = user is not None and user["is_active"] and password_ok
|
||||||
|
|
||||||
|
if not credentials_ok:
|
||||||
|
schedule_event(
|
||||||
|
event_type="login_failed",
|
||||||
|
username=body.username,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
path="/api/v1/auth/login",
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
|
||||||
|
|
||||||
|
assert user is not None # narrowed by credentials_ok above
|
||||||
|
token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent)
|
||||||
|
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.session_cookie_name,
|
||||||
|
value=token,
|
||||||
|
max_age=settings.session_ttl_hours * 3600,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_event(
|
||||||
|
event_type="login_success",
|
||||||
|
username=user["username"],
|
||||||
|
ip=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
path="/api/v1/auth/login",
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
return LoginResponse(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
if token:
|
||||||
|
revoke_session(db, token)
|
||||||
|
response.delete_cookie(key=settings.session_cookie_name, path="/")
|
||||||
|
return {"ok": True}
|
||||||
|
|
@ -7,16 +7,26 @@ Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это ст
|
||||||
Caddy basic_auth пропускает `X-Authenticated-User: <username>` через
|
Caddy basic_auth пропускает `X-Authenticated-User: <username>` через
|
||||||
`header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять
|
`header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять
|
||||||
кому что показывать.
|
кому что показывать.
|
||||||
|
|
||||||
|
#2552: session-first. Валидная DB-session cookie (см. app.services.auth_session)
|
||||||
|
отдаёт scope из tradein_users (role/display_name/org/email) БЕЗ похода в
|
||||||
|
roles.yaml. Без cookie (или невалидная/истёкшая) — legacy X-Authenticated-User
|
||||||
|
путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим — существующие тесты держат его
|
||||||
|
бит-в-бит).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.auth import UserScope, get_user_scope
|
from app.core.auth import UserScope, get_user_scope
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.services.auth_session import get_db_role_scope, get_session_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -25,9 +35,40 @@ router = APIRouter()
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(
|
async def me(
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
) -> UserScope:
|
) -> UserScope | dict[str, Any]:
|
||||||
"""Return the current user's RBAC scope (role + allowed/deny paths)."""
|
"""Return the current user's RBAC scope (role + allowed/deny paths).
|
||||||
|
|
||||||
|
Return type is a union (не только `UserScope`) — `UserScope.role` — это
|
||||||
|
`Literal["admin","pilot","analyst","expired"]` (legacy roles.yaml names),
|
||||||
|
а DB-роли (tradein_users.role) — `"admin"/"manager"/"employee"`. FastAPI
|
||||||
|
строит response-схему из return-аннотации; жёсткий `UserScope` завернул бы
|
||||||
|
"employee"/"manager" в ResponseValidationError. Итоговая JSON-форма
|
||||||
|
ОДИНАКОВАЯ (те же 8 ключей) для обеих веток.
|
||||||
|
"""
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
if token:
|
||||||
|
try:
|
||||||
|
session_user = get_session_user(db, token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("me: session lookup failed")
|
||||||
|
session_user = None
|
||||||
|
if session_user is not None:
|
||||||
|
role = session_user["role"]
|
||||||
|
allowed_paths, deny_paths = get_db_role_scope(role)
|
||||||
|
return {
|
||||||
|
"username": session_user["username"],
|
||||||
|
"role": role,
|
||||||
|
"allowed_paths": allowed_paths,
|
||||||
|
"deny_paths": deny_paths,
|
||||||
|
"brand": None,
|
||||||
|
"display_name": session_user["display_name"],
|
||||||
|
"org": session_user["org_name"],
|
||||||
|
"email": session_user["email"],
|
||||||
|
}
|
||||||
|
|
||||||
if not x_authenticated_user:
|
if not x_authenticated_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
|
|
|
||||||
663
tradein-mvp/backend/app/api/v1/team.py
Normal file
663
tradein-mvp/backend/app/api/v1/team.py
Normal file
|
|
@ -0,0 +1,663 @@
|
||||||
|
"""Team-management API — CRUD сотрудников, квоты, история (#2554, эпик #2549).
|
||||||
|
|
||||||
|
Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это
|
||||||
|
`/trade-in/api/v1/team/*` снаружи. `app.services.auth_session.DB_ROLE_PATHS`
|
||||||
|
уже закладывает `/api/v1/team/**` в scope роли `manager` (и `admin` через `/**`)
|
||||||
|
для `rbac_guard` (см. `app.core.rbac`) — этот роутер добавляет ВТОРОЙ,
|
||||||
|
более узкий барьер именно на identity:
|
||||||
|
|
||||||
|
- `current_team_actor` резолвит юзера ТОЛЬКО из session-cookie
|
||||||
|
(`app.services.auth_session.get_session_user`). Legacy
|
||||||
|
`X-Authenticated-User` (Caddy trusted-header, dual-mode) НЕ принимается
|
||||||
|
здесь — team-API новый, не участвует в переходном dual-mode auth. Без
|
||||||
|
валидной cookie — 401, даже если `rbac_guard` пропустил запрос по
|
||||||
|
legacy-заголовку (напр. admin через roles.yaml).
|
||||||
|
- Роль должна быть `admin` или `manager` — иначе 403.
|
||||||
|
|
||||||
|
Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих
|
||||||
|
employee (`tradein_users.manager_id = actor.user_id`). Чужой/несуществующий
|
||||||
|
employee_id → 404 (НЕ 403) — не подтверждаем/не опровергаем существование
|
||||||
|
чужого сотрудника перед manager'ом. См. `_authorize_employee`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Annotated, Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.engine import RowMapping
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.auth import get_role
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.password import hash_password
|
||||||
|
from app.schemas.team import (
|
||||||
|
EmployeeCreateRequest,
|
||||||
|
EmployeeHistoryEntry,
|
||||||
|
EmployeeOut,
|
||||||
|
EmployeeUpdateRequest,
|
||||||
|
QuotaStatusOut,
|
||||||
|
)
|
||||||
|
from app.services import account_quota
|
||||||
|
from app.services.auth_session import get_session_user, revoke_user_sessions
|
||||||
|
from app.services.user_events import schedule_event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TeamActor:
|
||||||
|
"""Резолвленный из session-cookie актёр team-API — admin или manager."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
role: str # "admin" | "manager"
|
||||||
|
|
||||||
|
|
||||||
|
async def current_team_actor(
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> TeamActor:
|
||||||
|
"""Dependency: session-only identity, роль admin|manager, иначе 401/403.
|
||||||
|
|
||||||
|
Намеренно НЕ читает `X-Authenticated-User` — см. модульный docstring.
|
||||||
|
"""
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="valid session required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_user = get_session_user(db, token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("team: session lookup failed")
|
||||||
|
raise HTTPException(status_code=401, detail="valid session required") from None
|
||||||
|
|
||||||
|
if session_user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="valid session required")
|
||||||
|
|
||||||
|
role = session_user["role"]
|
||||||
|
if role not in ("admin", "manager"):
|
||||||
|
raise HTTPException(status_code=403, detail="admin or manager role required")
|
||||||
|
|
||||||
|
return TeamActor(
|
||||||
|
user_id=session_user["user_id"],
|
||||||
|
username=session_user["username"],
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _origin_host_allowed(candidate: str) -> bool:
|
||||||
|
"""True если scheme://netloc *candidate* совпадает с одним из `settings.cors_origins`.
|
||||||
|
|
||||||
|
`cors_origins` уже является источником правды для «какие origin'ы это наш
|
||||||
|
фронт» (см. CORSMiddleware в app/main.py, ENV CORS_ORIGINS) — переиспользуем
|
||||||
|
его вместо нового хардкода."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(candidate)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
return False
|
||||||
|
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
return origin in settings.cors_origins
|
||||||
|
|
||||||
|
|
||||||
|
def _require_same_origin(request: Request) -> None:
|
||||||
|
"""CSRF defense-in-depth (issue #2554 DoD) для state-changing team-роутов
|
||||||
|
(POST/PATCH): `Origin` (или `Referer` как fallback) обязан матчить один из
|
||||||
|
`settings.cors_origins`, иначе 403.
|
||||||
|
|
||||||
|
Оба заголовка отсутствуют → ПРОПУСКАЕМ (не 403). Причина: это единственный
|
||||||
|
надёжный сигнал non-browser клиента в этом стеке — curl-смоуки внутри
|
||||||
|
контейнера (см. `.claude/rules/tradein.md` "Тестировать HTTP только ВНУТРИ
|
||||||
|
контейнера", `docker exec tradein-backend curl ...`) не шлют ни один из этих
|
||||||
|
заголовков, а реальный браузер (fetch/XHR/form) ВСЕГДА прикладывает Origin
|
||||||
|
на unsafe-методах (POST/PATCH) — так что "оба отсутствуют" практически
|
||||||
|
невозможно для настоящего кросс-сайтового CSRF через браузер. Session-cookie
|
||||||
|
уже стоит на `SameSite=Lax` (см. `app.api.v1.auth.login`) — это первый рубеж
|
||||||
|
против CSRF, Origin-check — второй.
|
||||||
|
"""
|
||||||
|
candidate = request.headers.get("origin") or request.headers.get("referer")
|
||||||
|
if candidate is None:
|
||||||
|
return
|
||||||
|
if not _origin_host_allowed(candidate):
|
||||||
|
logger.warning(
|
||||||
|
"team: Origin/Referer mismatch %r on %s — possible CSRF", candidate, request.url.path
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=403, detail="origin not allowed")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_employee_row(db: Session, employee_id: int) -> RowMapping | None:
|
||||||
|
return (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT id, username, display_name, org_name, email, is_active,
|
||||||
|
manager_id, created_at
|
||||||
|
FROM tradein_users
|
||||||
|
WHERE id = :id AND role = 'employee'
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"id": employee_id},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.fetchone()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping:
|
||||||
|
"""404 (НЕ 403) если сотрудник не найден ИЛИ принадлежит другому manager'у.
|
||||||
|
|
||||||
|
Org-изоляция: manager может видеть/менять только `manager_id == actor.user_id`.
|
||||||
|
404 вместо 403 — не палим существование чужого employee_id.
|
||||||
|
"""
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="employee not found")
|
||||||
|
if actor.role == "manager" and row["manager_id"] != actor.user_id:
|
||||||
|
raise HTTPException(status_code=404, detail="employee not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_quota_override(
|
||||||
|
db: Session, username: str, monthly_limit: int, actor_username: str
|
||||||
|
) -> None:
|
||||||
|
"""Upsert персонального лимита. Явная установка monthly_limit — сигнал "хочу
|
||||||
|
numeric-квоту", поэтому ВСЕГДА сбрасывает `unlimited=false` (иначе лимит может
|
||||||
|
молча не применяться — прежний unlimited-грант выигрывал бы у нового limit).
|
||||||
|
`note` — НЕ затирается, если уже задан (`COALESCE`): не перезаписываем
|
||||||
|
человеко-читаемую причину прошлого гранта (напр. "пилот, грант ...") молча
|
||||||
|
сгенерированной строкой; note проставляется только при первом upsert записи.
|
||||||
|
"""
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
|
||||||
|
VALUES (:username, CAST(:monthly_limit AS integer), false, :note)
|
||||||
|
ON CONFLICT (username) DO UPDATE SET
|
||||||
|
monthly_limit = EXCLUDED.monthly_limit,
|
||||||
|
unlimited = false,
|
||||||
|
note = COALESCE(account_quota_overrides.note, EXCLUDED.note),
|
||||||
|
updated_at = now()
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"username": username,
|
||||||
|
"monthly_limit": monthly_limit,
|
||||||
|
"note": f"team-api: set by {actor_username}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
|
||||||
|
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
|
||||||
|
|
||||||
|
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`
|
||||||
|
(follow-up review PR #2563 п.2 — предыдущая версия расходилась: батч ВСЕГДА
|
||||||
|
читал `account_quota_overrides.unlimited`, а `is_unlimited` — ТОЛЬКО для
|
||||||
|
username, присутствующего в roles.yaml):
|
||||||
|
- username НЕ в roles.yaml (`get_role` → KeyError) → unlimited=False ВСЕГДА,
|
||||||
|
`account_quota_overrides.unlimited` даже не проверяется (roles.yaml —
|
||||||
|
источник правды "кто вообще может быть unlimited", override — "у кого
|
||||||
|
именно из известных roles.yaml-юзеров"). Сегодня недостижимо для DB-only
|
||||||
|
сотрудников team-API (`_upsert_quota_override` всегда пишет
|
||||||
|
`unlimited=false`), но станет достижимым при ручном UPDATE
|
||||||
|
`account_quota_overrides` или расширении roles.yaml — расхождение с
|
||||||
|
реальным enforcement (`check_and_raise`/`increment`, тот же `is_unlimited`)
|
||||||
|
было бы честной ложью в списке: "без лимита", который движок всё равно
|
||||||
|
считает.
|
||||||
|
- username в roles.yaml и role == admin → unlimited=True (без похода в БД).
|
||||||
|
- username в roles.yaml, role != admin → unlimited = override.unlimited.
|
||||||
|
limit = override.monthly_limit (читается для ЛЮБОГО username, без gate по
|
||||||
|
roles.yaml — так же ведёт себя `account_quota.user_limit`), иначе глобальный
|
||||||
|
`account_quota.MONTHLY_LIMIT`.
|
||||||
|
"""
|
||||||
|
if not usernames:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
overrides = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT username, monthly_limit, unlimited
|
||||||
|
FROM account_quota_overrides
|
||||||
|
WHERE username = ANY(CAST(:usernames AS text[]))
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"usernames": usernames},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
override_by_username = {r["username"]: r for r in overrides}
|
||||||
|
|
||||||
|
period = account_quota.current_period()
|
||||||
|
usage_rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT username, used
|
||||||
|
FROM account_estimate_usage
|
||||||
|
WHERE username = ANY(CAST(:usernames AS text[])) AND period_month = :period
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"usernames": usernames, "period": period},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
used_by_username = {r["username"]: r["used"] for r in usage_rows}
|
||||||
|
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
for username in usernames:
|
||||||
|
override = override_by_username.get(username)
|
||||||
|
try:
|
||||||
|
role = get_role(username)
|
||||||
|
except KeyError:
|
||||||
|
role = None
|
||||||
|
if role == "admin":
|
||||||
|
unlimited = True
|
||||||
|
elif role is not None:
|
||||||
|
unlimited = bool(override is not None and override["unlimited"])
|
||||||
|
else:
|
||||||
|
# username не в roles.yaml — is_unlimited() короткое замыкание на
|
||||||
|
# False, override НЕ проверяется (см. докстринг выше).
|
||||||
|
unlimited = False
|
||||||
|
limit = (
|
||||||
|
int(override["monthly_limit"])
|
||||||
|
if override is not None and override["monthly_limit"] is not None
|
||||||
|
else account_quota.MONTHLY_LIMIT
|
||||||
|
)
|
||||||
|
used = used_by_username.get(username, 0)
|
||||||
|
if unlimited:
|
||||||
|
result[username] = {
|
||||||
|
"limit": limit,
|
||||||
|
"used": used,
|
||||||
|
"remaining": limit,
|
||||||
|
"unlimited": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
remaining = max(0, limit - max(0, used))
|
||||||
|
result[username] = {
|
||||||
|
"limit": limit,
|
||||||
|
"used": used,
|
||||||
|
"remaining": remaining,
|
||||||
|
"unlimited": False,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
|
||||||
|
return EmployeeOut(
|
||||||
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
display_name=row["display_name"],
|
||||||
|
org_name=row["org_name"],
|
||||||
|
email=row["email"],
|
||||||
|
is_active=row["is_active"],
|
||||||
|
manager_id=row["manager_id"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
quota=QuotaStatusOut(**quota),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /employees
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/employees", response_model=EmployeeOut, status_code=201)
|
||||||
|
async def create_employee(
|
||||||
|
body: EmployeeCreateRequest,
|
||||||
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
||||||
|
) -> EmployeeOut:
|
||||||
|
"""Создать сотрудника. Роль всегда `employee`.
|
||||||
|
|
||||||
|
manager_id: для actor.role == manager — принудительно свой id (любое
|
||||||
|
значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для
|
||||||
|
actor.role == admin — опционально из тела, валидируется что указанный id
|
||||||
|
существует и role='manager' (иначе 422).
|
||||||
|
"""
|
||||||
|
existing = db.execute(
|
||||||
|
text("SELECT id FROM tradein_users WHERE username = :u"),
|
||||||
|
{"u": body.username},
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="username already exists")
|
||||||
|
|
||||||
|
try:
|
||||||
|
password_hash = hash_password(body.password)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e)) from None
|
||||||
|
|
||||||
|
manager_id: int | None
|
||||||
|
if actor.role == "manager":
|
||||||
|
# Инвариант org-изоляции: manager не может создать сотрудника под
|
||||||
|
# чужим manager_id — любое значение из тела игнорируется молча.
|
||||||
|
manager_id = actor.user_id
|
||||||
|
else:
|
||||||
|
manager_id = body.manager_id
|
||||||
|
if manager_id is not None:
|
||||||
|
mgr = db.execute(
|
||||||
|
text("SELECT id FROM tradein_users WHERE id = :id AND role = 'manager'"),
|
||||||
|
{"id": manager_id},
|
||||||
|
).fetchone()
|
||||||
|
if mgr is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="manager_id does not reference an existing manager",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO tradein_users
|
||||||
|
(username, password_hash, role, manager_id, display_name, org_name,
|
||||||
|
email, is_active)
|
||||||
|
VALUES
|
||||||
|
(:username, :password_hash, 'employee', :manager_id, :display_name,
|
||||||
|
:org_name, :email, true)
|
||||||
|
RETURNING id, username, display_name, org_name, email, is_active,
|
||||||
|
manager_id, created_at
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"username": body.username,
|
||||||
|
"password_hash": password_hash,
|
||||||
|
"manager_id": manager_id,
|
||||||
|
"display_name": body.display_name,
|
||||||
|
"org_name": body.org_name,
|
||||||
|
"email": body.email,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.fetchone()
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
# TOCTOU: два конкурентных POST с одинаковым username между pre-check
|
||||||
|
# выше и этим INSERT — UNIQUE-констрейнт на tradein_users.username ловит.
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="username already exists") from None
|
||||||
|
|
||||||
|
assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку
|
||||||
|
|
||||||
|
if body.monthly_limit is not None:
|
||||||
|
_upsert_quota_override(db, body.username, body.monthly_limit, actor.username)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
schedule_event(
|
||||||
|
event_type="employee_created",
|
||||||
|
username=actor.username,
|
||||||
|
payload={
|
||||||
|
"employee_id": row["id"],
|
||||||
|
"employee_username": row["username"],
|
||||||
|
"manager_id": manager_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
quota = account_quota.get_status(db, body.username)
|
||||||
|
return _employee_out(row, quota)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /employees/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
|
||||||
|
async def update_employee(
|
||||||
|
employee_id: int,
|
||||||
|
body: EmployeeUpdateRequest,
|
||||||
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
||||||
|
) -> EmployeeOut:
|
||||||
|
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
|
||||||
|
|
||||||
|
manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404.
|
||||||
|
При is_active=False ИЛИ смене пароля (new_password) — обязательно revoke всех
|
||||||
|
сессий (HIGH, deep-review PR #2563): без этого блокировка/reset не подействуют
|
||||||
|
до истечения TTL текущей сессии сотрудника — хуже того, sliding-refresh
|
||||||
|
(`app.services.auth_session.get_session_user`) продлевает `expires_at` на
|
||||||
|
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
|
||||||
|
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
|
||||||
|
use-case — см. его докстринг.
|
||||||
|
"""
|
||||||
|
row = _fetch_employee_row(db, employee_id)
|
||||||
|
row = _authorize_employee(actor, row)
|
||||||
|
|
||||||
|
new_password_hash: str | None = None
|
||||||
|
if body.new_password is not None:
|
||||||
|
try:
|
||||||
|
new_password_hash = hash_password(body.new_password)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e)) from None
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE tradein_users
|
||||||
|
SET display_name = COALESCE(:display_name, display_name),
|
||||||
|
org_name = COALESCE(:org_name, org_name),
|
||||||
|
email = COALESCE(:email, email),
|
||||||
|
is_active = COALESCE(CAST(:is_active AS boolean), is_active),
|
||||||
|
password_hash = COALESCE(:password_hash, password_hash),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = :id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"display_name": body.display_name,
|
||||||
|
"org_name": body.org_name,
|
||||||
|
"email": body.email,
|
||||||
|
"is_active": body.is_active,
|
||||||
|
"password_hash": new_password_hash,
|
||||||
|
"id": employee_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.monthly_limit is not None:
|
||||||
|
_upsert_quota_override(db, row["username"], body.monthly_limit, actor.username)
|
||||||
|
|
||||||
|
if body.is_active is False or body.new_password is not None:
|
||||||
|
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
|
||||||
|
# коммитит сам (см. app.services.auth_session), это флашит и наш
|
||||||
|
# предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout
|
||||||
|
# невозможен: _fetch_employee_row фильтрует role='employee', actor
|
||||||
|
# (admin|manager) никогда не может патчить сам себя через этот роут.
|
||||||
|
revoke_user_sessions(db, employee_id)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
changed_profile_fields = [
|
||||||
|
f
|
||||||
|
for f, v in (
|
||||||
|
("display_name", body.display_name),
|
||||||
|
("org_name", body.org_name),
|
||||||
|
("email", body.email),
|
||||||
|
)
|
||||||
|
if v is not None
|
||||||
|
]
|
||||||
|
if changed_profile_fields:
|
||||||
|
schedule_event(
|
||||||
|
event_type="employee_updated",
|
||||||
|
username=actor.username,
|
||||||
|
payload={
|
||||||
|
"employee_id": employee_id,
|
||||||
|
"employee_username": row["username"],
|
||||||
|
"fields": changed_profile_fields,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if body.new_password is not None:
|
||||||
|
schedule_event(
|
||||||
|
event_type="employee_password_reset",
|
||||||
|
username=actor.username,
|
||||||
|
payload={"employee_id": employee_id, "employee_username": row["username"]},
|
||||||
|
)
|
||||||
|
if body.is_active is not None:
|
||||||
|
schedule_event(
|
||||||
|
event_type="employee_blocked" if body.is_active is False else "employee_unblocked",
|
||||||
|
username=actor.username,
|
||||||
|
payload={"employee_id": employee_id, "employee_username": row["username"]},
|
||||||
|
)
|
||||||
|
if body.monthly_limit is not None:
|
||||||
|
schedule_event(
|
||||||
|
event_type="quota_changed",
|
||||||
|
username=actor.username,
|
||||||
|
payload={
|
||||||
|
"employee_id": employee_id,
|
||||||
|
"employee_username": row["username"],
|
||||||
|
"monthly_limit": body.monthly_limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_row = _fetch_employee_row(db, employee_id)
|
||||||
|
assert updated_row is not None # только что успешно обновили эту же строку
|
||||||
|
quota = account_quota.get_status(db, updated_row["username"])
|
||||||
|
return _employee_out(updated_row, quota)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /employees
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
|
||||||
|
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
|
||||||
|
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
|
||||||
|
#
|
||||||
|
# ORDER BY created_at DESC, id DESC — тай-брейкер по `id` ОБЯЗАТЕЛЕН (follow-up
|
||||||
|
# review PR #2563 п.1): `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, а bulk-seed
|
||||||
|
# (#2557) вставляет много юзеров одной транзакцией → идентичный timestamp у N строк.
|
||||||
|
# Без тай-брейкера порядок между страницами (LIMIT/OFFSET) на PostgreSQL для
|
||||||
|
# строк-«близнецов» не гарантирован — сотрудники пропадали/дублировались бы при
|
||||||
|
# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный
|
||||||
|
# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и
|
||||||
|
# намерение DESC-сортировки по времени).
|
||||||
|
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
||||||
|
FROM tradein_users
|
||||||
|
WHERE role = 'employee' AND manager_id = :manager_id
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_LIST_EMPLOYEES_ALL_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
||||||
|
FROM tradein_users
|
||||||
|
WHERE role = 'employee'
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employees", response_model=list[EmployeeOut])
|
||||||
|
async def list_employees(
|
||||||
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
manager_id: Annotated[int | None, Query()] = None,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> list[EmployeeOut]:
|
||||||
|
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
|
||||||
|
|
||||||
|
Квота — ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1
|
||||||
|
(Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
|
||||||
|
"""
|
||||||
|
if actor.role == "manager":
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
_LIST_EMPLOYEES_BY_MANAGER_SQL,
|
||||||
|
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
elif manager_id is not None:
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
_LIST_EMPLOYEES_BY_MANAGER_SQL,
|
||||||
|
{"manager_id": manager_id, "limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = (
|
||||||
|
db.execute(_LIST_EMPLOYEES_ALL_SQL, {"limit": limit, "offset": offset}).mappings().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
|
||||||
|
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /employees/{id}/history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employees/{employee_id}/history", response_model=list[EmployeeHistoryEntry])
|
||||||
|
async def employee_history(
|
||||||
|
employee_id: int,
|
||||||
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> list[EmployeeHistoryEntry]:
|
||||||
|
"""История оценок сотрудника (адрес/дата/результат) — из `user_events`,
|
||||||
|
LEFT JOIN `trade_in_estimates` за фактическим результатом.
|
||||||
|
|
||||||
|
Та же org-проверка что и в PATCH: чужой employee_id → 404.
|
||||||
|
"""
|
||||||
|
row = _fetch_employee_row(db, employee_id)
|
||||||
|
row = _authorize_employee(actor, row)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
CAST(ue.estimate_id AS text) AS estimate_id,
|
||||||
|
ue.payload ->> 'address' AS address,
|
||||||
|
ue.payload ->> 'area_m2' AS area_m2,
|
||||||
|
ue.payload ->> 'rooms' AS rooms,
|
||||||
|
te.median_price,
|
||||||
|
te.confidence,
|
||||||
|
te.n_analogs,
|
||||||
|
ue.created_at
|
||||||
|
FROM user_events ue
|
||||||
|
LEFT JOIN trade_in_estimates te ON te.id = ue.estimate_id
|
||||||
|
WHERE ue.username = :username AND ue.event_type = 'estimate_request'
|
||||||
|
ORDER BY ue.created_at DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"username": row["username"], "limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [EmployeeHistoryEntry.model_validate(dict(r)) for r in rows]
|
||||||
|
|
@ -49,6 +49,28 @@ class Settings(BaseSettings):
|
||||||
default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET"
|
default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ────
|
||||||
|
# Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret
|
||||||
|
# (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт
|
||||||
|
# намеренно пустой, чтобы прод-контейнер не падал на старте до того как
|
||||||
|
# секрет проставлен в .env.runtime. ENV: SESSION_SECRET.
|
||||||
|
session_secret: str = Field(default="", validation_alias="SESSION_SECRET")
|
||||||
|
# Имя cookie для DB-based сессии (отдельно от Caddy basic_auth / trusted-header).
|
||||||
|
session_cookie_name: str = Field(
|
||||||
|
default="tradein_session", validation_alias="SESSION_COOKIE_NAME"
|
||||||
|
)
|
||||||
|
# TTL сессии в часах. Дефолт 720ч (30 дней).
|
||||||
|
session_ttl_hours: int = Field(default=720, validation_alias="SESSION_TTL_HOURS")
|
||||||
|
# "dual" — переходный режим (Caddy trusted-header ИЛИ DB-сессия оба валидны);
|
||||||
|
# "db_only" — только DB-сессия (Caddy basic_auth убран). Переключение — #2552+.
|
||||||
|
auth_mode: Literal["dual", "db_only"] = Field(default="dual", validation_alias="AUTH_MODE")
|
||||||
|
# Rate-limit на /login: не более login_rate_limit попыток за
|
||||||
|
# login_rate_limit_window_s секунд на ключ (обычно IP или username).
|
||||||
|
login_rate_limit: int = Field(default=5, validation_alias="LOGIN_RATE_LIMIT")
|
||||||
|
login_rate_limit_window_s: int = Field(
|
||||||
|
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
|
||||||
|
)
|
||||||
|
|
||||||
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
|
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
|
||||||
# backfill_house_coords.py + audit_address_mismatch.py + main backend
|
# backfill_house_coords.py + audit_address_mismatch.py + main backend
|
||||||
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
|
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
|
||||||
|
|
|
||||||
61
tradein-mvp/backend/app/core/password.py
Normal file
61
tradein-mvp/backend/app/core/password.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Bcrypt password hashing для DB-auth (#2550 — foundation, эпик #2549).
|
||||||
|
|
||||||
|
bcrypt тихо обрезает пароли длиннее 72 байт (UTF-8) — это silent-truncation
|
||||||
|
дыра (два разных пароля с общим 72-байтовым префиксом хешируются одинаково).
|
||||||
|
`hash_password` явно ловит это и падает с ValueError вместо тихого поведения.
|
||||||
|
`verify_password` на длинном пароле возвращает False (не raise) — сравнение
|
||||||
|
паролей не должно ронять запрос авторизации.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BCRYPT_MAX_BYTES = 72
|
||||||
|
_BCRYPT_ROUNDS = 12
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
"""Хеширует пароль через bcrypt (rounds=12).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: пустой пароль или пароль длиннее 72 байт в UTF-8
|
||||||
|
(bcrypt тихо обрезает — недопустимо, см. модульный docstring).
|
||||||
|
"""
|
||||||
|
if not plain:
|
||||||
|
raise ValueError("password must not be empty")
|
||||||
|
|
||||||
|
encoded = plain.encode("utf-8")
|
||||||
|
if len(encoded) > _BCRYPT_MAX_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"password too long: {len(encoded)} bytes (bcrypt max {_BCRYPT_MAX_BYTES})"
|
||||||
|
)
|
||||||
|
|
||||||
|
salt = bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
|
||||||
|
hashed = bcrypt.hashpw(encoded, salt)
|
||||||
|
return hashed.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
"""Сверяет пароль с bcrypt-хешем.
|
||||||
|
|
||||||
|
Пустой пароль или пароль длиннее 72 байт в UTF-8 → False (не raise —
|
||||||
|
verify — это false/true проверка на этапе логина, а не валидация ввода).
|
||||||
|
"""
|
||||||
|
if not plain or not hashed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
encoded = plain.encode("utf-8")
|
||||||
|
if len(encoded) > _BCRYPT_MAX_BYTES:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(encoded, hashed.encode("utf-8"))
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
# Malformed hash (напр. не-bcrypt строка в БД) — не должно ронять login.
|
||||||
|
logger.warning("verify_password: malformed hash rejected: %s", e)
|
||||||
|
return False
|
||||||
|
|
@ -7,12 +7,16 @@ manually". The copy drifted: it was missing the #2213
|
||||||
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
|
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
|
||||||
so a regression in that check would NOT have failed CI.
|
so a regression in that check would NOT have failed CI.
|
||||||
|
|
||||||
This module holds the real guard with no DB/lifespan/scheduler side effects
|
This module holds the real guard. Historically it had "no DB/lifespan/scheduler
|
||||||
(only ``app.core.auth`` + ``app.core.config``, both side-effect-free at
|
side effects" beyond ``app.core.auth``/``app.core.config`` (both side-effect-free
|
||||||
import time beyond requiring ``DATABASE_URL`` in the environment for
|
at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request
|
||||||
``Settings()``). ``app/main.py`` and the test apps both import THIS module,
|
DB round trip via ``app.core.db.SessionLocal`` — но ТОЛЬКО когда запрос реально
|
||||||
so tests exercise the exact production code path instead of a copy that can
|
несёт session-cookie (``request.cookies.get(settings.session_cookie_name)``);
|
||||||
silently fall out of sync.
|
без cookie (весь существующий тестовый трафик, legacy Caddy trusted-header
|
||||||
|
запросы) ветка не выполняется — ноль новых DB-побочных эффектов для старых
|
||||||
|
путей. ``app/main.py`` and the test apps both import THIS module, so tests
|
||||||
|
exercise the exact production code path instead of a copy that can silently
|
||||||
|
fall out of sync.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -21,12 +25,15 @@ import logging
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
|
||||||
from app.core.auth import get_role, is_path_allowed
|
from app.core.auth import get_role, is_path_allowed
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.auth_session import get_db_role_scope, get_session_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -40,7 +47,22 @@ logger = logging.getLogger(__name__)
|
||||||
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
|
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
|
||||||
# X-Authenticated-User там не приходит из Caddy.
|
# X-Authenticated-User там не приходит из Caddy.
|
||||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
# #2552: /api/v1/auth/login + /logout — по определению вызываются ДО того, как
|
||||||
|
# клиент аутентифицирован (login) или могут вызываться с уже протухшей/отсутствующей
|
||||||
|
# сессией (logout — должен уметь чистить stale cookie без валидной auth). Свой
|
||||||
|
# rate-limit у /login отдельный (app.api.v1.auth._LOGIN_LIMITER), RateLimitMiddleware
|
||||||
|
# на /api/* всё равно применяется — это ослабляет ТОЛЬКО rbac_guard'овский
|
||||||
|
# auth-required gate, не остальные защиты.
|
||||||
|
_PUBLIC_PATHS = frozenset(
|
||||||
|
{
|
||||||
|
"/health",
|
||||||
|
"/docs",
|
||||||
|
"/redoc",
|
||||||
|
"/openapi.json",
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
"/api/v1/auth/logout",
|
||||||
|
}
|
||||||
|
)
|
||||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||||
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
|
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
|
||||||
# scope-проверки восстанавливаем внешний путь.
|
# scope-проверки восстанавливаем внешний путь.
|
||||||
|
|
@ -51,6 +73,77 @@ _EXTERNAL_PREFIX = "/trade-in"
|
||||||
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
||||||
|
|
||||||
|
|
||||||
|
def _db_glob_match(pattern: str, path: str) -> bool:
|
||||||
|
"""Мини-матчер для фиксированного набора DB-role паттернов
|
||||||
|
(``app.services.auth_session.DB_ROLE_PATHS`` — только формы ``/**`` и
|
||||||
|
``<prefix>/**``, не нужна полная semantics ``app.core.auth._glob_to_regex``
|
||||||
|
— тот модуль private и MIRROR'ится вручную с основным бэкендом, лишний
|
||||||
|
импорт private-символа оттуда увеличивал бы drift-риск)."""
|
||||||
|
if pattern == "/**":
|
||||||
|
return True
|
||||||
|
if pattern.endswith("/**"):
|
||||||
|
prefix = pattern[: -len("/**")]
|
||||||
|
return path == prefix or path.startswith(prefix + "/")
|
||||||
|
return path == pattern
|
||||||
|
|
||||||
|
|
||||||
|
def _db_role_path_allowed(role: str, path: str) -> bool:
|
||||||
|
paths, deny = get_db_role_scope(role)
|
||||||
|
if any(_db_glob_match(p, path) for p in deny):
|
||||||
|
return False
|
||||||
|
return any(_db_glob_match(p, path) for p in paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
||||||
|
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
|
||||||
|
только добавляя при отсутствии, — чтобы ``RateLimitMiddleware``/
|
||||||
|
``RequestAuditMiddleware`` (оба читают сырой заголовок напрямую,
|
||||||
|
#2213/#2550) и downstream route-хендлеры (читающие его через FastAPI
|
||||||
|
``Header()``) видели РЕЗОЛВЛЕННОГО ИЗ СЕССИИ юзера — без правок в каждом
|
||||||
|
из этих мест по отдельности (минимально инвазивный способ).
|
||||||
|
|
||||||
|
#2552 post-review fix (CRITICAL): раньше это была skip-if-present
|
||||||
|
мутация (``if request.headers.get(...): return``) — сессия резолвилась
|
||||||
|
ПЕРВОЙ (см. rbac_guard), но клиент-контролируемый ``X-Authenticated-User``
|
||||||
|
(который Caddy шлёт на КАЖДЫЙ прод-запрос) выигрывал у неё для ВСЕГО
|
||||||
|
downstream-трафика: атакующий с валидной cookie юзера ``alice`` мог
|
||||||
|
подделать заголовок ``X-Authenticated-User: victim`` и получить доступ к
|
||||||
|
данным victim в ~15 роутах, читающих заголовок напрямую
|
||||||
|
(``_assert_estimate_access*``, ``account_quota``, ``/trade-in/history``,
|
||||||
|
``support.py``) — работало в ОБОИХ auth_mode (dual и db_only), т.к. эти
|
||||||
|
хендлеры не знают про rbac_guard'овский ``from_session`` флаг, только про
|
||||||
|
сырой заголовок. Session-identity ДОЛЖНА быть источником истины, если
|
||||||
|
сессия резолвлена — полная перезапись, не skip.
|
||||||
|
|
||||||
|
Механизм: ``request.scope`` — ОДИН и тот же dict-объект, прокинутый по
|
||||||
|
ссылке через весь ASGI call chain (Starlette не копирует scope между
|
||||||
|
слоями middleware). Мутация ``scope["headers"]`` ЗДЕСЬ видна:
|
||||||
|
- downstream call_next() цепочке (ExceptionMiddleware → Router →
|
||||||
|
endpoint) — т.к. rbac_guard мутирует scope ДО вызова call_next();
|
||||||
|
- ``RequestAuditMiddleware`` — он внешний относительно rbac_guard
|
||||||
|
(см. app/main.py: последний ``add_middleware`` оборачивает
|
||||||
|
предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()``
|
||||||
|
отработал весь внутренний стек, включая эту мутацию.
|
||||||
|
|
||||||
|
ASGI header-имена — всегда lowercase bytes (см. ASGI spec), поэтому
|
||||||
|
фильтр по ``b"x-authenticated-user"`` ловит заголовок независимо от
|
||||||
|
регистра, в котором его прислал клиент (Starlette уже нормализует).
|
||||||
|
|
||||||
|
Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно
|
||||||
|
rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот
|
||||||
|
guard успевает отработать) — для ЭТОГО конкретного запроса сессионный
|
||||||
|
юзер лимитируется по IP, а не по username (per-user множитель не
|
||||||
|
применяется). Не регрессия (IP-лимит применялся бы и раньше — до
|
||||||
|
добавления session-auth такие запросы вообще были 401), просто более
|
||||||
|
строгий бюджет специфично для session-cookie-запросов; при необходимости
|
||||||
|
точного per-user квотинга для DB-юзеров — переносить резолв сессии выше
|
||||||
|
RateLimit в app/main.py отдельным issue.
|
||||||
|
"""
|
||||||
|
request.scope["headers"] = [
|
||||||
|
(k, v) for k, v in request.scope.get("headers", []) if k != b"x-authenticated-user"
|
||||||
|
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
|
||||||
|
|
||||||
|
|
||||||
async def rbac_guard(
|
async def rbac_guard(
|
||||||
request: Request,
|
request: Request,
|
||||||
call_next: Callable[[Request], Awaitable[Response]],
|
call_next: Callable[[Request], Awaitable[Response]],
|
||||||
|
|
@ -59,6 +152,37 @@ async def rbac_guard(
|
||||||
if path in _PUBLIC_PATHS:
|
if path in _PUBLIC_PATHS:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
username: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
from_session = False
|
||||||
|
|
||||||
|
# #2552: session-cookie резолвится ПЕРВЫМ. Если cookie нет вообще —
|
||||||
|
# request.cookies.get() возвращает None без единого похода в БД (ноль
|
||||||
|
# side-effects для всего существующего трафика без cookie).
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
if token:
|
||||||
|
session_user: dict[str, Any] | None = None
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
session_user = get_session_user(db, token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("RBAC: session lookup failed for %s", path)
|
||||||
|
if session_user is not None:
|
||||||
|
username = session_user["username"]
|
||||||
|
role = session_user["role"]
|
||||||
|
from_session = True
|
||||||
|
_propagate_authenticated_user(request, username)
|
||||||
|
|
||||||
|
if not from_session:
|
||||||
|
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
|
||||||
|
# отключён, даже если валидный X-Authenticated-User присутствует.
|
||||||
|
if settings.auth_mode != "dual":
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "valid session required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
|
||||||
username = request.headers.get("X-Authenticated-User")
|
username = request.headers.get("X-Authenticated-User")
|
||||||
if not username:
|
if not username:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -94,6 +218,9 @@ async def rbac_guard(
|
||||||
content={"detail": "user not in roles config"},
|
content={"detail": "user not in roles config"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert username is not None
|
||||||
|
assert role is not None
|
||||||
|
|
||||||
if _ADMIN_API_RE.match(path) and role != "admin":
|
if _ADMIN_API_RE.match(path) and role != "admin":
|
||||||
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
|
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -101,15 +228,14 @@ async def rbac_guard(
|
||||||
content={"detail": "admin only"},
|
content={"detail": "admin only"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не
|
# #R2-H3: энфорсим scope (paths/deny) для ВСЕХ non-admin путей, а не
|
||||||
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко-
|
# только /admin/*. Bootstrap-пути (/me, /brand) исключены — иначе revoked/
|
||||||
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search —
|
# scope-narrowed юзер не смог бы получить свою роль вовсе.
|
||||||
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
|
|
||||||
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
|
|
||||||
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
|
|
||||||
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
|
|
||||||
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
||||||
external_path = _EXTERNAL_PREFIX + path
|
external_path = _EXTERNAL_PREFIX + path
|
||||||
|
if from_session:
|
||||||
|
allowed = _db_role_path_allowed(role, external_path)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
allowed = is_path_allowed(role, external_path)
|
allowed = is_path_allowed(role, external_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
admin,
|
admin,
|
||||||
audit,
|
audit,
|
||||||
|
auth,
|
||||||
brand,
|
brand,
|
||||||
buildings,
|
buildings,
|
||||||
geocode,
|
geocode,
|
||||||
|
|
@ -30,6 +31,7 @@ from app.api.v1 import (
|
||||||
me,
|
me,
|
||||||
search,
|
search,
|
||||||
support,
|
support,
|
||||||
|
team,
|
||||||
trade_in,
|
trade_in,
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
@ -106,6 +108,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
|
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# #2552: session_secret зарезервирован на будущее (напр. подписанные токены) —
|
||||||
|
# opaque session-токены (secrets.token_urlsafe, см. app.services.auth_session)
|
||||||
|
# НЕ требуют подписи, их валидность проверяется исключительно наличием строки
|
||||||
|
# в tradein_sessions + expires_at/is_active. Пустой session_secret НЕ должен
|
||||||
|
# ронять старт контейнера (не startup-fail) — только громкий WARNING, чтобы
|
||||||
|
# прод не остался без него незамеченно до момента, когда он реально понадобится.
|
||||||
|
if not settings.session_secret:
|
||||||
|
logger.warning(
|
||||||
|
"SESSION_SECRET пуст — не блокирует старт (opaque session-токены не "
|
||||||
|
"требуют подписи), но задай его в .env.runtime до появления фич, "
|
||||||
|
"которым подпись реально нужна"
|
||||||
|
)
|
||||||
|
|
||||||
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
||||||
# Best-effort: failure does not abort startup, just logs.
|
# Best-effort: failure does not abort startup, just logs.
|
||||||
try:
|
try:
|
||||||
|
|
@ -158,6 +173,7 @@ def health() -> dict[str, str]:
|
||||||
return {"status": "ok", "environment": settings.environment}
|
return {"status": "ok", "environment": settings.environment}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
||||||
|
|
@ -168,3 +184,4 @@ app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-su
|
||||||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||||
|
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
|
||||||
|
|
|
||||||
110
tradein-mvp/backend/app/schemas/team.py
Normal file
110
tradein-mvp/backend/app/schemas/team.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Pydantic-схемы team-management API (#2554, эпик #2549).
|
||||||
|
|
||||||
|
CRUD сотрудников (`tradein_users.role = 'employee'`), квоты, история оценок.
|
||||||
|
Org-изоляция (manager видит/меняет только своих employee) реализована в
|
||||||
|
`app.api.v1.team`, эти схемы — только форма запросов/ответов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
# ASCII-only — не-ASCII username ломает downstream identity-пропагацию
|
||||||
|
# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с
|
||||||
|
# errors="replace"), поэтому валидация формы обязательна на границе API,
|
||||||
|
# а не только на уровне БД.
|
||||||
|
#
|
||||||
|
# `\Z`, НЕ `$` — deep-review seed #2564: в Python `$` матчит перед trailing
|
||||||
|
# newline (`re.match(r'...\$', 'admin\n')` → True), а Postgres `~` в CHECK
|
||||||
|
# tradein_users_username_ascii_ck (миграция 193) — False. С `$` строка
|
||||||
|
# "admin\n" проходила бы Pydantic-валидацию и падала уже в БД → 500 вместо
|
||||||
|
# честного 422. `\Z` — конец строки БЕЗ поблажки на trailing newline, совпадает
|
||||||
|
# с семантикой Postgres `~`.
|
||||||
|
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}\Z")
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaStatusOut(BaseModel):
|
||||||
|
"""Статус месячной квоты оценок — вложен в `EmployeeOut`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
limit: int
|
||||||
|
used: int
|
||||||
|
remaining: int
|
||||||
|
unlimited: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeCreateRequest(BaseModel):
|
||||||
|
"""`POST /employees` — создать сотрудника. Роль всегда `employee` (не в теле)."""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
display_name: str | None = None
|
||||||
|
org_name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
monthly_limit: int | None = Field(default=None, ge=1)
|
||||||
|
# Только для actor.role == admin — опциональная привязка к конкретному manager.
|
||||||
|
# Для actor.role == manager это поле ИГНОРИРУЕТСЯ (принудительно свой id) —
|
||||||
|
# см. app.api.v1.team.create_employee.
|
||||||
|
manager_id: int | None = None
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def _validate_username(cls, v: str) -> str:
|
||||||
|
if not _USERNAME_RE.match(v):
|
||||||
|
raise ValueError(
|
||||||
|
"username must be 3-64 ASCII chars: letters, digits, dot, underscore, hyphen"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeUpdateRequest(BaseModel):
|
||||||
|
"""`PATCH /employees/{id}` — частичное обновление, все поля опциональны."""
|
||||||
|
|
||||||
|
is_active: bool | None = None
|
||||||
|
monthly_limit: int | None = Field(default=None, ge=1)
|
||||||
|
display_name: str | None = None
|
||||||
|
org_name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
new_password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeOut(BaseModel):
|
||||||
|
"""Одна строка в `GET /employees` + ответ `POST`/`PATCH /employees/{id}`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: str | None = None
|
||||||
|
org_name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
is_active: bool
|
||||||
|
manager_id: int | None = None
|
||||||
|
created_at: datetime
|
||||||
|
quota: QuotaStatusOut
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeHistoryEntry(BaseModel):
|
||||||
|
"""Одна строка истории оценок сотрудника — `GET /employees/{id}/history`.
|
||||||
|
|
||||||
|
Источник — `user_events` (event_type='estimate_request', паттерн
|
||||||
|
`app.api.v1.audit.account_drilldown`), LEFT JOIN на `trade_in_estimates`
|
||||||
|
за фактическим результатом (median_price/confidence/n_analogs) — join
|
||||||
|
может не сматчиться (старая запись без estimate_id / оценка insufficient_data),
|
||||||
|
поэтому все result-поля nullable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
estimate_id: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
area_m2: str | None = None
|
||||||
|
rooms: str | None = None
|
||||||
|
median_price: int | None = None
|
||||||
|
confidence: str | None = None
|
||||||
|
n_analogs: int | None = None
|
||||||
|
created_at: datetime
|
||||||
221
tradein-mvp/backend/app/services/auth_session.py
Normal file
221
tradein-mvp/backend/app/services/auth_session.py
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
"""Session-сервис для DB-backed auth (#2552, эпик #2549 — auth-core).
|
||||||
|
|
||||||
|
Схема: `tradein_users` + `tradein_sessions` (migration `192_tradein_users_auth.sql`).
|
||||||
|
Опаковые (`secrets.token_urlsafe`) токены-сессии — не JWT, не подписаны: валидность
|
||||||
|
проверяется исключительно наличием + `expires_at`/`is_active` строкой в БД, поэтому
|
||||||
|
`SESSION_SECRET` НЕ обязателен для работы этого модуля (зарезервирован на будущее,
|
||||||
|
см. `app.core.config.Settings.session_secret` docstring).
|
||||||
|
|
||||||
|
Все функции здесь принимают уже открытую `db: Session` — сами НЕ открывают
|
||||||
|
`SessionLocal()` (вызывающая сторона решает время жизни транзакции: `rbac_guard`
|
||||||
|
и `app.core.db.get_db()`-роуты открывают её по-разному). Это делает модуль
|
||||||
|
тривиально unit-тестируемым без патчинга `SessionLocal` — тесты просто передают
|
||||||
|
fake/real `Session`.
|
||||||
|
|
||||||
|
Ни одна функция не должна ронять вызывающий HTTP-запрос: DB-ошибки логируются
|
||||||
|
через `logger` вызывающей стороной (см. `app.core.rbac.rbac_guard`,
|
||||||
|
`app.api.v1.me`), сам сервис поднимает исключения как есть (это НЕ fire-and-forget
|
||||||
|
аудит-лог вроде `app.services.user_events`, а часть auth-decision — сбой обязан
|
||||||
|
быть виден вызывающему, чтобы тот мог fail-closed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Sliding-window refresh: last_seen_at/expires_at продлеваются НЕ чаще раза в
|
||||||
|
# 5 минут — иначе каждый API-запрос авторизованного юзера бил бы в БД лишним
|
||||||
|
# UPDATE (RBAC гоняет get_session_user на КАЖДЫЙ non-public запрос).
|
||||||
|
_SLIDING_REFRESH_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
_TOKEN_BYTES = 32 # secrets.token_urlsafe(32) — 256 бит энтропии, ~43 символа
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
ip: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Создаёт новую сессию для *user_id* и возвращает opaque-токен.
|
||||||
|
|
||||||
|
`expires_at = now() + settings.session_ttl_hours`. Коммитит сам (self-contained,
|
||||||
|
как `app.services.user_events.record_event`).
|
||||||
|
"""
|
||||||
|
token = secrets.token_urlsafe(_TOKEN_BYTES)
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO tradein_sessions (token, user_id, expires_at, ip_address, user_agent)
|
||||||
|
VALUES (
|
||||||
|
:token, :user_id,
|
||||||
|
now() + make_interval(hours => CAST(:ttl_hours AS integer)),
|
||||||
|
CAST(:ip AS inet), :user_agent
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"token": token,
|
||||||
|
"user_id": user_id,
|
||||||
|
"ttl_hours": settings.session_ttl_hours,
|
||||||
|
"ip": ip,
|
||||||
|
"user_agent": user_agent,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
|
||||||
|
"""Резолвит сессионный токен в данные юзера, или None если сессия
|
||||||
|
невалидна (не найдена / истекла / юзер деактивирован).
|
||||||
|
|
||||||
|
Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут —
|
||||||
|
продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh
|
||||||
|
(напр. read-replica) логируется и НЕ мешает вернуть валидного юзера —
|
||||||
|
это best-effort продление, а не часть решения "валидна ли сессия".
|
||||||
|
"""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT s.user_id, s.expires_at, s.last_seen_at,
|
||||||
|
u.username, u.role, u.display_name, u.org_name, u.email, u.is_active
|
||||||
|
FROM tradein_sessions s
|
||||||
|
JOIN tradein_users u ON u.id = s.user_id
|
||||||
|
WHERE s.token = :token
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"token": token},
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if row.expires_at is None or row.expires_at <= now:
|
||||||
|
return None
|
||||||
|
if not row.is_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE tradein_sessions
|
||||||
|
SET last_seen_at = now(),
|
||||||
|
expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))
|
||||||
|
WHERE token = :token
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"ttl_hours": settings.session_ttl_hours, "token": token},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"auth_session: sliding refresh failed for user_id=%r", row.user_id, exc_info=True
|
||||||
|
)
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": row.user_id,
|
||||||
|
"username": row.username,
|
||||||
|
"role": row.role,
|
||||||
|
"display_name": row.display_name,
|
||||||
|
"org_name": row.org_name,
|
||||||
|
"email": row.email,
|
||||||
|
"is_active": row.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
|
||||||
|
"""Возвращает строку `tradein_users` по username, или None если не найден.
|
||||||
|
|
||||||
|
Используется login-флоу (`app.api.v1.auth.login`) для password-проверки.
|
||||||
|
Отдаёт `password_hash` как есть (может быть NULL — переходный период,
|
||||||
|
см. migration 192 docstring) — вызывающая сторона решает, что с ним делать.
|
||||||
|
"""
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT id, username, password_hash, role, is_active,
|
||||||
|
display_name, org_name, email
|
||||||
|
FROM tradein_users
|
||||||
|
WHERE username = :username
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"username": username},
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": row.id,
|
||||||
|
"username": row.username,
|
||||||
|
"password_hash": row.password_hash,
|
||||||
|
"role": row.role,
|
||||||
|
"is_active": row.is_active,
|
||||||
|
"display_name": row.display_name,
|
||||||
|
"org_name": row.org_name,
|
||||||
|
"email": row.email,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_session(db: Session, token: str) -> None:
|
||||||
|
"""Удаляет одну сессию по токену (logout). No-op если токен не найден."""
|
||||||
|
db.execute(text("DELETE FROM tradein_sessions WHERE token = :token"), {"token": token})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_user_sessions(db: Session, user_id: int) -> None:
|
||||||
|
"""Удаляет ВСЕ сессии юзера (напр. смена пароля / принудительный logout всех
|
||||||
|
устройств — не используется этим PR напрямую, задел для будущих admin-действий)."""
|
||||||
|
db.execute(text("DELETE FROM tradein_sessions WHERE user_id = :user_id"), {"user_id": user_id})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DB-role → RBAC scope (paths/deny) — #2552 dual-mode.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# tradein_users.role ('admin'|'manager'|'employee', CHECK-констрейнт migration 192)
|
||||||
|
# НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь,
|
||||||
|
# который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же
|
||||||
|
# paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml:
|
||||||
|
# employee -> те же права, что legacy pilot (/trade-in/** только).
|
||||||
|
# manager -> employee + задел /api/v1/team/** (роутер появится в #2554).
|
||||||
|
# admin -> полный доступ, как legacy admin.
|
||||||
|
DB_ROLE_PATHS: dict[str, tuple[list[str], list[str]]] = {
|
||||||
|
"employee": (
|
||||||
|
["/trade-in/**", "/trade-in/api/v1/**"],
|
||||||
|
["/admin/**", "/api/v1/admin/**", "/trade-in/api/v1/admin/**"],
|
||||||
|
),
|
||||||
|
"manager": (
|
||||||
|
["/trade-in/**", "/trade-in/api/v1/**", "/api/v1/team/**"],
|
||||||
|
["/admin/**", "/api/v1/admin/**", "/trade-in/api/v1/admin/**"],
|
||||||
|
),
|
||||||
|
"admin": (["/**"], []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_role_scope(role: str) -> tuple[list[str], list[str]]:
|
||||||
|
"""Возвращает (allowed_paths, deny_paths) для DB-роли.
|
||||||
|
|
||||||
|
Неизвестная роль (не должно случиться — CHECK-констрейнт на колонке
|
||||||
|
ограничивает role тремя значениями) -> fail-closed (пустой allow, deny всё).
|
||||||
|
"""
|
||||||
|
return DB_ROLE_PATHS.get(role, ([], ["/**"]))
|
||||||
88
tradein-mvp/backend/data/sql/192_tradein_users_auth.sql
Normal file
88
tradein-mvp/backend/data/sql/192_tradein_users_auth.sql
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
-- Migration 192: tradein_users + tradein_sessions — DB-backed auth (issue #2551, эпик #2549)
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- Trade-in auth сейчас держится на legacy Caddy basic-auth fallback (см. auth/roles.yaml,
|
||||||
|
-- упомянут в 191_account_quota_unlimited_flag.sql как "хардкод username в коде"). Эпик #2549
|
||||||
|
-- переводит auth на DB-backed модель: пользователи + сессии как данные, роли admin/manager/
|
||||||
|
-- employee с иерархией manager -> employee. Эта миграция — только схема (Foundation),
|
||||||
|
-- без seed-данных (seed — отдельная задача #2557) и без Python-кода (backend wiring — отдельно).
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- 1. tradein_users — identity + role + org-иерархия.
|
||||||
|
-- - password_hash NULL допустим: переходный период, когда логин ещё идёт через
|
||||||
|
-- legacy Caddy fallback, а не через password verify в приложении.
|
||||||
|
-- - role CHECK ('admin','manager','employee') — три уровня доступа.
|
||||||
|
-- - manager_id — self-FK, ON DELETE SET NULL (увольнение/удаление manager'а не должно
|
||||||
|
-- каскадно сносить его employees, они просто остаются без привязки).
|
||||||
|
-- - CHECK role_manager_hierarchy: admin/manager обязаны иметь manager_id IS NULL
|
||||||
|
-- (это top-level роли, у них нет "начальника" в этой модели); employee — manager_id
|
||||||
|
-- любой, включая NULL (свободный слот employee без организации допустим).
|
||||||
|
-- 2. tradein_sessions — токен-based сессии, привязаны к user_id, ON DELETE CASCADE
|
||||||
|
-- (удалили пользователя — его сессии теряют смысл, каскадная очистка корректна).
|
||||||
|
-- last_seen_at отдельно от created_at — для idle-timeout / активности сессии.
|
||||||
|
-- 3. Индексы: expires_at (уборка протухших сессий), user_id (список сессий юзера),
|
||||||
|
-- partial на manager_id (иерархия) — WHERE manager_id IS NOT NULL, т.к. большинство
|
||||||
|
-- admin/manager строк это NULL и не участвуют в lookup "employees этого manager'а".
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY:
|
||||||
|
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS. Повторный прогон — no-op.
|
||||||
|
-- CHECK-констрейнты добавлены inline в CREATE TABLE (не через ALTER) — при повторном
|
||||||
|
-- запуске CREATE TABLE IF NOT EXISTS не выполнится вообще, констрейнт не задублируется.
|
||||||
|
--
|
||||||
|
-- Dependencies: нет (новые таблицы, ничего существующего не меняем).
|
||||||
|
-- Deploy order: эта миграция — Foundation эпика #2549. Seed (#2557) и backend auth-код —
|
||||||
|
-- отдельные PR'ы ПОСЛЕ этой (SQL-схема первой, см. .claude/rules/sql.md "Migration order").
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tradein_users (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
username text NOT NULL UNIQUE,
|
||||||
|
password_hash text NULL,
|
||||||
|
role text NOT NULL CHECK (role IN ('admin', 'manager', 'employee')),
|
||||||
|
manager_id bigint NULL REFERENCES tradein_users(id) ON DELETE SET NULL,
|
||||||
|
display_name text NULL,
|
||||||
|
org_name text NULL,
|
||||||
|
email text NULL,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT tradein_users_role_manager_hierarchy_ck CHECK (
|
||||||
|
role NOT IN ('admin', 'manager') OR manager_id IS NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE tradein_users IS
|
||||||
|
'Trade-in DB-backed auth — пользователи (issue #2551, эпик #2549). password_hash NULL '
|
||||||
|
'допустим в переходный период (логин через legacy Caddy fallback). Seed — отдельно (#2557).';
|
||||||
|
COMMENT ON COLUMN tradein_users.password_hash IS
|
||||||
|
'NULL = логин только через legacy Caddy basic-auth fallback, не через password verify.';
|
||||||
|
COMMENT ON COLUMN tradein_users.manager_id IS
|
||||||
|
'Self-FK на tradein_users(id). NULL для admin/manager (top-level, CHECK ниже) или для '
|
||||||
|
'employee без назначенной организации.';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tradein_sessions (
|
||||||
|
token text PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL REFERENCES tradein_users(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ip_address inet NULL,
|
||||||
|
user_agent text NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE tradein_sessions IS
|
||||||
|
'Trade-in DB-backed auth — активные сессии (issue #2551, эпик #2549). '
|
||||||
|
'ON DELETE CASCADE от tradein_users: удалённый пользователь теряет все сессии.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS tradein_sessions_expires_at_idx
|
||||||
|
ON tradein_sessions (expires_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS tradein_sessions_user_id_idx
|
||||||
|
ON tradein_sessions (user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS tradein_users_manager_id_idx
|
||||||
|
ON tradein_users (manager_id)
|
||||||
|
WHERE manager_id IS NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
127
tradein-mvp/backend/data/sql/193_tradein_users_seed.sql
Normal file
127
tradein-mvp/backend/data/sql/193_tradein_users_seed.sql
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
-- Migration 193: seed существующих юзеров в tradein_users + ASCII-CHECK на username
|
||||||
|
-- (issue #2557, эпик #2549)
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- Migration 192 создала schema (tradein_users/tradein_sessions), но без данных —
|
||||||
|
-- DB-backed auth не может заработать, пока реальные аккаунты (сейчас живущие только
|
||||||
|
-- в auth/roles.yaml + caddy/users.caddy.snippet, legacy Caddy basic-auth) не отражены
|
||||||
|
-- в таблице. Эта миграция переносит org-карту, утверждённую владельцем продукта,
|
||||||
|
-- в данные — без единого пароля (см. WHAT.2) и без Python-кода (backend wiring — #2556,
|
||||||
|
-- team-UI для проставления паролей — отдельная задача, тоже #2556).
|
||||||
|
--
|
||||||
|
-- ASCII-CHECK (deep-review #2561, обязательное требование ДО прод-данных):
|
||||||
|
-- rbac кодирует session-username через `encode("latin-1", "replace")`. Кириллические
|
||||||
|
-- логины ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку под этой кодировкой
|
||||||
|
-- ("иванов" и "петров" оба 6 кириллических символов -> оба превращаются в одинаковую
|
||||||
|
-- строку из '?' одной длины) -> общий downstream-identity между разными людьми, общая
|
||||||
|
-- квота, взаимный IDOR (один видит сессии/данные другого). Все текущие org-логины уже
|
||||||
|
-- ASCII (admin/kopylov/praktika/userN), поэтому constraint не конфликтует с seed'ом
|
||||||
|
-- ниже; он существует, чтобы navsegda запретить будущим кириллическим логинам попасть
|
||||||
|
-- в таблицу — fail-closed на уровне схемы, а не на уровне доверия к тому, что кто-то
|
||||||
|
-- не забудет проверить в UI/API layer.
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$').
|
||||||
|
-- Добавлен ДО seed-инсертов ниже для читаемости файла (CHECK — immediate constraint,
|
||||||
|
-- Postgres валидирует им и ROW-строки транзакции независимо от того, в каком месте
|
||||||
|
-- файла он объявлен относительно INSERT, так что порядок сам по себе не критичен).
|
||||||
|
-- 2. Seed — org-карта, утверждённая владельцем продукта (2026-07-30):
|
||||||
|
-- admin role=admin, manager_id=NULL, is_active=true (владелец)
|
||||||
|
-- kopylov role=manager, manager_id=NULL, is_active=true (отдельный клиент)
|
||||||
|
-- praktika role=manager, manager_id=NULL, is_active=true (ГК «Практика»)
|
||||||
|
-- user1, user3-10 role=employee, manager_id=NULL, is_active=true (свободные слоты, без org)
|
||||||
|
-- user2 role=employee, manager_id=NULL, is_active=false («Брусника», доступ
|
||||||
|
-- закрыт 2026-07-30)
|
||||||
|
-- password_hash = NULL для ВСЕХ — пароли админ проставит вручную через team-UI (#2556).
|
||||||
|
-- NULL-hash делает password-логин невозможным для этой строки, но НЕ снимает доступ:
|
||||||
|
-- в переходный период работает только legacy Caddy basic-auth fallback (dual-mode,
|
||||||
|
-- см. комментарий password_hash в 192_tradein_users_auth.sql) — никто не теряет доступ
|
||||||
|
-- из-за этой миграции.
|
||||||
|
-- display_name = 'Копылов' для kopylov (источник — auth.py::_USERNAME_PROFILE, уже
|
||||||
|
-- задокументированная фамилия). Для остальных — NULL, реальных данных нет, не выдумываем.
|
||||||
|
-- НЕ мигрируем admintest/pilottest/analysttest/expiredtest — temp QA-фикстуры
|
||||||
|
-- (auth/roles.yaml), остаются только там, в DB-backed auth не нужны.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY:
|
||||||
|
-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint (Postgres не поддерживает
|
||||||
|
-- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из
|
||||||
|
-- 189_account_estimate_usage_nonnegative.sql.
|
||||||
|
-- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash,
|
||||||
|
-- manager_id, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) —
|
||||||
|
-- если админ уже проставил пароль / назначил manager_id (team-API #2563 пишет
|
||||||
|
-- manager_id при создании сотрудника менеджером) / поменял display_name вручную
|
||||||
|
-- через team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply
|
||||||
|
-- при recovery — обычный auto-apply тречит filename в _schema_migrations и не
|
||||||
|
-- запускает файл дважды на одном окружении, но scratch/staging БД такого
|
||||||
|
-- трекинга не имеют), повторный прогон НЕ должен затереть это состояние NULL-ом /
|
||||||
|
-- seed-дефолтом. Deep-review #2564 нашёл это живым багом: manager_id, назначенный
|
||||||
|
-- через #2563, тихо обнулялся повторным прогоном сида — employee выпадал из
|
||||||
|
-- `_LIST_EMPLOYEES_BY_MANAGER_SQL`, менеджер переставал видеть его в дашборде.
|
||||||
|
-- role намеренно синкается с EXCLUDED (не защищён) — это и есть источник истины
|
||||||
|
-- org-карты из этой миграции; если владелец продукта поправит эту таблицу новой
|
||||||
|
-- миграцией поверх, DO UPDATE-ветка должна донести исправление роли, а не
|
||||||
|
-- заморозить первый прогон навсегда.
|
||||||
|
-- - is_active НАМЕРЕННО отсутствует в SET (не COALESCE — колонка NOT NULL DEFAULT
|
||||||
|
-- true, COALESCE(NOT NULL, x) никогда не берёт x, это была бы мёртвая, вводящая в
|
||||||
|
-- заблуждение симметрия с password_hash/manager_id, deep-review #2564 medium).
|
||||||
|
-- Открытие/закрытие доступа (is_active) — решение владельца продукта, принимается
|
||||||
|
-- через UI (#2556), НЕ повторным прогоном этого seed-файла: после первой вставки
|
||||||
|
-- колонка сознательно «замораживается» на текущем значении в БД, seed её больше
|
||||||
|
-- не трогает.
|
||||||
|
--
|
||||||
|
-- Dependencies: 192_tradein_users_auth.sql (создаёт tradein_users, tradein_sessions).
|
||||||
|
-- Deploy order: после 192 (Foundation). Backend auth-код (login/password-verify) и
|
||||||
|
-- team-UI (#2556) — отдельные PR'ы ПОСЛЕ этой миграции (SQL-схема+данные первыми, см.
|
||||||
|
-- .claude/rules/sql.md "Migration order").
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Часть 1: ASCII-CHECK (immediate constraint — валидирует и вставляемые ниже строки).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'tradein_users_username_ascii_ck'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tradein_users
|
||||||
|
ADD CONSTRAINT tradein_users_username_ascii_ck
|
||||||
|
CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMENT ON CONSTRAINT tradein_users_username_ascii_ck ON tradein_users IS
|
||||||
|
'Fail-closed защита от кириллических/не-ASCII логинов (deep-review #2561): '
|
||||||
|
'rbac кодирует session-username через encode("latin-1","replace"), не-ASCII '
|
||||||
|
'логины одинаковой длины схлопываются в общий downstream-identity (IDOR).';
|
||||||
|
|
||||||
|
-- Часть 2: seed org-карты (владелец продукта, 2026-07-30).
|
||||||
|
INSERT INTO tradein_users
|
||||||
|
(username, password_hash, role, manager_id, display_name, org_name, email, is_active)
|
||||||
|
VALUES
|
||||||
|
('admin', NULL, 'admin', NULL, NULL, NULL, NULL, true),
|
||||||
|
('kopylov', NULL, 'manager', NULL, 'Копылов', NULL, NULL, true),
|
||||||
|
('praktika', NULL, 'manager', NULL, NULL, NULL, NULL, true),
|
||||||
|
('user1', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||||
|
('user2', NULL, 'employee', NULL, NULL, NULL, NULL, false),
|
||||||
|
('user3', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||||
|
('user4', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||||
|
('user5', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||||
|
('user6', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||||
|
('user7', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||||
|
('user8', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||||
|
('user9', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||||
|
('user10', NULL, 'employee', NULL, NULL, NULL, NULL, true)
|
||||||
|
ON CONFLICT (username) DO UPDATE SET
|
||||||
|
role = EXCLUDED.role,
|
||||||
|
-- manager_id защищён COALESCE: team-API (#2563) пишет manager_id при назначении
|
||||||
|
-- сотрудника менеджером, повторный прогон seed'а не должен тихо обнулять эту связь.
|
||||||
|
manager_id = COALESCE(tradein_users.manager_id, EXCLUDED.manager_id),
|
||||||
|
password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash),
|
||||||
|
display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name),
|
||||||
|
org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name),
|
||||||
|
email = COALESCE(tradein_users.email, EXCLUDED.email),
|
||||||
|
-- is_active НЕ в SET: NOT NULL DEFAULT true колонка, COALESCE был бы мёртвым кодом
|
||||||
|
-- (см. IDEMPOTENCY выше) — open/close доступа решается через UI (#2556), не seed'ом.
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -25,6 +25,7 @@ dependencies = [
|
||||||
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
|
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
|
||||||
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2)
|
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2)
|
||||||
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
|
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
|
||||||
|
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
|
||||||
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
|
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
|
||||||
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
|
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
|
||||||
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
|
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
|
||||||
|
|
|
||||||
536
tradein-mvp/backend/tests/test_auth_api.py
Normal file
536
tradein-mvp/backend/tests/test_auth_api.py
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
"""Integration tests for #2552 auth-core: POST /login, /logout, dual-mode GET /me,
|
||||||
|
and rbac_guard session-cookie resolution.
|
||||||
|
|
||||||
|
Uses the REAL `rbac_guard` (app.core.rbac) + REAL `auth.router` / `me.router` wired
|
||||||
|
into an isolated FastAPI test app (same pattern as tests/test_rbac.py), with an
|
||||||
|
in-memory fake DB standing in for `tradein_users`/`tradein_sessions`:
|
||||||
|
- `app.core.rbac.SessionLocal` is monkeypatched (rbac_guard opens its own session,
|
||||||
|
it's middleware — no FastAPI DI available there).
|
||||||
|
- `app.core.db.get_db` is overridden via `app.dependency_overrides` (auth.py /
|
||||||
|
me.py use `Depends(get_db)`, the idiomatic FastAPI-testable path).
|
||||||
|
|
||||||
|
Both point at the SAME `_Store` instance per test, so a session created by POST
|
||||||
|
/login is immediately visible to rbac_guard's own DB round trip on the next request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, Header
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1 import auth as auth_router
|
||||||
|
from app.api.v1 import me as me_router
|
||||||
|
from app.core import auth as auth_mod
|
||||||
|
from app.core import config
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.password import hash_password
|
||||||
|
from app.core.rbac import rbac_guard
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fake DB backing tradein_users / tradein_sessions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.users: dict[str, dict[str, Any]] = {}
|
||||||
|
self.sessions: dict[str, dict[str, Any]] = {}
|
||||||
|
self._next_id = 1
|
||||||
|
|
||||||
|
def add_user(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password_hash: str | None,
|
||||||
|
*,
|
||||||
|
role: str = "employee",
|
||||||
|
is_active: bool = True,
|
||||||
|
display_name: str | None = "Alice A.",
|
||||||
|
org_name: str | None = "Org LLC",
|
||||||
|
email: str | None = "alice@example.com",
|
||||||
|
) -> int:
|
||||||
|
uid = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
self.users[username] = {
|
||||||
|
"id": uid,
|
||||||
|
"username": username,
|
||||||
|
"password_hash": password_hash,
|
||||||
|
"role": role,
|
||||||
|
"is_active": is_active,
|
||||||
|
"display_name": display_name,
|
||||||
|
"org_name": org_name,
|
||||||
|
"email": email,
|
||||||
|
}
|
||||||
|
return uid
|
||||||
|
|
||||||
|
def user_by_id(self, uid: int) -> dict[str, Any] | None:
|
||||||
|
for u in self.users.values():
|
||||||
|
if u["id"] == uid:
|
||||||
|
return u
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_expired_session(self, token: str, user_id: int) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
self.sessions[token] = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"expires_at": now - timedelta(minutes=1),
|
||||||
|
"last_seen_at": now - timedelta(minutes=1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
"""Minimal Session stand-in dispatching on SQL text — see module docstring."""
|
||||||
|
|
||||||
|
def __init__(self, store: _Store) -> None:
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def __enter__(self) -> _FakeDB:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
|
||||||
|
sql = str(stmt)
|
||||||
|
p = params or {}
|
||||||
|
|
||||||
|
if "INSERT INTO tradein_sessions" in sql:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
self.store.sessions[p["token"]] = {
|
||||||
|
"user_id": p["user_id"],
|
||||||
|
"expires_at": now + timedelta(hours=p["ttl_hours"]),
|
||||||
|
"last_seen_at": now,
|
||||||
|
}
|
||||||
|
return SimpleNamespace(fetchone=lambda: None)
|
||||||
|
|
||||||
|
if "UPDATE tradein_sessions" in sql and "SET last_seen_at" in sql:
|
||||||
|
sess = self.store.sessions.get(p["token"])
|
||||||
|
if sess is not None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
sess["last_seen_at"] = now
|
||||||
|
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
|
||||||
|
return SimpleNamespace(fetchone=lambda: None)
|
||||||
|
|
||||||
|
if "DELETE FROM tradein_sessions WHERE token" in sql:
|
||||||
|
self.store.sessions.pop(p["token"], None)
|
||||||
|
return SimpleNamespace(fetchone=lambda: None)
|
||||||
|
|
||||||
|
if "DELETE FROM tradein_sessions WHERE user_id" in sql:
|
||||||
|
uid = p["user_id"]
|
||||||
|
for tok in [t for t, s in self.store.sessions.items() if s["user_id"] == uid]:
|
||||||
|
del self.store.sessions[tok]
|
||||||
|
return SimpleNamespace(fetchone=lambda: None)
|
||||||
|
|
||||||
|
if "FROM tradein_sessions s" in sql and "JOIN tradein_users u" in sql:
|
||||||
|
sess = self.store.sessions.get(p["token"])
|
||||||
|
if sess is None:
|
||||||
|
return SimpleNamespace(fetchone=lambda: None)
|
||||||
|
user = self.store.user_by_id(sess["user_id"])
|
||||||
|
if user is None:
|
||||||
|
return SimpleNamespace(fetchone=lambda: None)
|
||||||
|
row = SimpleNamespace(
|
||||||
|
user_id=sess["user_id"],
|
||||||
|
expires_at=sess["expires_at"],
|
||||||
|
last_seen_at=sess["last_seen_at"],
|
||||||
|
username=user["username"],
|
||||||
|
role=user["role"],
|
||||||
|
display_name=user["display_name"],
|
||||||
|
org_name=user["org_name"],
|
||||||
|
email=user["email"],
|
||||||
|
is_active=user["is_active"],
|
||||||
|
)
|
||||||
|
return SimpleNamespace(fetchone=lambda: row)
|
||||||
|
|
||||||
|
if "FROM tradein_users" in sql:
|
||||||
|
user = self.store.users.get(p["username"])
|
||||||
|
if user is None:
|
||||||
|
return SimpleNamespace(fetchone=lambda: None)
|
||||||
|
row = SimpleNamespace(**user)
|
||||||
|
return SimpleNamespace(fetchone=lambda: row)
|
||||||
|
|
||||||
|
raise AssertionError(f"unhandled fake SQL in test_auth_api: {sql!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test app
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_test_app(store: _Store) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
app.middleware("http")(rbac_guard)
|
||||||
|
app.include_router(auth_router.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
|
||||||
|
|
||||||
|
@app.get("/api/v1/trade-in/dummy")
|
||||||
|
async def tradein_dummy() -> dict:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/v1/trade-in/whoami")
|
||||||
|
async def tradein_whoami(
|
||||||
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Echoes the X-Authenticated-User header exactly as a downstream handler
|
||||||
|
(`_assert_estimate_access*`, `account_quota`, etc.) would see it — used to
|
||||||
|
assert session-identity wins over a client-forged header (#2552 spoof fix)."""
|
||||||
|
return {"user": x_authenticated_user}
|
||||||
|
|
||||||
|
def _override_get_db(): # generator dependency — matches app.core.db.get_db shape
|
||||||
|
yield _FakeDB(store)
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_get_db
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
auth_mod.reset_cache_for_tests()
|
||||||
|
auth_router._LOGIN_LIMITER._hits.clear()
|
||||||
|
monkeypatch.setattr(config.settings, "auth_mode", "dual")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store() -> _Store:
|
||||||
|
return _Store()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||||
|
monkeypatch.setattr("app.core.rbac.SessionLocal", lambda: _FakeDB(store))
|
||||||
|
# base_url=https:// — login sets the session cookie with Secure=True (real prod
|
||||||
|
# behaviour, not weakened for tests); httpx's cookie jar silently drops Secure
|
||||||
|
# cookies on a plain-http connection, so a plain http://testserver client would
|
||||||
|
# never resend the cookie on subsequent requests within the same test.
|
||||||
|
return TestClient(_build_test_app(store), base_url="https://testserver")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /login
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_happy_path_sets_cookie(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||||
|
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
|
cookie_name = config.settings.session_cookie_name
|
||||||
|
assert cookie_name in resp.cookies
|
||||||
|
assert resp.cookies[cookie_name]
|
||||||
|
# Сессия реально создана в сторе под этим токеном.
|
||||||
|
assert resp.cookies[cookie_name] in store.sessions
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password_401_and_logs_failed_event(
|
||||||
|
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw))
|
||||||
|
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "wrong"})
|
||||||
|
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "detail" in resp.json()
|
||||||
|
assert config.settings.session_cookie_name not in resp.cookies
|
||||||
|
assert any(e["event_type"] == "login_failed" for e in events)
|
||||||
|
failed = next(e for e in events if e["event_type"] == "login_failed")
|
||||||
|
assert failed["username"] == "alice"
|
||||||
|
# Raw-пароль никогда не попадает в событие.
|
||||||
|
assert "wrong" not in str(failed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_unknown_username_401_generic_message(client: TestClient) -> None:
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "whatever"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
# НЕ раскрываем, что юзера не существует — то же сообщение, что и wrong-password.
|
||||||
|
body_ghost = resp.json()["detail"]
|
||||||
|
|
||||||
|
resp2 = client.post("/api/v1/auth/login", json={"username": "ghost2", "password": "x"})
|
||||||
|
assert resp2.json()["detail"] == body_ghost
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_inactive_user_401(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("bob", hash_password("Secret123!"), role="employee", is_active=False)
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"username": "bob", "password": "Secret123!"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_null_password_hash_401(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("carol", None, role="employee")
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"username": "carol", "password": "anything"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_always_calls_verify_password_timing_oracle_guard(
|
||||||
|
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""#2552 post-review Medium 2: `verify_password` должен выполняться ровно
|
||||||
|
один раз на КАЖДУЮ попытку логина — включая неизвестный username и NULL
|
||||||
|
password_hash — иначе короткое замыкание даёт наблюдаемый timing-oracle
|
||||||
|
для user-enumeration. Тест не измеряет тайминг (флейки в CI), а проверяет
|
||||||
|
сам факт + аргумент вызова через monkeypatch-счётчик."""
|
||||||
|
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||||
|
store.add_user("nullhash", None, role="employee")
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
real_verify = auth_router.verify_password
|
||||||
|
|
||||||
|
def _counting_verify(plain: str, hashed: str) -> bool:
|
||||||
|
calls.append(hashed)
|
||||||
|
return real_verify(plain, hashed)
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_router, "verify_password", _counting_verify)
|
||||||
|
|
||||||
|
resp_unknown = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"})
|
||||||
|
assert resp_unknown.status_code == 401
|
||||||
|
|
||||||
|
resp_null_hash = client.post(
|
||||||
|
"/api/v1/auth/login", json={"username": "nullhash", "password": "x"}
|
||||||
|
)
|
||||||
|
assert resp_null_hash.status_code == 401
|
||||||
|
|
||||||
|
resp_wrong_pw = client.post(
|
||||||
|
"/api/v1/auth/login", json={"username": "alice", "password": "wrong"}
|
||||||
|
)
|
||||||
|
assert resp_wrong_pw.status_code == 401
|
||||||
|
|
||||||
|
assert len(calls) == 3
|
||||||
|
# Unknown user / NULL hash — сверяется против dummy-хеша, не против NULL.
|
||||||
|
assert calls[0] == auth_router._DUMMY_PASSWORD_HASH
|
||||||
|
assert calls[1] == auth_router._DUMMY_PASSWORD_HASH
|
||||||
|
# Реальный юзер с реальным hash — НЕ dummy.
|
||||||
|
assert calls[2] != auth_router._DUMMY_PASSWORD_HASH
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_rate_limit_429(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("dave", hash_password("Secret123!"), role="employee")
|
||||||
|
limit = config.settings.login_rate_limit
|
||||||
|
|
||||||
|
for _ in range(limit):
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
|
||||||
|
assert resp.status_code == 429
|
||||||
|
assert "Retry-After" in resp.headers
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /logout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_revokes_session_and_clears_cookie(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||||
|
login_resp = client.post(
|
||||||
|
"/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}
|
||||||
|
)
|
||||||
|
token = login_resp.cookies[config.settings.session_cookie_name]
|
||||||
|
assert token in store.sessions
|
||||||
|
|
||||||
|
logout_resp = client.post("/api/v1/auth/logout")
|
||||||
|
assert logout_resp.status_code == 200
|
||||||
|
assert logout_resp.json() == {"ok": True}
|
||||||
|
assert token not in store.sessions
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /me — session-first + dual-mode legacy fallback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_with_session_cookie_returns_db_role(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user(
|
||||||
|
"alice",
|
||||||
|
hash_password("Secret123!"),
|
||||||
|
role="employee",
|
||||||
|
display_name="Алиса",
|
||||||
|
org_name="ООО Ромашка",
|
||||||
|
email="alice@romashka.ru",
|
||||||
|
)
|
||||||
|
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/me")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["username"] == "alice"
|
||||||
|
assert body["role"] == "employee"
|
||||||
|
assert "/trade-in/**" in body["allowed_paths"]
|
||||||
|
assert "/admin/**" in body["deny_paths"]
|
||||||
|
assert body["display_name"] == "Алиса"
|
||||||
|
assert body["org"] == "ООО Ромашка"
|
||||||
|
assert body["email"] == "alice@romashka.ru"
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_manager_role_gets_team_path(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("mgr", hash_password("Secret123!"), role="manager")
|
||||||
|
client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"})
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/me")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "/api/v1/team/**" in resp.json()["allowed_paths"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_without_cookie_dual_mode_legacy_still_works(client: TestClient) -> None:
|
||||||
|
"""Regression guard: без сессии, auth_mode=dual — legacy X-Authenticated-User
|
||||||
|
путь через roles.yaml работает БЕЗ ИЗМЕНЕНИЙ."""
|
||||||
|
resp = client.get("/api/v1/me", headers={"X-Authenticated-User": "admin"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["username"] == "admin"
|
||||||
|
assert body["role"] == "admin"
|
||||||
|
assert body["allowed_paths"] == ["/**"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_no_cookie_no_header_401(client: TestClient) -> None:
|
||||||
|
resp = client.get("/api/v1/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_session_falls_back_to_no_auth(client: TestClient, store: _Store) -> None:
|
||||||
|
"""Истёкшая сессия трактуется как отсутствие cookie: без legacy-заголовка — 401."""
|
||||||
|
uid = store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||||
|
store.add_expired_session("expired-tok", uid)
|
||||||
|
|
||||||
|
client.cookies.set(config.settings.session_cookie_name, "expired-tok")
|
||||||
|
resp = client.get("/api/v1/trade-in/dummy")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_session_falls_back_to_legacy_header_in_dual_mode(
|
||||||
|
client: TestClient, store: _Store
|
||||||
|
) -> None:
|
||||||
|
"""Истёкшая сессия + валидный legacy header в dual-mode — header отрабатывает."""
|
||||||
|
uid = store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||||
|
store.add_expired_session("expired-tok", uid)
|
||||||
|
|
||||||
|
client.cookies.set(config.settings.session_cookie_name, "expired-tok")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/dummy",
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# rbac_guard dual vs db_only
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_only_mode_rejects_legacy_header_without_session(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/dummy",
|
||||||
|
headers={"X-Authenticated-User": "admin"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "session" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_only_mode_accepts_valid_session(
|
||||||
|
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||||
|
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/trade-in/dummy")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_user_can_reach_tradein_but_not_admin(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||||
|
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
|
||||||
|
|
||||||
|
ok = client.get("/api/v1/trade-in/dummy")
|
||||||
|
assert ok.status_code == 200
|
||||||
|
|
||||||
|
denied = client.get("/api/v1/admin/dummy")
|
||||||
|
# rbac_guard's admin-gate matches the path regex BEFORE routing even happens
|
||||||
|
# (route isn't registered on this test app) — role=employee != admin -> 403,
|
||||||
|
# never a 404 (a bare "any non-2xx" assertion would mask a rbac_guard typo).
|
||||||
|
assert denied.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #2552 post-review CRITICAL fix: session identity must win over a spoofed
|
||||||
|
# client-sent X-Authenticated-User header (was a skip-if-present bug — the
|
||||||
|
# forged header used to override the session for every downstream reader of
|
||||||
|
# the raw header: _assert_estimate_access*, account_quota, /trade-in/history,
|
||||||
|
# support.py — in BOTH auth_mode=dual and db_only).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_identity_wins_over_spoofed_header_dual_mode(
|
||||||
|
client: TestClient, store: _Store
|
||||||
|
) -> None:
|
||||||
|
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||||
|
store.add_user("victim", hash_password("Secret123!"), role="employee")
|
||||||
|
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/whoami",
|
||||||
|
headers={"X-Authenticated-User": "victim"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["user"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_identity_wins_over_spoofed_header_db_only_mode(
|
||||||
|
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||||
|
store.add_user("victim", hash_password("Secret123!"), role="employee")
|
||||||
|
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/whoami",
|
||||||
|
headers={"X-Authenticated-User": "victim"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["user"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cyrillic_username_session_propagation_does_not_500(
|
||||||
|
client: TestClient, store: _Store
|
||||||
|
) -> None:
|
||||||
|
"""#2552 post-review Medium 1: `.encode("latin-1")` без errors="replace" на
|
||||||
|
кириллическом username крашил бы КАЖДЫЙ запрос такого юзера с 500."""
|
||||||
|
store.add_user("алиса", hash_password("Secret123!"), role="employee")
|
||||||
|
login_resp = client.post(
|
||||||
|
"/api/v1/auth/login", json={"username": "алиса", "password": "Secret123!"}
|
||||||
|
)
|
||||||
|
assert login_resp.status_code == 200, login_resp.text
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/trade-in/whoami")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# latin-1 "replace" гарантированно не крашит — точное значение (что именно
|
||||||
|
# получится из non-latin1 байт) не является контрактом, важно отсутствие 500.
|
||||||
|
assert resp.json()["user"] is not None
|
||||||
306
tradein-mvp/backend/tests/test_auth_session.py
Normal file
306
tradein-mvp/backend/tests/test_auth_session.py
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
"""Tests for app.services.auth_session — session CRUD + DB-role scope mapping (#2552).
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
- create_session: INSERT with CAST(...) (never `:x::type`), commit, unique tokens.
|
||||||
|
- get_session_user: valid/expired/inactive/missing-row + sliding refresh (only when
|
||||||
|
last_seen_at is stale, best-effort — a refresh failure still returns the user).
|
||||||
|
- get_user_by_username: found/not-found.
|
||||||
|
- revoke_session / revoke_user_sessions: DELETE + commit.
|
||||||
|
- get_db_role_scope: employee/manager/admin/unknown mapping.
|
||||||
|
|
||||||
|
All functions here take `db: Session` as a plain argument (no SessionLocal() opened
|
||||||
|
internally) — unit tests just pass a hand-rolled fake, mirroring the `_FakeSession`
|
||||||
|
pattern from tests/test_user_events.py but adapted for `.fetchone()`-based reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.services import auth_session as svc
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fake DB session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
"""Minimal `Session` stand-in: queued `.fetchone()` results per `execute()` call,
|
||||||
|
in call order. `execute()` beyond the queue returns a result with `fetchone()
|
||||||
|
-> None`. Set `.raise_on_call = N` to make the Nth `execute()` (1-indexed) raise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, rows: list[Any] | None = None) -> None:
|
||||||
|
self._rows: list[Any] = list(rows or [])
|
||||||
|
self.executed: list[tuple[str, dict[str, Any] | None]] = []
|
||||||
|
self.committed = 0
|
||||||
|
self.rolled_back = 0
|
||||||
|
self.raise_on_call: int | None = None
|
||||||
|
|
||||||
|
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
|
||||||
|
call_no = len(self.executed) + 1
|
||||||
|
self.executed.append((str(stmt), params))
|
||||||
|
if self.raise_on_call == call_no:
|
||||||
|
raise RuntimeError("simulated DB failure")
|
||||||
|
row = self._rows.pop(0) if self._rows else None
|
||||||
|
return SimpleNamespace(fetchone=lambda: row)
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.committed += 1
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
self.rolled_back += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _session_row(
|
||||||
|
*,
|
||||||
|
user_id: int = 1,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
last_seen_at: datetime | None = None,
|
||||||
|
username: str = "alice",
|
||||||
|
role: str = "employee",
|
||||||
|
is_active: bool = True,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
return SimpleNamespace(
|
||||||
|
user_id=user_id,
|
||||||
|
expires_at=expires_at if expires_at is not None else now + timedelta(hours=1),
|
||||||
|
last_seen_at=last_seen_at if last_seen_at is not None else now,
|
||||||
|
username=username,
|
||||||
|
role=role,
|
||||||
|
display_name="Alice A.",
|
||||||
|
org_name="Org LLC",
|
||||||
|
email="alice@example.com",
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_row(
|
||||||
|
*,
|
||||||
|
user_id: int = 1,
|
||||||
|
username: str = "alice",
|
||||||
|
password_hash: str | None = "hash",
|
||||||
|
role: str = "employee",
|
||||||
|
is_active: bool = True,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=user_id,
|
||||||
|
username=username,
|
||||||
|
password_hash=password_hash,
|
||||||
|
role=role,
|
||||||
|
is_active=is_active,
|
||||||
|
display_name="Alice A.",
|
||||||
|
org_name="Org LLC",
|
||||||
|
email="alice@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_inserts_and_commits() -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
token = svc.create_session(db, user_id=42, ip="1.2.3.4", user_agent="pytest")
|
||||||
|
|
||||||
|
assert db.committed == 1
|
||||||
|
assert len(db.executed) == 1
|
||||||
|
sql, params = db.executed[0]
|
||||||
|
assert "INSERT INTO tradein_sessions" in sql
|
||||||
|
assert params is not None
|
||||||
|
assert params["user_id"] == 42
|
||||||
|
assert params["ip"] == "1.2.3.4"
|
||||||
|
assert params["user_agent"] == "pytest"
|
||||||
|
assert params["token"] == token
|
||||||
|
assert isinstance(token, str)
|
||||||
|
assert len(token) >= 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_cast_not_doublecolon() -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
svc.create_session(db, user_id=1)
|
||||||
|
sql, _ = db.executed[0]
|
||||||
|
assert not re.search(r":\w+::\w", sql)
|
||||||
|
assert "CAST(:ttl_hours AS integer)" in sql
|
||||||
|
assert "CAST(:ip AS inet)" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_tokens_are_unique() -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
t1 = svc.create_session(db, user_id=1)
|
||||||
|
t2 = svc.create_session(db, user_id=1)
|
||||||
|
assert t1 != t2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_session_user
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_session_user_no_token_returns_none() -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
assert svc.get_session_user(db, "") is None
|
||||||
|
assert db.executed == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_session_user_missing_row_returns_none() -> None:
|
||||||
|
db = _FakeDB(rows=[None])
|
||||||
|
assert svc.get_session_user(db, "tok") is None
|
||||||
|
sql, params = db.executed[0]
|
||||||
|
assert "FROM tradein_sessions s" in sql
|
||||||
|
assert "JOIN tradein_users u" in sql
|
||||||
|
assert params == {"token": "tok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_session_user_expired_returns_none() -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
db = _FakeDB(rows=[_session_row(expires_at=now - timedelta(minutes=1))])
|
||||||
|
assert svc.get_session_user(db, "tok") is None
|
||||||
|
# Никакого sliding-refresh UPDATE для невалидной сессии.
|
||||||
|
assert len(db.executed) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_session_user_inactive_returns_none() -> None:
|
||||||
|
db = _FakeDB(rows=[_session_row(is_active=False)])
|
||||||
|
assert svc.get_session_user(db, "tok") is None
|
||||||
|
assert len(db.executed) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_session_user_valid_recent_no_refresh() -> None:
|
||||||
|
"""last_seen_at свежий (<5 мин) — sliding refresh НЕ триггерится."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=1))])
|
||||||
|
result = svc.get_session_user(db, "tok")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["username"] == "alice"
|
||||||
|
assert result["role"] == "employee"
|
||||||
|
assert result["display_name"] == "Alice A."
|
||||||
|
assert result["org_name"] == "Org LLC"
|
||||||
|
assert result["email"] == "alice@example.com"
|
||||||
|
assert result["user_id"] == 1
|
||||||
|
# Только 1 execute (SELECT) — никакого UPDATE.
|
||||||
|
assert len(db.executed) == 1
|
||||||
|
assert db.committed == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_session_user_stale_last_seen_triggers_refresh() -> None:
|
||||||
|
"""last_seen_at старше 5 минут — один UPDATE (sliding refresh) + commit."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
|
||||||
|
result = svc.get_session_user(db, "tok")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert len(db.executed) == 2
|
||||||
|
update_sql, update_params = db.executed[1]
|
||||||
|
assert "UPDATE tradein_sessions" in update_sql
|
||||||
|
assert "SET last_seen_at" in update_sql
|
||||||
|
assert not re.search(r":\w+::\w", update_sql)
|
||||||
|
assert "CAST(:ttl_hours AS integer)" in update_sql
|
||||||
|
assert update_params == {"ttl_hours": 720, "token": "tok"}
|
||||||
|
assert db.committed == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_session_user_refresh_failure_is_swallowed() -> None:
|
||||||
|
"""Sliding-refresh UPDATE падает — всё равно возвращаем валидного юзера
|
||||||
|
(best-effort refresh, не часть решения "валидна ли сессия")."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
|
||||||
|
db.raise_on_call = 2
|
||||||
|
|
||||||
|
result = svc.get_session_user(db, "tok")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["username"] == "alice"
|
||||||
|
assert db.rolled_back == 1
|
||||||
|
assert db.committed == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_user_by_username
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_by_username_found() -> None:
|
||||||
|
db = _FakeDB(rows=[_user_row()])
|
||||||
|
user = svc.get_user_by_username(db, "alice")
|
||||||
|
|
||||||
|
assert user is not None
|
||||||
|
assert user["username"] == "alice"
|
||||||
|
assert user["password_hash"] == "hash"
|
||||||
|
assert user["role"] == "employee"
|
||||||
|
assert user["is_active"] is True
|
||||||
|
sql, params = db.executed[0]
|
||||||
|
assert "FROM tradein_users" in sql
|
||||||
|
assert params == {"username": "alice"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_by_username_not_found() -> None:
|
||||||
|
db = _FakeDB(rows=[None])
|
||||||
|
assert svc.get_user_by_username(db, "ghost") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# revoke_session / revoke_user_sessions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_session_deletes_and_commits() -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
svc.revoke_session(db, "tok")
|
||||||
|
|
||||||
|
assert db.committed == 1
|
||||||
|
sql, params = db.executed[0]
|
||||||
|
assert "DELETE FROM tradein_sessions" in sql
|
||||||
|
assert "token" in sql
|
||||||
|
assert params == {"token": "tok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_user_sessions_deletes_and_commits() -> None:
|
||||||
|
db = _FakeDB()
|
||||||
|
svc.revoke_user_sessions(db, 7)
|
||||||
|
|
||||||
|
assert db.committed == 1
|
||||||
|
sql, params = db.executed[0]
|
||||||
|
assert "DELETE FROM tradein_sessions" in sql
|
||||||
|
assert "user_id" in sql
|
||||||
|
assert params == {"user_id": 7}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_db_role_scope
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_db_role_scope_employee_matches_legacy_pilot() -> None:
|
||||||
|
paths, deny = svc.get_db_role_scope("employee")
|
||||||
|
assert "/trade-in/**" in paths
|
||||||
|
assert "/trade-in/api/v1/**" in paths
|
||||||
|
assert "/admin/**" in deny
|
||||||
|
assert "/api/v1/admin/**" in deny
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_db_role_scope_manager_adds_team_path() -> None:
|
||||||
|
paths, deny = svc.get_db_role_scope("manager")
|
||||||
|
assert "/trade-in/**" in paths
|
||||||
|
assert "/api/v1/team/**" in paths
|
||||||
|
assert "/admin/**" in deny
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_db_role_scope_admin_full_access() -> None:
|
||||||
|
paths, deny = svc.get_db_role_scope("admin")
|
||||||
|
assert paths == ["/**"]
|
||||||
|
assert deny == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_db_role_scope_unknown_role_denies_all() -> None:
|
||||||
|
paths, deny = svc.get_db_role_scope("ghost")
|
||||||
|
assert paths == []
|
||||||
|
assert deny == ["/**"]
|
||||||
79
tradein-mvp/backend/tests/test_password.py
Normal file
79
tradein-mvp/backend/tests/test_password.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""Тесты для app/core/password.py — bcrypt hash/verify (#2550)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.password import hash_password, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip() -> None:
|
||||||
|
"""hash_password → verify_password с тем же паролем возвращает True."""
|
||||||
|
hashed = hash_password("correct horse battery staple")
|
||||||
|
assert verify_password("correct horse battery staple", hashed) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_returns_false() -> None:
|
||||||
|
"""Неверный пароль против валидного хеша → False."""
|
||||||
|
hashed = hash_password("correct horse battery staple")
|
||||||
|
assert verify_password("wrong password", hashed) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_too_long_raises_value_error() -> None:
|
||||||
|
"""Пароль >72 байт в UTF-8 → ValueError в hash_password (нет silent truncation)."""
|
||||||
|
long_password = "a" * 73
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
hash_password(long_password)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_exactly_72_bytes_ok() -> None:
|
||||||
|
"""Ровно 72 байта — граничное значение, ещё допустимо."""
|
||||||
|
password = "a" * 72
|
||||||
|
hashed = hash_password(password)
|
||||||
|
assert verify_password(password, hashed) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_too_long_multibyte_raises_value_error() -> None:
|
||||||
|
"""40 кириллических символов = 80 байт UTF-8 (2 байта/символ) → ValueError.
|
||||||
|
|
||||||
|
Проверяет, что лимит считается в байтах, а не в символах — иначе 40-символьный
|
||||||
|
кириллический пароль (< 72 символов, но 80 байт) прошёл бы мимо guard'а.
|
||||||
|
"""
|
||||||
|
long_cyrillic_password = "а" * 40
|
||||||
|
assert len(long_cyrillic_password.encode("utf-8")) == 80
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
hash_password(long_cyrillic_password)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_too_long_returns_false_not_raise() -> None:
|
||||||
|
"""verify_password на >72-байтовом пароле возвращает False, НЕ raise."""
|
||||||
|
hashed = hash_password("some valid password")
|
||||||
|
long_password = "a" * 73
|
||||||
|
assert verify_password(long_password, hashed) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_empty_raises_value_error() -> None:
|
||||||
|
"""Пустой пароль → ValueError в hash_password."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
hash_password("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_empty_returns_false() -> None:
|
||||||
|
"""Пустой пароль в verify_password → False (не raise)."""
|
||||||
|
hashed = hash_password("some valid password")
|
||||||
|
assert verify_password("", hashed) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_is_unique_due_to_salt() -> None:
|
||||||
|
"""Два хеша одного пароля различаются (уникальная соль на каждый вызов)."""
|
||||||
|
password = "correct horse battery staple"
|
||||||
|
hash1 = hash_password(password)
|
||||||
|
hash2 = hash_password(password)
|
||||||
|
assert hash1 != hash2
|
||||||
|
assert verify_password(password, hash1) is True
|
||||||
|
assert verify_password(password, hash2) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_malformed_hash_returns_false() -> None:
|
||||||
|
"""Некорректный (не-bcrypt) хеш в verify_password → False, не raise."""
|
||||||
|
assert verify_password("some password", "not-a-bcrypt-hash") is False
|
||||||
|
|
@ -90,7 +90,9 @@ def test_get_role_known_users() -> None:
|
||||||
assert auth_mod.get_role("admin") == "admin"
|
assert auth_mod.get_role("admin") == "admin"
|
||||||
assert auth_mod.get_role("kopylov") == "pilot"
|
assert auth_mod.get_role("kopylov") == "pilot"
|
||||||
for n in range(1, 11):
|
for n in range(1, 11):
|
||||||
assert auth_mod.get_role(f"user{n}") == "pilot"
|
# user2 («Брусника») — доступ закрыт 2026-07-30 (#2548)
|
||||||
|
expected = "expired" if n == 2 else "pilot"
|
||||||
|
assert auth_mod.get_role(f"user{n}") == expected
|
||||||
|
|
||||||
|
|
||||||
def test_get_role_unknown_user_raises() -> None:
|
def test_get_role_unknown_user_raises() -> None:
|
||||||
|
|
|
||||||
1031
tradein-mvp/backend/tests/test_team_api.py
Normal file
1031
tradein-mvp/backend/tests/test_team_api.py
Normal file
File diff suppressed because it is too large
Load diff
288
tradein-mvp/frontend/src/app/login/page.tsx
Normal file
288
tradein-mvp/frontend/src/app/login/page.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #2555 (эпик #2549) — login-форма для новой DB-backed session auth
|
||||||
|
* (POST /api/v1/auth/login, см. `tradein-mvp/backend/app/api/v1/auth.py`).
|
||||||
|
*
|
||||||
|
* Не гейтится RouteGuard'ом (см. `components/auth/RouteGuard.tsx` —
|
||||||
|
* `isLoginPage` bypass) — иначе редирект-петля: 401 от /me на /login тоже
|
||||||
|
* пытался бы увести на /login.
|
||||||
|
*
|
||||||
|
* `next=` читаем вручную из `window.location.search` (SSR-guard), а НЕ
|
||||||
|
* через `next/navigation` `useSearchParams()` — тот форсит Suspense boundary
|
||||||
|
* и ломает `next build` (см. `app/v2/page.tsx: readUrlId` — тот же паттерн,
|
||||||
|
* уже принятый в этом репо).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { CSSProperties, FormEvent } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch, HTTPError } from "@/lib/api";
|
||||||
|
import { ME_QUERY_KEY } from "@/lib/useMe";
|
||||||
|
|
||||||
|
interface LoginInput {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginRequest(input: LoginInput): Promise<void> {
|
||||||
|
await apiFetch<{ ok: boolean }>("/api/v1/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNextParam(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return new URLSearchParams(window.location.search).get("next");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open-redirect guard: принимаем только внутренний путь, начинающийся
|
||||||
|
* ровно с одного "/" — не "//host" (protocol-relative URL) и не "/\host"
|
||||||
|
* (браузеры местами трактуют backslash как forward slash в URL-парсинге).
|
||||||
|
*
|
||||||
|
* PR #2562 review finding 1: WHATWG URL-парсер (который `router.push`
|
||||||
|
* использует под капотом) убирает ВСЕ ASCII tab/CR/LF из строки ПЕРЕД
|
||||||
|
* парсингом — так `"/\t//evil"` для наивного regex выглядит как безопасный
|
||||||
|
* путь с одним leading slash (символ в позиции 1 — таб, не "/" и не "\"),
|
||||||
|
* а после навигации превращается в `"//evil"` (protocol-relative → чужой
|
||||||
|
* origin). Убираем те же символы ДО валидации, чтобы regex видел ту же
|
||||||
|
* строку, что увидит парсер.
|
||||||
|
*
|
||||||
|
* PR #2562 review finding 2: `next=/login` (или `/login?...`) после успешного
|
||||||
|
* логина кидал бы юзера обратно на форму входа (RouteGuard не гейтит
|
||||||
|
* `/login`) — dead-end. Фолбэк на "/" в этом случае.
|
||||||
|
*/
|
||||||
|
function sanitizeNext(next: string | null): string {
|
||||||
|
if (!next) return "/";
|
||||||
|
const cleaned = next.replace(/[\t\r\n]/g, "");
|
||||||
|
if (!/^\/(?!\/|\\)/.test(cleaned)) return "/";
|
||||||
|
if (
|
||||||
|
cleaned === "/login" ||
|
||||||
|
cleaned.startsWith("/login?") ||
|
||||||
|
cleaned.startsWith("/login#")
|
||||||
|
) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof HTTPError) {
|
||||||
|
if (error.status === 401) return "Неверный логин или пароль";
|
||||||
|
if (error.status === 429) {
|
||||||
|
return "Слишком много попыток. Попробуйте через несколько минут";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Не удалось войти. Проверьте подключение и попробуйте ещё раз";
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyle: CSSProperties = {
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "32px 28px",
|
||||||
|
maxWidth: 380,
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginBottom: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
height: 40,
|
||||||
|
padding: "0 12px",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const loginMutation = useMutation({
|
||||||
|
mutationFn: loginRequest,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
|
||||||
|
router.push(sanitizeNext(readNextParam()));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (loginMutation.isPending) return;
|
||||||
|
loginMutation.mutate({ username: username.trim(), password });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "var(--bg-app)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
@keyframes login-spin { to { transform: rotate(360deg); } }
|
||||||
|
.login-spinner { animation: login-spin .7s linear infinite; }
|
||||||
|
.login-input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||||
|
}
|
||||||
|
.login-submit:hover:not(:disabled) { background: var(--accent-hover); }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={cardStyle} aria-label="Вход в Меру">
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px",
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
lineHeight: 1.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Вход
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 24px",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Войдите, чтобы продолжить работу с Мерой.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle} htmlFor="login-username">
|
||||||
|
Логин
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="login-username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="login-input"
|
||||||
|
style={inputStyle}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={loginMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<label style={labelStyle} htmlFor="login-password">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
className="login-input"
|
||||||
|
style={inputStyle}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loginMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginMutation.isError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
margin: "0 0 16px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--danger-soft)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loginErrorMessage(loginMutation.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="login-submit"
|
||||||
|
disabled={loginMutation.isPending}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 16px",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: loginMutation.isPending ? "wait" : "pointer",
|
||||||
|
opacity: loginMutation.isPending ? 0.75 : 1,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loginMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="login-spinner"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="9"
|
||||||
|
stroke="rgba(255,255,255,0.35)"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21 12a9 9 0 0 0-9-9"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Входим…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Войти"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -66,7 +66,7 @@ import {
|
||||||
} from "@/lib/trade-in-api";
|
} from "@/lib/trade-in-api";
|
||||||
import { useQuota } from "@/lib/useQuota";
|
import { useQuota } from "@/lib/useQuota";
|
||||||
import { useMe } from "@/lib/useMe";
|
import { useMe } from "@/lib/useMe";
|
||||||
import { logout } from "@/lib/logout";
|
import { useLogout } from "@/lib/useLogout";
|
||||||
|
|
||||||
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
|
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
|
||||||
// non-interactive overlay drawn over the artboard gradient. The frame has
|
// non-interactive overlay drawn over the artboard gradient. The frame has
|
||||||
|
|
@ -524,6 +524,10 @@ export default function TradeInV2Page() {
|
||||||
// (#2046) — known profile fields fall back to username / brand ?? role / ""
|
// (#2046) — known profile fields fall back to username / brand ?? role / ""
|
||||||
// when absent (never invented). undefined while loading → TopNav «Гость».
|
// when absent (never invented). undefined while loading → TopNav «Гость».
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
|
// #2555: session-logout (POST /api/v1/auth/logout + local /me-cache
|
||||||
|
// invalidate + redirect на /login) — replaces the legacy basic_auth-cache
|
||||||
|
// -bust `logout()` for the v2 TopNav (new /login-form users).
|
||||||
|
const logoutMutation = useLogout();
|
||||||
|
|
||||||
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
||||||
// section via the mappers (null input) rather than blanking the page.
|
// section via the mappers (null input) rather than blanking the page.
|
||||||
|
|
@ -973,7 +977,7 @@ export default function TradeInV2Page() {
|
||||||
onNavigate={setNav}
|
onNavigate={setNav}
|
||||||
reports={reportsCount ?? 0}
|
reports={reportsCount ?? 0}
|
||||||
user={topNavUser}
|
user={topNavUser}
|
||||||
onLogout={logout}
|
onLogout={() => logoutMutation.mutate()}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
<main
|
<main
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,17 @@
|
||||||
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
|
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
|
||||||
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
|
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
|
||||||
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
|
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
|
||||||
|
*
|
||||||
|
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
|
||||||
|
* пространстве путей БЕЗ basePath — Next сам префиксит basePath на навигации
|
||||||
|
* (см. `next.config.ts` комментарий `basePath`). Поэтому `next=` в query
|
||||||
|
* строится из `rawPath` (БЕЗ basePath), а не `absolutePath` — иначе
|
||||||
|
* `/login/page.tsx` сделал бы `router.push("/trade-in/history")`, и Next
|
||||||
|
* задвоил бы префикс в `/trade-in/trade-in/history`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||||||
import { HTTPError } from "@/lib/api";
|
import { HTTPError } from "@/lib/api";
|
||||||
|
|
@ -29,6 +37,7 @@ interface RouteGuardProps {
|
||||||
|
|
||||||
export function RouteGuard({ children }: RouteGuardProps) {
|
export function RouteGuard({ children }: RouteGuardProps) {
|
||||||
const rawPath = usePathname() ?? "/";
|
const rawPath = usePathname() ?? "/";
|
||||||
|
const router = useRouter();
|
||||||
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
|
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
|
||||||
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
|
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
|
||||||
const absolutePath = BASE_PATH
|
const absolutePath = BASE_PATH
|
||||||
|
|
@ -36,21 +45,51 @@ export function RouteGuard({ children }: RouteGuardProps) {
|
||||||
: rawPath;
|
: rawPath;
|
||||||
const { data, isLoading, error } = useMe();
|
const { data, isLoading, error } = useMe();
|
||||||
|
|
||||||
|
// #2555: /login сам себя не гейтит — иначе редирект-петля (401 на /me →
|
||||||
|
// редирект на /login → RouteGuard на /login опять видит 401 → редирект…).
|
||||||
|
const isLoginPage = rawPath === "/login";
|
||||||
|
|
||||||
|
// Prod-only: сессия истекла/отсутствует → уводим на логин вместо старого
|
||||||
|
// NoAccessScreen variant="session". Редирект — побочный эффект (нельзя
|
||||||
|
// router.push во время рендера), поэтому useEffect; пока он не сработал,
|
||||||
|
// рендерим null (см. return ниже), чтобы не мигал старый contents.
|
||||||
|
const shouldRedirectToLogin =
|
||||||
|
!isLoginPage &&
|
||||||
|
process.env.NODE_ENV === "production" &&
|
||||||
|
error instanceof HTTPError &&
|
||||||
|
error.status === 401;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldRedirectToLogin) return;
|
||||||
|
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
|
||||||
|
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
|
||||||
|
// `window.location.search` юзер, чья сессия истекла mid-session на такой
|
||||||
|
// ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect
|
||||||
|
// — гарантированно client-side (useEffect тело никогда не бежит на SSR),
|
||||||
|
// поэтому `window` тут безопасен без typeof-guard.
|
||||||
|
const next = `${rawPath}${window.location.search}`;
|
||||||
|
router.push(`/login?next=${encodeURIComponent(next)}`);
|
||||||
|
}, [shouldRedirectToLogin, rawPath, router]);
|
||||||
|
|
||||||
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
|
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
|
||||||
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
|
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
|
||||||
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
|
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoginPage) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
if (error instanceof HTTPError && error.status === 401) {
|
if (error instanceof HTTPError && error.status === 401) {
|
||||||
// Dev without Caddy: 401 is normal, mount the app so local dev works.
|
// Dev without Caddy: 401 is normal, mount the app so local dev works.
|
||||||
// Prod: mounting children on 401 causes TanStack Query re-subscribe storm
|
|
||||||
// (each new observer on errored query triggers a refetch). Show session screen
|
|
||||||
// instead — prevents the subtree from mounting, kills the loop.
|
|
||||||
if (process.env.NODE_ENV !== "production") return <>{children}</>;
|
if (process.env.NODE_ENV !== "production") return <>{children}</>;
|
||||||
return <NoAccessScreen variant="session" />;
|
// Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
|
||||||
|
// навигация не завершится (mounting children on 401 causes TanStack
|
||||||
|
// Query re-subscribe storm, см. историю до #2555 в git blame).
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof HTTPError && error.status === 403) {
|
if (error instanceof HTTPError && error.status === 403) {
|
||||||
|
|
|
||||||
40
tradein-mvp/frontend/src/lib/useLogout.ts
Normal file
40
tradein-mvp/frontend/src/lib/useLogout.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #2555: session-logout — POST /api/v1/auth/logout (revoke DB session +
|
||||||
|
* очистка httponly cookie tradein_session), затем чистим локальный
|
||||||
|
* TanStack Query /me-кэш и уходим на /login.
|
||||||
|
*
|
||||||
|
* NB: это НЕ замена legacy `@/lib/logout.ts` (Caddy basic_auth cache-bust +
|
||||||
|
* hard reload) — тот остаётся для страниц/пользователей на старом
|
||||||
|
* trusted-header механизме (см. `app.core.rbac` dual-mode resolver).
|
||||||
|
* useLogout — для юзеров, залогиненных через новую /login форму (#2552).
|
||||||
|
*
|
||||||
|
* Backend logout — best-effort по духу (revoke конкретной сессии), поэтому
|
||||||
|
* локальный logout (кэш + редирект) выполняется в `onSettled`, а не только
|
||||||
|
* `onSuccess`: сетевой сбой / уже-протухшая сессия не должны запирать юзера
|
||||||
|
* на странице без возможности разлогиниться.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { ME_QUERY_KEY } from "@/lib/useMe";
|
||||||
|
|
||||||
|
async function logoutRequest(): Promise<void> {
|
||||||
|
await apiFetch<{ ok: boolean }>("/api/v1/auth/logout", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: logoutRequest,
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
|
||||||
|
router.push("/login");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,18 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
|
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
|
||||||
|
|
||||||
export type Role = "admin" | "pilot" | "expired";
|
// #2555: session-auth (POST /api/v1/auth/login) вводит новые роли
|
||||||
|
// admin|manager|employee. Legacy Caddy trusted-header роли (pilot|analyst|
|
||||||
|
// expired) остаются — backend `/api/v1/me` может отдать любую из обеих
|
||||||
|
// групп в зависимости от того, каким механизмом пришёл юзер (dual-mode
|
||||||
|
// resolver, см. `tradein-mvp/backend/app/core/rbac.py`).
|
||||||
|
export type Role =
|
||||||
|
| "admin"
|
||||||
|
| "manager"
|
||||||
|
| "employee"
|
||||||
|
| "pilot"
|
||||||
|
| "analyst"
|
||||||
|
| "expired";
|
||||||
|
|
||||||
export interface UserScope {
|
export interface UserScope {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
|
||||||
68
tradein-mvp/uv.lock
generated
68
tradein-mvp/uv.lock
generated
|
|
@ -43,6 +43,72 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|
@ -1609,6 +1675,7 @@ name = "tradein-mvp-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "backend" }
|
source = { virtual = "backend" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "curl-cffi" },
|
{ name = "curl-cffi" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "geoalchemy2" },
|
{ name = "geoalchemy2" },
|
||||||
|
|
@ -1644,6 +1711,7 @@ dev = [
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "bcrypt", specifier = ">=4.2.0" },
|
||||||
{ name = "curl-cffi", specifier = ">=0.7.0" },
|
{ name = "curl-cffi", specifier = ">=0.7.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||||
{ name = "geoalchemy2", specifier = ">=0.15.0" },
|
{ name = "geoalchemy2", specifier = ">=0.15.0" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue