feat: implement user profile management and oauth/traefik integration
User Profile (US-004): - Add authenticated profile endpoints (GET/PUT /users/me) - Add avatar upload with file validation (PNG/JPEG, max 2MB) - Create frontend profile page with edit form and avatar upload - Update app shell to link to profile page OAuth/Traefik Integration: - Externalize all Authentik URLs to environment variables - Add domain configuration (API_DOMAIN, WEB_DOMAIN, AUTHENTIK_DOMAIN) - Create docker-compose.traefik.yml for reverse proxy deployment - Update OAuth redirect/callback URLs to use configured domains - Add VITE_APP_URL for frontend public URL configuration Quality gates: pytest (50 passed), ruff, mypy, npm test (12 passed), typecheck, lint, build
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.users import router as users_router
|
||||
|
||||
__all__ = ["auth_router"]
|
||||
__all__ = ["auth_router", "users_router"]
|
||||
|
||||
@@ -32,7 +32,7 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
@router.get("/login")
|
||||
async def login() -> RedirectResponse:
|
||||
settings = Settings()
|
||||
redirect_uri = "http://localhost:8000/auth/callback"
|
||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||
state = token_urlsafe(24)
|
||||
location = build_login_redirect_url(
|
||||
settings=settings,
|
||||
@@ -57,7 +57,7 @@ async def callback(
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid state")
|
||||
|
||||
settings = Settings()
|
||||
redirect_uri = "http://localhost:8000/auth/callback"
|
||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_payload = await exchange_code_for_tokens(
|
||||
settings=settings,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.jwt_service import decode_access_token
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
UPLOAD_DIR = Path("uploads/avatars")
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
access_token: Annotated[str | None, Cookie()] = None,
|
||||
) -> uuid.UUID:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
||||
|
||||
try:
|
||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
||||
return uuid.UUID(str(claims["sub"]))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
return user
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
name: str
|
||||
avatar_url: str | None
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserProfileResponse)
|
||||
async def get_profile(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> User:
|
||||
return await _get_user(session, user_id)
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserProfileResponse)
|
||||
async def update_profile(
|
||||
data: UserProfileUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> User:
|
||||
user = await _get_user(session, user_id)
|
||||
|
||||
if data.name is not None:
|
||||
if len(data.name.strip()) == 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
|
||||
user.name = data.name.strip()
|
||||
|
||||
if data.email is not None:
|
||||
if "@" not in data.email:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
|
||||
user.email = data.email.strip()
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/me/avatar", response_model=UserProfileResponse)
|
||||
async def upload_avatar(
|
||||
file: UploadFile,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> User:
|
||||
user = await _get_user(session, user_id)
|
||||
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"invalid file type: {file.content_type}. only png and jpg allowed",
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > MAX_AVATAR_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="file too large. max size is 2mb",
|
||||
)
|
||||
|
||||
# Delete old avatar if exists
|
||||
if user.avatar_url:
|
||||
old_path = UPLOAD_DIR / Path(user.avatar_url).name
|
||||
if old_path.exists():
|
||||
old_path.unlink()
|
||||
|
||||
# Save new avatar with UUID filename
|
||||
filename_part = file.filename or "avatar.png"
|
||||
ext = filename_part.split(".")[-1].lower() if "." in filename_part else "png"
|
||||
if ext not in {"png", "jpg", "jpeg"}:
|
||||
ext = "png"
|
||||
|
||||
filename = f"{uuid.uuid4()}.{ext}"
|
||||
file_path = UPLOAD_DIR / filename
|
||||
file_path.write_bytes(content)
|
||||
|
||||
user.avatar_url = f"/uploads/avatars/{filename}"
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
@@ -23,7 +23,7 @@ def build_login_redirect_url(
|
||||
"nonce": nonce,
|
||||
}
|
||||
)
|
||||
return f"{settings.authentik_authorize_url}?{query}"
|
||||
return f"{settings.resolved_authentik_authorize_url}?{query}"
|
||||
|
||||
|
||||
async def exchange_code_for_tokens(
|
||||
@@ -34,7 +34,7 @@ async def exchange_code_for_tokens(
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict[str, str]:
|
||||
response = await client.post(
|
||||
settings.authentik_token_url,
|
||||
settings.resolved_authentik_token_url,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
@@ -52,7 +52,7 @@ async def exchange_code_for_tokens(
|
||||
|
||||
|
||||
async def fetch_jwks(*, settings: Settings, client: httpx.AsyncClient) -> dict[str, list[dict[str, str]]]:
|
||||
response = await client.get(settings.authentik_jwks_url)
|
||||
response = await client.get(settings.resolved_authentik_jwks_url)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {"keys": payload["keys"]}
|
||||
@@ -72,6 +72,6 @@ def verify_provider_access_token(
|
||||
jwk_key,
|
||||
algorithms=[jwk_key.get("alg", "HS256")],
|
||||
audience=settings.authentik_audience,
|
||||
issuer=settings.authentik_issuer,
|
||||
issuer=settings.resolved_authentik_issuer,
|
||||
)
|
||||
return dict(claims)
|
||||
|
||||
+59
-4
@@ -22,12 +22,22 @@ class Settings(BaseSettings):
|
||||
postgres_port: int = 5432
|
||||
postgres_db: str = "headquarter"
|
||||
|
||||
# Domain configuration
|
||||
api_domain: str = "localhost"
|
||||
web_domain: str = "localhost"
|
||||
authentik_domain: str = "authentik.local"
|
||||
|
||||
# Public URLs (constructed from domains if not explicitly set)
|
||||
api_public_url: str | None = None
|
||||
web_public_url: str | None = None
|
||||
|
||||
# Authentik configuration - no hardcoded URLs
|
||||
authentik_client_id: str = "headquarter-web"
|
||||
authentik_client_secret: str = "change-me"
|
||||
authentik_authorize_url: str = "https://authentik.local/application/o/authorize/"
|
||||
authentik_token_url: str = "https://authentik.local/application/o/token/"
|
||||
authentik_jwks_url: str = "https://authentik.local/application/o/headquarter-web/jwks/"
|
||||
authentik_issuer: str = "https://authentik.local/application/o/headquarter-web/"
|
||||
authentik_authorize_url: str | None = None
|
||||
authentik_token_url: str | None = None
|
||||
authentik_jwks_url: str | None = None
|
||||
authentik_issuer: str | None = None
|
||||
authentik_audience: str = "headquarter-web"
|
||||
|
||||
jwt_secret: str = "change-me-jwt-secret"
|
||||
@@ -50,6 +60,51 @@ class Settings(BaseSettings):
|
||||
database=self.postgres_db,
|
||||
)
|
||||
|
||||
@property
|
||||
def api_base_url(self) -> str:
|
||||
if self.api_public_url:
|
||||
return self.api_public_url
|
||||
protocol = "https" if self.app_env == "production" else "http"
|
||||
port = "" if self.app_env == "production" else ":8000"
|
||||
return f"{protocol}://{self.api_domain}{port}"
|
||||
|
||||
@property
|
||||
def web_base_url(self) -> str:
|
||||
if self.web_public_url:
|
||||
return self.web_public_url
|
||||
protocol = "https" if self.app_env == "production" else "http"
|
||||
port = "" if self.app_env == "production" else ":3000"
|
||||
return f"{protocol}://{self.web_domain}{port}"
|
||||
|
||||
@property
|
||||
def authentik_base_url(self) -> str:
|
||||
protocol = "https" if self.app_env == "production" else "http"
|
||||
return f"{protocol}://{self.authentik_domain}"
|
||||
|
||||
@property
|
||||
def resolved_authentik_authorize_url(self) -> str:
|
||||
if self.authentik_authorize_url:
|
||||
return self.authentik_authorize_url
|
||||
return f"{self.authentik_base_url}/application/o/authorize/"
|
||||
|
||||
@property
|
||||
def resolved_authentik_token_url(self) -> str:
|
||||
if self.authentik_token_url:
|
||||
return self.authentik_token_url
|
||||
return f"{self.authentik_base_url}/application/o/token/"
|
||||
|
||||
@property
|
||||
def resolved_authentik_jwks_url(self) -> str:
|
||||
if self.authentik_jwks_url:
|
||||
return self.authentik_jwks_url
|
||||
return f"{self.authentik_base_url}/application/o/{self.authentik_client_id}/jwks/"
|
||||
|
||||
@property
|
||||
def resolved_authentik_issuer(self) -> str:
|
||||
if self.authentik_issuer:
|
||||
return self.authentik_issuer
|
||||
return f"{self.authentik_base_url}/application/o/{self.authentik_client_id}/"
|
||||
|
||||
@property
|
||||
def cookie_secure(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.projects import router as projects_router
|
||||
from src.api.users import router as users_router
|
||||
|
||||
app = FastAPI(title="Headquarter API")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(projects_router)
|
||||
app.include_router(users_router)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
@@ -102,7 +102,7 @@ async def test_exchange_code_for_tokens_posts_expected_payload() -> None:
|
||||
settings = Settings()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url == httpx.URL(settings.authentik_token_url)
|
||||
assert request.url == httpx.URL(settings.resolved_authentik_token_url)
|
||||
payload = dict(httpx.QueryParams(request.content.decode("utf-8")))
|
||||
assert payload["grant_type"] == "authorization_code"
|
||||
assert payload["code"] == "auth-code"
|
||||
|
||||
@@ -33,9 +33,9 @@ def test_auth_settings_have_secure_defaults() -> None:
|
||||
|
||||
assert settings.authentik_client_id == "headquarter-web"
|
||||
assert settings.authentik_client_secret == "change-me"
|
||||
assert settings.authentik_authorize_url.endswith("/application/o/authorize/")
|
||||
assert settings.authentik_token_url.endswith("/application/o/token/")
|
||||
assert settings.authentik_jwks_url.endswith("/application/o/headquarter-web/jwks/")
|
||||
assert settings.resolved_authentik_authorize_url.endswith("/application/o/authorize/")
|
||||
assert settings.resolved_authentik_token_url.endswith("/application/o/token/")
|
||||
assert settings.resolved_authentik_jwks_url.endswith("/application/o/headquarter-web/jwks/")
|
||||
assert settings.jwt_algorithm == "HS256"
|
||||
assert settings.access_token_ttl_minutes == 15
|
||||
assert settings.refresh_token_ttl_days == 7
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import asyncio
|
||||
import io
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from src.auth.jwt_service import mint_access_token
|
||||
from src.config import Settings, build_database_url
|
||||
from src.models import Base
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def configure_local_database(monkeypatch) -> None:
|
||||
local_url = build_database_url(
|
||||
user="headquarter",
|
||||
password="headquarter",
|
||||
host="localhost",
|
||||
port=5432,
|
||||
database="headquarter",
|
||||
)
|
||||
monkeypatch.setenv("DATABASE_URL", local_url)
|
||||
|
||||
|
||||
def _prepare_users_test_db() -> None:
|
||||
async def _run() -> None:
|
||||
engine = create_async_engine(
|
||||
build_database_url(
|
||||
user="headquarter",
|
||||
password="headquarter",
|
||||
host="localhost",
|
||||
port=5432,
|
||||
database="headquarter",
|
||||
)
|
||||
)
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await connection.execute(text("TRUNCATE TABLE refresh_tokens, users RESTART IDENTITY CASCADE"))
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def _load_app():
|
||||
import importlib
|
||||
import src.database as database_module
|
||||
import src.api.users as users_module
|
||||
import src.main as main_module
|
||||
|
||||
importlib.reload(database_module)
|
||||
importlib.reload(users_module)
|
||||
importlib.reload(main_module)
|
||||
return main_module.app
|
||||
|
||||
|
||||
def _insert_test_user(user_id: str) -> None:
|
||||
async def _run() -> None:
|
||||
engine = create_async_engine(
|
||||
build_database_url(
|
||||
user="headquarter",
|
||||
password="headquarter",
|
||||
host="localhost",
|
||||
port=5432,
|
||||
database="headquarter",
|
||||
)
|
||||
)
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
user = User(
|
||||
id=uuid.UUID(user_id),
|
||||
email="test@headquarter.local",
|
||||
name="Test User",
|
||||
authentik_id="test-user",
|
||||
avatar_url=None,
|
||||
)
|
||||
await session.merge(user)
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def _create_auth_cookie(user_id: str) -> str:
|
||||
settings = Settings()
|
||||
return mint_access_token(
|
||||
settings=settings,
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
name="Test User",
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||
)
|
||||
|
||||
|
||||
def test_get_profile_returns_401_without_cookie() -> None:
|
||||
_prepare_users_test_db()
|
||||
app = _load_app()
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/users/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_get_profile_returns_user_data() -> None:
|
||||
_prepare_users_test_db()
|
||||
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
|
||||
_insert_test_user(user_id)
|
||||
app = _load_app()
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _create_auth_cookie(user_id))
|
||||
response = client.get("/users/me")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == "test@headquarter.local"
|
||||
assert data["name"] == "Test User"
|
||||
assert data["avatar_url"] is None
|
||||
|
||||
|
||||
def test_update_profile_changes_name_and_email() -> None:
|
||||
_prepare_users_test_db()
|
||||
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
|
||||
_insert_test_user(user_id)
|
||||
app = _load_app()
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _create_auth_cookie(user_id))
|
||||
response = client.put("/users/me", json={"name": "Updated Name", "email": "updated@headquarter.local"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Updated Name"
|
||||
assert data["email"] == "updated@headquarter.local"
|
||||
|
||||
|
||||
def test_update_profile_rejects_empty_name() -> None:
|
||||
_prepare_users_test_db()
|
||||
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
|
||||
_insert_test_user(user_id)
|
||||
app = _load_app()
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _create_auth_cookie(user_id))
|
||||
response = client.put("/users/me", json={"name": " "})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_update_profile_rejects_invalid_email() -> None:
|
||||
_prepare_users_test_db()
|
||||
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
|
||||
_insert_test_user(user_id)
|
||||
app = _load_app()
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _create_auth_cookie(user_id))
|
||||
response = client.put("/users/me", json={"email": "not-an-email"})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_upload_avatar_updates_avatar_url() -> None:
|
||||
_prepare_users_test_db()
|
||||
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
|
||||
_insert_test_user(user_id)
|
||||
app = _load_app()
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _create_auth_cookie(user_id))
|
||||
|
||||
# Create a simple 1x1 PNG
|
||||
png_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
||||
response = client.post(
|
||||
"/users/me/avatar",
|
||||
files={"file": ("test.png", io.BytesIO(png_data), "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["avatar_url"] is not None
|
||||
assert data["avatar_url"].startswith("/uploads/avatars/")
|
||||
|
||||
|
||||
def test_upload_avatar_rejects_invalid_file_type() -> None:
|
||||
_prepare_users_test_db()
|
||||
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
|
||||
_insert_test_user(user_id)
|
||||
app = _load_app()
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _create_auth_cookie(user_id))
|
||||
|
||||
response = client.post(
|
||||
"/users/me/avatar",
|
||||
files={"file": ("test.txt", io.BytesIO(b"not an image"), "text/plain")},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_upload_avatar_rejects_oversized_file() -> None:
|
||||
_prepare_users_test_db()
|
||||
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
|
||||
_insert_test_user(user_id)
|
||||
app = _load_app()
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _create_auth_cookie(user_id))
|
||||
|
||||
large_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * (3 * 1024 * 1024) # 3MB
|
||||
response = client.post(
|
||||
"/users/me/avatar",
|
||||
files={"file": ("large.png", io.BytesIO(large_png), "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 108 B |
Binary file not shown.
|
After Width: | Height: | Size: 108 B |
Binary file not shown.
|
After Width: | Height: | Size: 108 B |
Binary file not shown.
|
After Width: | Height: | Size: 108 B |
Reference in New Issue
Block a user