diff --git a/.env.example b/.env.example index e914ffa..c94943a 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,7 @@ RABBITMQ_DEFAULT_USER=flowix RABBITMQ_DEFAULT_PASS=flowix # Auth +# ⚠️ dev-only values — обязательно заменить в prod (issue #53: пустые значения роняют сервисы на старте) JWT_SECRET=change-me-super-secret-jwt-key-32chars JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=168h diff --git a/Makefile b/Makefile index c43bd42..cbaf83a 100644 --- a/Makefile +++ b/Makefile @@ -94,8 +94,9 @@ sync-py: uv sync --project services/transcoder # local dev via uv / go (требует Go 1.27 локально, иначе используй docker compose up) +# ENV=dev — bypass fail-fast на пустых JWT_SECRET/INTERNAL_TOKEN (issue #53) dev-auth: - uv run --project services/auth uvicorn src.main:app --reload --port 8001 + ENV=dev uv run --project services/auth uvicorn src.main:app --reload --port 8001 dev-transcoder: uv run --project services/transcoder python -m app.consumer @@ -103,13 +104,13 @@ dev-transcoder-celery: # legacy, Phase 10 — celery deprecated, use dev-transco uv run --project services/transcoder celery -A app.celery_app worker --loglevel=info dev-metadata: - set -a; . ./.env 2>/dev/null || true; cd services/metadata && go run ./cmd/server + set -a; . ./.env 2>/dev/null || true; export ENV=dev; cd services/metadata && go run ./cmd/server dev-upload: - set -a; . ./.env 2>/dev/null || true; cd services/upload && go run ./cmd/server + set -a; . ./.env 2>/dev/null || true; export ENV=dev; cd services/upload && go run ./cmd/server dev-gateway: - set -a; . ./.env 2>/dev/null || true; cd services/gateway && go run ./cmd/server + set -a; . ./.env 2>/dev/null || true; export ENV=dev; cd services/gateway && go run ./cmd/server dev-frontend: cd frontend && npm run dev diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index bef7daf..33f1887 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -5,7 +5,7 @@ services: environment: TZ: UTC ENV: prod - INTERNAL_TOKEN: ${INTERNAL_TOKEN:-} + INTERNAL_TOKEN: ${INTERNAL_TOKEN:?INTERNAL_TOKEN is required — set it in .env (see .env.example)} volumes: - ./nginx/nginx.prod.conf:/etc/nginx/nginx.prod.conf.template:ro diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index c3a2fc4..24524c3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -131,7 +131,7 @@ services: nginx-vod: build: ./nginx environment: - INTERNAL_TOKEN: ${INTERNAL_TOKEN:-} + INTERNAL_TOKEN: ${INTERNAL_TOKEN:?INTERNAL_TOKEN is required — set it in .env (see .env.example)} depends_on: minio: condition: service_healthy @@ -156,7 +156,7 @@ services: DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-10} PGBOUNCER_ENABLED: ${PGBOUNCER_ENABLED:-false} RABBITMQ_URL: amqp://flowix:flowix@rabbitmq:5672/ - JWT_SECRET: ${JWT_SECRET} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required — set it in .env (see .env.example)} MINIO_ENDPOINT: minio:9000 MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} @@ -183,8 +183,8 @@ services: DATABASE_URL: postgres://flowix:flowix@postgres:5432/flowix?sslmode=disable DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-5} PGBOUNCER_ENABLED: ${PGBOUNCER_ENABLED:-false} - JWT_SECRET: ${JWT_SECRET} - INTERNAL_TOKEN: ${INTERNAL_TOKEN:-} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required — set it in .env (see .env.example)} + INTERNAL_TOKEN: ${INTERNAL_TOKEN:?INTERNAL_TOKEN is required — set it in .env (see .env.example)} MINIO_ENDPOINT: minio:9000 MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} @@ -217,8 +217,8 @@ services: MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} VIDEO_STORAGE_BUCKET: ${VIDEO_STORAGE_BUCKET:-videos} METADATA_URL: http://metadata:8002 - INTERNAL_TOKEN: ${INTERNAL_TOKEN:-} - JWT_SECRET: ${JWT_SECRET} + INTERNAL_TOKEN: ${INTERNAL_TOKEN:?INTERNAL_TOKEN is required — set it in .env (see .env.example)} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required — set it in .env (see .env.example)} UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-5368709120} MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000} ports: @@ -249,7 +249,7 @@ services: MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} VIDEO_STORAGE_BUCKET: ${VIDEO_STORAGE_BUCKET:-videos} METADATA_URL: http://metadata:8002 - INTERNAL_TOKEN: ${INTERNAL_TOKEN:-} + INTERNAL_TOKEN: ${INTERNAL_TOKEN:?INTERNAL_TOKEN is required — set it in .env (see .env.example)} FFMPEG_THREADS: ${FFMPEG_THREADS:-2} FFMPEG_PRESET: ${FFMPEG_PRESET:-veryfast} ENCODE_MODE: ${ENCODE_MODE:-cbr} @@ -277,8 +277,8 @@ services: gateway: build: ../services/gateway environment: - JWT_SECRET: ${JWT_SECRET} - INTERNAL_TOKEN: ${INTERNAL_TOKEN:-} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required — set it in .env (see .env.example)} + INTERNAL_TOKEN: ${INTERNAL_TOKEN:?INTERNAL_TOKEN is required — set it in .env (see .env.example)} UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-5368709120} AUTH_URL: http://auth:8001 METADATA_URL: http://metadata:8002 diff --git a/docs/spec.md b/docs/spec.md index 5d8077d..2c05e73 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -73,6 +73,7 @@ Each service has its own `Dockerfile` and can be developed independently. - **Framework**: FastAPI (or Django + DRF). - **Database**: PostgreSQL (users, tokens). - **Communication**: Exposes `/auth/*` endpoints; other services validate JWT via middleware. +- **JWT**: PyJWT (HS256) + argon2-cffi; токены содержат `exp`, `sub`, `type` (`access`/`refresh`); размер токена ограничен при decode (issue #48, CVE-2024-33663/33664). ### 5. Transcoding Worker (Python) - **Purpose**: Consume `video.uploaded` events, download original from MinIO, run FFmpeg to produce multiple renditions, upload results, publish `video.transcoded` event. diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 410783b..c89e788 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -23,7 +23,7 @@ GATEWAY=${GATEWAY:-http://localhost:8080} VOD=${VOD:-$GATEWAY} EMAIL=${EMAIL:-user@example.com} -PASSWORD=${PASSWORD:-string} +PASSWORD=${PASSWORD:-e2e-password} SAMPLE=${SAMPLE:-} VIDEO_ID=${VIDEO_ID:-} POLL_TIMEOUT=${POLL_TIMEOUT:-600} diff --git a/services/auth/pyproject.toml b/services/auth/pyproject.toml index ced04df..4d988d4 100644 --- a/services/auth/pyproject.toml +++ b/services/auth/pyproject.toml @@ -10,8 +10,8 @@ dependencies = [ "asyncpg>=0.30,<0.31", "alembic>=1.14,<2", "psycopg[binary]>=3.1,<4", - "python-jose[cryptography]>=3.3,<4", - "passlib[argon2]>=1.7,<2", + "pyjwt>=2.10,<3", + "argon2-cffi>=23.1,<26", "pydantic[email]>=2.9,<3", "email-validator>=2.0", "pydantic-settings>=2.6,<3", @@ -54,7 +54,7 @@ ignore_missing_imports = true disable_error_code = ["import-untyped", "import-not-found", "import"] [[tool.mypy.overrides]] -module = ["jose.*", "passlib.*"] +module = ["jwt.*", "argon2.*"] ignore_missing_imports = true [tool.pyright] diff --git a/services/auth/src/core/config.py b/services/auth/src/core/config.py index c641731..4a059c9 100644 --- a/services/auth/src/core/config.py +++ b/services/auth/src/core/config.py @@ -4,7 +4,8 @@ class Settings(BaseSettings): database_url: str = "postgres://flowix:flowix@localhost:5432/flowix?sslmode=disable" # asyncpg needs postgresql+asyncpg scheme - jwt_secret: str = "change-me-super-secret-jwt-key-32chars" + # issue #53: empty secret fail-fasts at startup (main.validate_secrets) unless ENV=dev + jwt_secret: str = "" jwt_access_ttl: str = "15m" jwt_refresh_ttl: str = "168h" minio_endpoint: str = "localhost:9000" diff --git a/services/auth/src/core/security.py b/services/auth/src/core/security.py index 37f068e..1951c8f 100644 --- a/services/auth/src/core/security.py +++ b/services/auth/src/core/security.py @@ -1,12 +1,17 @@ import re from datetime import datetime, timedelta, timezone -from jose import JWTError, jwt # type: ignore[import-untyped] -from passlib.context import CryptContext # type: ignore[import-untyped] +import jwt +from argon2 import PasswordHasher +from argon2.exceptions import Argon2Error from .config import settings -pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") +# Issue #48: python-jose CVE-2024-33664 (JWT bomb DoS in decode) — reject +# oversized tokens before parsing instead of relying on the lib. +MAX_TOKEN_LENGTH = 8192 + +pwd_context = PasswordHasher() ALGO = "HS256" @@ -15,7 +20,10 @@ def hash_password(pw: str) -> str: def verify_password(plain: str, hashed: str) -> bool: - return pwd_context.verify(plain, hashed) + try: + return pwd_context.verify(hashed, plain) + except Argon2Error: + return False def _parse_ttl(s: str) -> timedelta: @@ -46,7 +54,14 @@ def create_refresh_token(sub: str) -> str: def decode_token(token: str) -> dict: + if len(token) > MAX_TOKEN_LENGTH: + raise ValueError("token too large") try: - return jwt.decode(token, settings.jwt_secret, algorithms=[ALGO]) - except JWTError as e: + return jwt.decode( + token, + settings.jwt_secret, + algorithms=[ALGO], + options={"require": ["exp", "sub"]}, + ) + except jwt.PyJWTError as e: raise ValueError(str(e)) diff --git a/services/auth/src/main.py b/services/auth/src/main.py index 48285f1..e7ecec2 100644 --- a/services/auth/src/main.py +++ b/services/auth/src/main.py @@ -1,12 +1,15 @@ import json import logging +import os import sys import time import uuid +from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, Response +from .core.config import settings from .core.limiter import limiter from .routers.auth import router @@ -30,7 +33,30 @@ except ImportError: METRICS_ENABLED = False -app = FastAPI(title="flowix-auth", version="0.1.0") + +def validate_secrets() -> None: + """Fail fast on an empty JWT_SECRET at startup (issue #53). + + Same semantics as requireEnv in the Go services: empty secret → hard fail, + ENV=dev bypasses for local runs. Called from the app lifespan (not at + import) so unit tests with default settings keep working. + """ + if os.getenv("ENV", "") == "dev": + return + if not settings.jwt_secret: + raise RuntimeError( + "JWT_SECRET is empty — set it in .env (see .env.example). " + "Set ENV=dev to bypass for local development." + ) + + +@asynccontextmanager +async def _lifespan(app: FastAPI): # type: ignore[no-untyped-def] + validate_secrets() + yield + + +app = FastAPI(title="flowix-auth", version="0.1.0", lifespan=_lifespan) # CORS handled by gateway (single entry point) to avoid duplicate # Access-Control-Allow-Origin headers (gateway sets origin, upstream must not). # Auth is behind gateway; direct :8001 access is internal/Swagger only. diff --git a/services/auth/src/routers/auth.py b/services/auth/src/routers/auth.py index 40f0cca..a9905c5 100644 --- a/services/auth/src/routers/auth.py +++ b/services/auth/src/routers/auth.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..core.db import get_db @@ -48,7 +49,12 @@ async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)): raise HTTPException(409, "email already exists") user = User(email=body.email, password_hash=hash_password(body.password)) db.add(user) - await db.commit() + try: + await db.commit() + except IntegrityError: + # конкурентная регистрация той же почты (issue #49) + await db.rollback() + raise HTTPException(409, "email already exists") await db.refresh(user) return TokenResponse( access_token=create_access_token(str(user.id)), diff --git a/services/auth/src/schemas.py b/services/auth/src/schemas.py index c7b6e41..1b8ec49 100644 --- a/services/auth/src/schemas.py +++ b/services/auth/src/schemas.py @@ -1,15 +1,32 @@ -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, Field, field_validator + +PASSWORD_MIN_LENGTH = 8 + + +def _normalize_email(v: str) -> str: + # issue #49: users.email UNIQUE is case-sensitive — store and query lowercase + return v.strip().lower() class RegisterRequest(BaseModel): email: EmailStr - password: str + password: str = Field(min_length=PASSWORD_MIN_LENGTH) + + @field_validator("email") + @classmethod + def email_lowercase(cls, v: str) -> str: + return _normalize_email(v) class LoginRequest(BaseModel): email: EmailStr password: str + @field_validator("email") + @classmethod + def email_lowercase(cls, v: str) -> str: + return _normalize_email(v) + class TokenResponse(BaseModel): access_token: str diff --git a/services/auth/tests/conftest.py b/services/auth/tests/conftest.py new file mode 100644 index 0000000..119b5a5 --- /dev/null +++ b/services/auth/tests/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from src.core.config import settings + +TEST_JWT_SECRET = "test-secret-for-auth-unit-tests" + + +@pytest.fixture(autouse=True) +def _jwt_secret(monkeypatch): + # In CI there is no .env, so settings.jwt_secret defaults to "" — + # PyJWT refuses to sign HMAC with an empty key. Give every test a + # non-empty secret; test_validate_secrets_fail_fast overrides it itself. + if not settings.jwt_secret: + monkeypatch.setattr(settings, "jwt_secret", TEST_JWT_SECRET) + yield diff --git a/services/auth/tests/test_auth.py b/services/auth/tests/test_auth.py index cb3944e..34858b5 100644 --- a/services/auth/tests/test_auth.py +++ b/services/auth/tests/test_auth.py @@ -80,6 +80,55 @@ def test_register_duplicate(): assert r.status_code == 409 +def test_register_short_password_rejected(): + # issue #49: password policy — min 8 chars + mock_db = AsyncMock() + mock_db.execute = AsyncMock(return_value=FakeResult(None)) + c = client_with_mock(mock_db) + r = c.post("/api/v1/auth/register", json={"email": "short@example.com", "password": "short"}) + clear_overrides() + assert r.status_code == 422 + mock_db.add.assert_not_called() + + +def test_register_email_normalized_lowercase(): + # issue #49: A@x.com == a@x.com — store lowercase + from sqlalchemy.exc import IntegrityError + + mock_db = AsyncMock() + mock_db.execute = AsyncMock(return_value=FakeResult(None)) + mock_db.add = MagicMock() + mock_db.commit = AsyncMock(side_effect=IntegrityError("dup", None, Exception())) + mock_db.rollback = AsyncMock() + c = client_with_mock(mock_db) + r = c.post( + "/api/v1/auth/register", json={"email": "MiXeD@Example.COM", "password": "secret123"} + ) + clear_overrides() + # конкурентный INSERT пойман как IntegrityError → 409, не 500 + assert r.status_code == 409 + added_user = mock_db.add.call_args[0][0] + assert added_user.email == "mixed@example.com" + + +def test_login_email_normalized_lowercase(): + # issue #49: логин с другим регистром находит пользователя (запрос lowercase) + u = fake_user(email="case@example.com", password="secret123") + mock_db = AsyncMock() + captured = {} + + async def _execute(q): + captured["params"] = q.compile().params + return FakeResult(u) + + mock_db.execute = AsyncMock(side_effect=_execute) + c = client_with_mock(mock_db) + r = c.post("/api/v1/auth/login", json={"email": "CASE@Example.com", "password": "secret123"}) + clear_overrides() + assert r.status_code == 200 + assert "case@example.com" in captured["params"].values() + + def test_login_success(): u = fake_user(email="login@example.com", password="secret123") mock_db = AsyncMock() @@ -140,6 +189,47 @@ def test_me_invalid_token(): assert r.status_code == 401 +def test_me_oversized_token_rejected(): + # issue #48: JWT bomb (CVE-2024-33664) — oversized token must 401 before decode + mock_db = AsyncMock() + mock_db.execute = AsyncMock(return_value=FakeResult(None)) + c = client_with_mock(mock_db) + r = c.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {'x' * 16384}"}) + clear_overrides() + assert r.status_code == 401 + + +def test_refresh_token_without_exp_rejected(): + # issue #53: token without exp must not validate + import jwt as pyjwt + + from src.core.config import settings + + token = pyjwt.encode({"sub": str(uuid.uuid4()), "type": "refresh"}, settings.jwt_secret, algorithm="HS256") + c = TestClient(app) + r = c.post("/api/v1/auth/refresh", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 401 + + +def test_validate_secrets_fail_fast(monkeypatch): + # issue #53: empty JWT_SECRET must fail at startup, ENV=dev bypasses. + # Patch settings directly — ambient .env/env must not affect the test. + import pytest + + from src.core.config import settings + from src.main import validate_secrets + + monkeypatch.setattr(settings, "jwt_secret", "") + monkeypatch.delenv("ENV", raising=False) + with pytest.raises(RuntimeError): + validate_secrets() + monkeypatch.setattr(settings, "jwt_secret", "real-secret") + validate_secrets() # non-empty → no exception + monkeypatch.setattr(settings, "jwt_secret", "") + monkeypatch.setenv("ENV", "dev") + validate_secrets() # dev bypass — no exception + + def test_refresh_success(): uid = str(uuid.uuid4()) refresh = create_refresh_token(uid) diff --git a/services/auth/uv.lock b/services/auth/uv.lock index 233edf7..e86de4c 100644 --- a/services/auth/uv.lock +++ b/services/auth/uv.lock @@ -276,56 +276,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "cryptography" -version = "50.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, - { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, - { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, - { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, - { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, - { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, - { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, - { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, - { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, - { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, - { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, - { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, - { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, - { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, - { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, - { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, - { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, - { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, -] - [[package]] name = "deprecated" version = "1.3.1" @@ -347,18 +297,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] -[[package]] -name = "ecdsa" -version = "0.19.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, -] - [[package]] name = "email-validator" version = "2.3.0" @@ -406,16 +344,16 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "email-validator" }, { name = "fastapi" }, { name = "httpx" }, - { name = "passlib", extra = ["argon2"] }, { name = "prometheus-client" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, - { name = "python-jose", extra = ["cryptography"] }, + { name = "pyjwt" }, { name = "python-multipart" }, { name = "redis" }, { name = "slowapi" }, @@ -436,16 +374,16 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.14,<2" }, + { name = "argon2-cffi", specifier = ">=23.1,<26" }, { name = "asyncpg", specifier = ">=0.30,<0.31" }, { name = "email-validator", specifier = ">=2.0" }, { name = "fastapi", specifier = ">=0.115,<0.116" }, { name = "httpx", specifier = ">=0.27,<0.28" }, - { name = "passlib", extras = ["argon2"], specifier = ">=1.7,<2" }, { name = "prometheus-client", specifier = ">=0.21" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1,<4" }, { name = "pydantic", extras = ["email"], specifier = ">=2.9,<3" }, { name = "pydantic-settings", specifier = ">=2.6,<3" }, - { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3,<4" }, + { name = "pyjwt", specifier = ">=2.10,<3" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "redis", specifier = ">=5.0" }, { name = "slowapi", specifier = ">=0.1.9" }, @@ -783,20 +721,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, -] - -[package.optional-dependencies] -argon2 = [ - { name = "argon2-cffi" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -868,15 +792,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] -[[package]] -name = "pyasn1" -version = "0.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, -] - [[package]] name = "pycodestyle" version = "2.14.0" @@ -988,6 +903,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -1025,25 +949,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] -[[package]] -name = "python-jose" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ecdsa" }, - { name = "pyasn1" }, - { name = "rsa" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, -] - -[package.optional-dependencies] -cryptography = [ - { name = "cryptography" }, -] - [[package]] name = "python-multipart" version = "0.0.32" @@ -1107,18 +1012,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "ruff" version = "0.16.4" @@ -1144,15 +1037,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "slowapi" version = "0.1.10" diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index f720972..c343962 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -31,8 +31,8 @@ type routerConfig struct { func main() { port := envOr("GATEWAY_PORT", "8080") - jwtSecret := envOr("JWT_SECRET", "change-me-super-secret-jwt-key-32chars") - internalToken := envOr("INTERNAL_TOKEN", "") + jwtSecret := requireEnv("JWT_SECRET") + internalToken := requireEnv("INTERNAL_TOKEN") uploadMaxBytes := int64(5 << 30) // 5GB default for Phase 9 if v := envOr("UPLOAD_MAX_BYTES", ""); v != "" { if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { @@ -217,3 +217,13 @@ func envOr(k, def string) string { } return def } + +// requireEnv reads a mandatory env var and exits with a clear error when it is +// empty — unless ENV=dev (local `make dev-*` runs without .env, issue #53). +func requireEnv(k string) string { + v := os.Getenv(k) + if v == "" && os.Getenv("ENV") != "dev" { + log.Fatal().Str("env", k).Msg("required environment variable is empty — set it in .env (see .env.example)") + } + return v +} diff --git a/services/gateway/internal/middleware/auth.go b/services/gateway/internal/middleware/auth.go index d3ff224..2a1e99e 100644 --- a/services/gateway/internal/middleware/auth.go +++ b/services/gateway/internal/middleware/auth.go @@ -27,7 +27,7 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler { tokenStr := strings.TrimPrefix(h, "Bearer ") token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { return []byte(secret), nil - }, jwt.WithValidMethods([]string{"HS256"})) + }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) if err != nil || !token.Valid { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return @@ -42,8 +42,8 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return } - // тип токена должен быть access (опционально, если поле есть) - if typ, ok := claims["type"].(string); ok && typ != "" && typ != "access" { + // тип токена обязан быть access (issue #53: обязательные exp и type) + if typ, _ := claims["type"].(string); typ != "access" { http.Error(w, `{"error":"invalid token type"}`, http.StatusUnauthorized) return } @@ -73,14 +73,14 @@ func OptionalAuth(secret string) func(http.Handler) http.Handler { tokenStr := strings.TrimPrefix(h, "Bearer ") token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { return []byte(secret), nil - }, jwt.WithValidMethods([]string{"HS256"})) + }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) if err != nil || !token.Valid { next.ServeHTTP(w, r) return } claims, _ := token.Claims.(jwt.MapClaims) - // refresh-токен не является идентичностью для чтения приватных данных - if typ, ok := claims["type"].(string); ok && typ != "" && typ != "access" { + // refresh-токен и токен без type не являются идентичностью (issue #53) + if typ, _ := claims["type"].(string); typ != "access" { next.ServeHTTP(w, r) return } diff --git a/services/gateway/internal/middleware/auth_test.go b/services/gateway/internal/middleware/auth_test.go index c9e909f..bc22faf 100644 --- a/services/gateway/internal/middleware/auth_test.go +++ b/services/gateway/internal/middleware/auth_test.go @@ -4,16 +4,60 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/golang-jwt/jwt/v5" ) func mustToken(secret, sub string) string { - t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": sub, "type": "access"}) + t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": sub, + "type": "access", + "exp": time.Now().Add(time.Hour).Unix(), + }) s, _ := t.SignedString([]byte(secret)) return s } +// issue #53: token without exp must be rejected by AuthMiddleware. +func TestAuthRejectsTokenWithoutExp(t *testing.T) { + secret := "test-secret" + tok := func() string { + tt := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "u1", "type": "access"}) + s, _ := tt.SignedString([]byte(secret)) + return s + }() + h := AuthMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not call") })) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 401 { + t.Fatalf("want 401 for token without exp got %d", w.Code) + } +} + +// issue #53: token without type claim must be rejected (type=access required). +func TestAuthRejectsTokenWithoutType(t *testing.T) { + secret := "test-secret" + tok := func() string { + tt := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "u1", + "exp": time.Now().Add(time.Hour).Unix(), + }) + s, _ := tt.SignedString([]byte(secret)) + return s + }() + h := AuthMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not call") })) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 401 { + t.Fatalf("want 401 for token without type got %d", w.Code) + } +} + func TestAuthMiddlewareOK(t *testing.T) { secret := "test-secret-32chars-for-unit-tests-!!" called := false diff --git a/services/gateway/internal/middleware/hlsauth.go b/services/gateway/internal/middleware/hlsauth.go index 3c3a32a..142f1f6 100644 --- a/services/gateway/internal/middleware/hlsauth.go +++ b/services/gateway/internal/middleware/hlsauth.go @@ -40,7 +40,7 @@ func GenerateHLSToken(videoID, userID, secret string) (string, error) { func validateHLSToken(tokenStr, expectedVideoID, secret string) (string, bool) { tok, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { return []byte(secret), nil - }, jwt.WithValidMethods([]string{"HS256"})) + }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) if err != nil || !tok.Valid { return "", false } @@ -69,7 +69,7 @@ func validateAccessToken(r *http.Request, secret string) (string, bool) { tokenStr := strings.TrimPrefix(h, "Bearer ") tok, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { return []byte(secret), nil - }, jwt.WithValidMethods([]string{"HS256"})) + }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) if err != nil || !tok.Valid { return "", false } @@ -77,7 +77,8 @@ func validateAccessToken(r *http.Request, secret string) (string, bool) { if !ok { return "", false } - if typ, _ := claims["type"].(string); typ != "" && typ != "access" { + // тип токена обязан быть access (issue #53) + if typ, _ := claims["type"].(string); typ != "access" { return "", false } sub, _ := claims["sub"].(string) diff --git a/services/metadata/cmd/server/main.go b/services/metadata/cmd/server/main.go index 4318496..ed45097 100644 --- a/services/metadata/cmd/server/main.go +++ b/services/metadata/cmd/server/main.go @@ -53,11 +53,8 @@ func main() { dbURL = strings.ReplaceAll(dbURL, "localhost:", "pgbouncer:") dbURL = strings.ReplaceAll(dbURL, "127.0.0.1:", "pgbouncer:") } - jwtSecret := os.Getenv("JWT_SECRET") - if jwtSecret == "" { - jwtSecret = "change-me-super-secret-jwt-key-32chars" - } - internalToken := os.Getenv("INTERNAL_TOKEN") + jwtSecret := requireEnv("JWT_SECRET") + internalToken := requireEnv("INTERNAL_TOKEN") minioEndpoint := os.Getenv("MINIO_ENDPOINT") if minioEndpoint == "" { minioEndpoint = "minio:9000" @@ -215,3 +212,14 @@ func shouldPretty(r *http.Request) bool { } return false } + +// requireEnv reads a mandatory env var and exits with a clear error when it is +// empty — unless ENV=dev (local `make dev-*` runs without .env, issue #53). +// Empty INTERNAL_TOKEN previously left /internal/* metadata open. +func requireEnv(k string) string { + v := os.Getenv(k) + if v == "" && os.Getenv("ENV") != "dev" { + log.Fatalf("required environment variable %s is empty — set it in .env (see .env.example)", k) + } + return v +} diff --git a/services/metadata/internal/handler/video_test.go b/services/metadata/internal/handler/video_test.go index 8519e35..1f854c6 100644 --- a/services/metadata/internal/handler/video_test.go +++ b/services/metadata/internal/handler/video_test.go @@ -19,7 +19,11 @@ import ( const testSecret = "test-secret-32chars-for-unit-tests-!!" func signToken(sub string) string { - t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": sub}) + t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": sub, + "type": "access", + "exp": time.Now().Add(time.Hour).Unix(), + }) s, _ := t.SignedString([]byte(testSecret)) return s } diff --git a/services/metadata/internal/middleware/auth.go b/services/metadata/internal/middleware/auth.go index a7d9fe9..c73229f 100644 --- a/services/metadata/internal/middleware/auth.go +++ b/services/metadata/internal/middleware/auth.go @@ -24,7 +24,7 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler { tokenStr := strings.TrimPrefix(h, "Bearer ") token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { return []byte(secret), nil - }, jwt.WithValidMethods([]string{"HS256"})) + }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) if err != nil || !token.Valid { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return @@ -39,6 +39,11 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return } + // тип токена обязан быть access (issue #53: обязательные exp и type) + if typ, _ := claims["type"].(string); typ != "access" { + http.Error(w, `{"error":"invalid token type"}`, http.StatusUnauthorized) + return + } ctx := context.WithValue(r.Context(), UserIDKey, sub) next.ServeHTTP(w, r.WithContext(ctx)) }) @@ -65,7 +70,7 @@ func OptionalAuth(secret string) func(http.Handler) http.Handler { } token, err := jwt.Parse(strings.TrimPrefix(h, "Bearer "), func(t *jwt.Token) (interface{}, error) { return []byte(secret), nil - }, jwt.WithValidMethods([]string{"HS256"})) + }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) if err != nil || !token.Valid { next.ServeHTTP(w, r) return @@ -75,8 +80,8 @@ func OptionalAuth(secret string) func(http.Handler) http.Handler { next.ServeHTTP(w, r) return } - // refresh-токен не является идентичностью для чтения приватных данных - if typ, ok := claims["type"].(string); ok && typ != "" && typ != "access" { + // refresh-токен и токен без type не являются идентичностью (issue #53) + if typ, _ := claims["type"].(string); typ != "access" { next.ServeHTTP(w, r) return } diff --git a/services/metadata/internal/middleware/auth_test.go b/services/metadata/internal/middleware/auth_test.go index 65d97a7..f9e3fc0 100644 --- a/services/metadata/internal/middleware/auth_test.go +++ b/services/metadata/internal/middleware/auth_test.go @@ -5,16 +5,39 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/golang-jwt/jwt/v5" ) func mustToken(secret, sub string) string { - t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": sub}) + t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": sub, + "type": "access", + "exp": time.Now().Add(time.Hour).Unix(), + }) s, _ := t.SignedString([]byte(secret)) return s } +// issue #53: token without exp or type=access must be rejected. +func TestAuthRejectsTokenWithoutExpOrType(t *testing.T) { + secret := "test-secret" + noExp := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "u1", "type": "access"}) + noType := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "u1", "exp": time.Now().Add(time.Hour).Unix()}) + for name, tok := range map[string]*jwt.Token{"no_exp": noExp, "no_type": noType} { + s, _ := tok.SignedString([]byte(secret)) + h := AuthMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not call") })) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer "+s) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 401 { + t.Fatalf("%s: want 401 got %d", name, w.Code) + } + } +} + func TestAuthMiddlewareOK(t *testing.T) { secret := "test-secret-32chars-for-unit-tests-!!" called := false diff --git a/services/metadata/internal/middleware/internal.go b/services/metadata/internal/middleware/internal.go index f1c1d5d..752f401 100644 --- a/services/metadata/internal/middleware/internal.go +++ b/services/metadata/internal/middleware/internal.go @@ -1,10 +1,14 @@ package middleware -import "net/http" +import ( + "crypto/subtle" + "net/http" +) // InternalAuth protects /internal/* with shared secret header X-Internal-Token. // If secret is empty (dev), it allows all — avoids breaking `make dev-*` without .env. -// In prod secret must be set (see .env.example: INTERNAL_TOKEN). +// Non-dev startup fails fast on an empty token (issue #53), so the bypass is +// dev-only. Comparison is constant-time (issue #53). func InternalAuth(secret string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -12,7 +16,8 @@ func InternalAuth(secret string) func(http.Handler) http.Handler { next.ServeHTTP(w, r) return } - if r.Header.Get("X-Internal-Token") != secret { + got := []byte(r.Header.Get("X-Internal-Token")) + if subtle.ConstantTimeCompare(got, []byte(secret)) != 1 { http.Error(w, `{"error":"unauthorized internal"}`, http.StatusUnauthorized) return } diff --git a/services/metadata/internal/middleware/internal_test.go b/services/metadata/internal/middleware/internal_test.go new file mode 100644 index 0000000..323ecbc --- /dev/null +++ b/services/metadata/internal/middleware/internal_test.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// issue #53: InternalAuth must reject a wrong X-Internal-Token via +// constant-time comparison; a valid token passes, wrong/missing — 401. +func TestInternalAuth(t *testing.T) { + h := InternalAuth("tok-123")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + cases := []struct { + name string + token string + want int + }{ + {"ok", "tok-123", 200}, + {"wrong", "tok-124", 401}, + {"empty", "", 401}, + {"prefix", "tok", 401}, + } + for _, tc := range cases { + req := httptest.NewRequest("PATCH", "/internal/videos/00000000-0000-0000-0000-000000000000/status", nil) + if tc.token != "" { + req.Header.Set("X-Internal-Token", tc.token) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("%s: want %d got %d", tc.name, tc.want, w.Code) + } + } +} diff --git a/services/upload/cmd/server/main.go b/services/upload/cmd/server/main.go index fd4a55d..bdf88e6 100644 --- a/services/upload/cmd/server/main.go +++ b/services/upload/cmd/server/main.go @@ -34,10 +34,7 @@ func main() { if port == "" { port = "8003" } - jwtSecret := os.Getenv("JWT_SECRET") - if jwtSecret == "" { - jwtSecret = "change-me-super-secret-jwt-key-32chars" - } + jwtSecret := requireEnv("JWT_SECRET") minioEndpoint := os.Getenv("MINIO_ENDPOINT") if minioEndpoint == "" { minioEndpoint = "minio:9000" @@ -81,7 +78,7 @@ func main() { } defer pub.Close() - metaCl := client.NewMetadataClient(metadataURL, os.Getenv("INTERNAL_TOKEN")) + metaCl := client.NewMetadataClient(metadataURL, requireEnv("INTERNAL_TOKEN")) uh := handler.NewUploadHandler(store, pub, metaCl) ph := handler.NewPresignHandler(store, pub, metaCl, metaCl) rh := handler.NewResumableHandler(store, metaCl) @@ -114,3 +111,13 @@ func main() { log.Fatal(err) } } + +// requireEnv reads a mandatory env var and exits with a clear error when it is +// empty — unless ENV=dev (local `make dev-*` runs without .env, issue #53). +func requireEnv(k string) string { + v := os.Getenv(k) + if v == "" && os.Getenv("ENV") != "dev" { + log.Fatalf("required environment variable %s is empty — set it in .env (see .env.example)", k) + } + return v +} diff --git a/services/upload/internal/handler/upload_test.go b/services/upload/internal/handler/upload_test.go index 5b0eb64..643aebc 100644 --- a/services/upload/internal/handler/upload_test.go +++ b/services/upload/internal/handler/upload_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" mw "flowix/upload/internal/middleware" @@ -18,7 +19,11 @@ import ( const secret = "test-secret-32chars-for-upload-tests!!" func token(sub string) string { - t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": sub}) + t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": sub, + "type": "access", + "exp": time.Now().Add(time.Hour).Unix(), + }) s, _ := t.SignedString([]byte(secret)) return s } diff --git a/services/upload/internal/middleware/auth.go b/services/upload/internal/middleware/auth.go index 75cd3ed..29ad493 100644 --- a/services/upload/internal/middleware/auth.go +++ b/services/upload/internal/middleware/auth.go @@ -24,7 +24,7 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler { tokenStr := strings.TrimPrefix(h, "Bearer ") token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { return []byte(secret), nil - }, jwt.WithValidMethods([]string{"HS256"})) + }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) if err != nil || !token.Valid { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return @@ -39,6 +39,11 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return } + // тип токена обязан быть access (issue #53: обязательные exp и type) + if typ, _ := claims["type"].(string); typ != "access" { + http.Error(w, `{"error":"invalid token type"}`, http.StatusUnauthorized) + return + } ctx := context.WithValue(r.Context(), UserIDKey, sub) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/services/upload/internal/middleware/auth_test.go b/services/upload/internal/middleware/auth_test.go index 0b5f4ed..fca93f3 100644 --- a/services/upload/internal/middleware/auth_test.go +++ b/services/upload/internal/middleware/auth_test.go @@ -5,16 +5,39 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/golang-jwt/jwt/v5" ) func mustToken(secret, sub string) string { - t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": sub}) + t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": sub, + "type": "access", + "exp": time.Now().Add(time.Hour).Unix(), + }) s, _ := t.SignedString([]byte(secret)) return s } +// issue #53: token without exp or type=access must be rejected. +func TestAuthRejectsTokenWithoutExpOrType(t *testing.T) { + secret := "test-secret" + noExp := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "u1", "type": "access"}) + noType := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "u1", "exp": time.Now().Add(time.Hour).Unix()}) + for name, tok := range map[string]*jwt.Token{"no_exp": noExp, "no_type": noType} { + s, _ := tok.SignedString([]byte(secret)) + h := AuthMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not call") })) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer "+s) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 401 { + t.Fatalf("%s: want 401 got %d", name, w.Code) + } + } +} + func TestAuthOK(t *testing.T) { secret := "test-secret-32chars-for-upload-tests!!" next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {