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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -94,22 +94,23 @@ 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
dev-transcoder-celery: # legacy, Phase 10 — celery deprecated, use dev-transcoder (pika)
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
Expand Down
2 changes: 1 addition & 1 deletion deploy/docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 9 additions & 9 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion scripts/e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
6 changes: 3 additions & 3 deletions services/auth/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion services/auth/src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 21 additions & 6 deletions services/auth/src/core/security.py
Original file line number Diff line number Diff line change
@@ -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"


Expand All @@ -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:
Expand Down Expand Up @@ -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))
28 changes: 27 additions & 1 deletion services/auth/src/main.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion services/auth/src/routers/auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)),
Expand Down
21 changes: 19 additions & 2 deletions services/auth/src/schemas.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 15 additions & 0 deletions services/auth/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading