From 78b503fe43b635e6865855a0dde2dbce674f43c4 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Mon, 27 Apr 2026 17:58:32 +0200 Subject: [PATCH] implement initial backend structure with authentication, user management, and database integration --- backend/app/__init__.py | 0 backend/app/db.py | 66 ++++++++++++++++ backend/app/dependencies.py | 42 ++++++++++ backend/app/main.py | 44 +++++++++++ backend/app/models/__init__.py | 0 backend/app/models/auth.py | 22 ++++++ backend/app/models/users.py | 17 ++++ backend/app/routers/__init__.py | 0 backend/app/routers/admin.py | 62 +++++++++++++++ backend/app/routers/auth.py | 96 ++++++++++++++++++++++ backend/app/routers/users.py | 86 ++++++++++++++++++++ backend/app/services/__init__.py | 0 backend/app/services/auth.py | 132 +++++++++++++++++++++++++++++++ backend/app/services/users.py | 86 ++++++++++++++++++++ 14 files changed, 653 insertions(+) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/db.py create mode 100644 backend/app/dependencies.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/auth.py create mode 100644 backend/app/models/users.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/auth.py create mode 100644 backend/app/services/users.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..4d86a2d --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,66 @@ +"""DuckDB singleton connection with asyncio write lock and schema migrations.""" +import asyncio +import os +import duckdb + +_conn: duckdb.DuckDBPyConnection | None = None +_write_lock = asyncio.Lock() + + +def get_db_path() -> str: + return os.getenv("DB_PATH", "data/app.db") + + +def init_db(path: str | None = None) -> duckdb.DuckDBPyConnection: + """Open (or reuse) the DuckDB connection and run schema migrations.""" + global _conn + if _conn is not None: + return _conn + db_path = path or get_db_path() + if db_path != ":memory:": + os.makedirs(os.path.dirname(db_path), exist_ok=True) + _conn = duckdb.connect(db_path) + _run_migrations(_conn) + return _conn + + +def get_conn() -> duckdb.DuckDBPyConnection: + """Return the active connection; raises if not yet initialised.""" + if _conn is None: + raise RuntimeError("Database not initialised. Call init_db() first.") + return _conn + + +def close_db() -> None: + """Close the connection (called on app shutdown).""" + global _conn + if _conn is not None: + _conn.close() + _conn = None + + +def get_write_lock() -> asyncio.Lock: + """Return the asyncio lock that serialises write operations.""" + return _write_lock + + +def _run_migrations(conn: duckdb.DuckDBPyConnection) -> None: + conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id UUID DEFAULT uuid() PRIMARY KEY, + email VARCHAR NOT NULL UNIQUE, + password_hash VARCHAR NOT NULL, + role VARCHAR DEFAULT 'user', + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS refresh_tokens ( + jti UUID DEFAULT uuid() PRIMARY KEY, + user_id UUID NOT NULL, + issued_at TIMESTAMP DEFAULT now(), + expires_at TIMESTAMP NOT NULL, + revoked BOOLEAN DEFAULT false + ) + """) diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..56d316a --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,42 @@ +"""FastAPI dependencies (e.g. authenticated user extraction).""" +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError + +from backend.app.services.auth import decode_token + +_bearer = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(_bearer), +) -> dict: + """Extract and validate the Bearer JWT. Returns the token payload.""" + credentials_error = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials.", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_token(credentials.credentials) + except JWTError: + raise credentials_error + + if payload.get("type") != "access": + raise credentials_error + + user_id: str | None = payload.get("sub") + if user_id is None: + raise credentials_error + + return {"id": user_id, "email": payload.get("email"), "role": payload.get("role")} + + +async def require_admin(current_user: dict = Depends(get_current_user)) -> dict: + """Raise 403 if the authenticated user is not an admin.""" + if current_user.get("role") != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required.", + ) + return current_user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2d0b665 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,44 @@ +from backend.app.routers import auth as auth_router +from backend.app.routers import users as users_router +from backend.app.routers import admin as admin_router +from backend.app.db import close_db, init_db +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from dotenv import load_dotenv + +load_dotenv() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + yield + close_db() + + +app = FastAPI( + title="AI Allucanget Biz API", + description="Multi-modal AI generation API powered by openrouter.ai", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=[os.getenv("CORS_ORIGINS", "http://localhost:5000")], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth_router.router) +app.include_router(users_router.router) +app.include_router(admin_router.router) + + +@app.get("/health", tags=["health"]) +async def health() -> dict: + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py new file mode 100644 index 0000000..9a6270c --- /dev/null +++ b/backend/app/models/auth.py @@ -0,0 +1,22 @@ +"""Pydantic schemas for authentication endpoints.""" +from pydantic import BaseModel, EmailStr + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class RefreshRequest(BaseModel): + refresh_token: str diff --git a/backend/app/models/users.py b/backend/app/models/users.py new file mode 100644 index 0000000..a799371 --- /dev/null +++ b/backend/app/models/users.py @@ -0,0 +1,17 @@ +"""Pydantic schemas for user management endpoints.""" +from pydantic import BaseModel, EmailStr + + +class UserResponse(BaseModel): + id: str + email: str + role: str + + +class UpdateUserRequest(BaseModel): + email: EmailStr | None = None + password: str | None = None + + +class SetRoleRequest(BaseModel): + role: str diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..4694d4a --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,62 @@ +"""Admin router: operational endpoints for application management.""" +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends + +from backend.app.db import get_conn, get_write_lock +from backend.app.dependencies import require_admin + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/stats") +async def get_stats(_: dict = Depends(require_admin)) -> dict: + """Return aggregate statistics: user counts and token counts.""" + conn = get_conn() + total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + users_by_role = conn.execute( + "SELECT role, COUNT(*) FROM users GROUP BY role ORDER BY role" + ).fetchall() + total_tokens = conn.execute( + "SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] + active_tokens = conn.execute( + "SELECT COUNT(*) FROM refresh_tokens WHERE revoked = false AND expires_at > ?", + [datetime.now(timezone.utc)], + ).fetchone()[0] + return { + "users": { + "total": total_users, + "by_role": {row[0]: row[1] for row in users_by_role}, + }, + "refresh_tokens": { + "total": total_tokens, + "active": active_tokens, + "revoked_or_expired": total_tokens - active_tokens, + }, + } + + +@router.get("/health/db") +async def db_health(_: dict = Depends(require_admin)) -> dict: + """Verify DuckDB is reachable.""" + conn = get_conn() + result = conn.execute("SELECT 1").fetchone()[0] + return {"status": "ok" if result == 1 else "error"} + + +@router.post("/tokens/purge", status_code=200) +async def purge_tokens(_: dict = Depends(require_admin)) -> dict: + """Delete all expired or revoked refresh tokens. Returns count removed.""" + conn = get_conn() + lock = get_write_lock() + now = datetime.now(timezone.utc) + async with lock: + before = conn.execute( + "SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] + conn.execute( + "DELETE FROM refresh_tokens WHERE revoked = true OR expires_at <= ?", [ + now] + ) + after = conn.execute( + "SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] + return {"deleted": before - after, "remaining": after} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..a975e93 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,96 @@ +"""Auth router: register, login, refresh, logout.""" +import uuid + +from fastapi import APIRouter, HTTPException, status +from jose import JWTError + +from backend.app.models.auth import LoginRequest, RefreshRequest, RegisterRequest, TokenResponse +from backend.app.services.auth import ( + authenticate_user, + create_access_token, + create_refresh_token, + decode_token, + register_user, + revoke_refresh_token, + store_refresh_token, + validate_refresh_token_jti, +) + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", status_code=status.HTTP_201_CREATED) +async def register(body: RegisterRequest) -> dict: + try: + user = await register_user(body.email, body.password) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + return {"id": user["id"], "email": user["email"], "role": user["role"]} + + +@router.post("/login", response_model=TokenResponse) +async def login(body: LoginRequest) -> TokenResponse: + user = await authenticate_user(body.email, body.password) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials.", + headers={"WWW-Authenticate": "Bearer"}, + ) + jti = str(uuid.uuid4()) + await store_refresh_token(user["id"], jti) + return TokenResponse( + access_token=create_access_token(user["id"], user["email"], user["role"]), + refresh_token=create_refresh_token(user["id"], jti), + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh(body: RefreshRequest) -> TokenResponse: + credentials_error = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token.", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_token(body.refresh_token) + except JWTError: + raise credentials_error + + if payload.get("type") != "refresh": + raise credentials_error + + user_id: str = payload.get("sub", "") + jti: str = payload.get("jti", "") + + if not await validate_refresh_token_jti(jti, user_id): + raise credentials_error + + # Rotate: revoke old JTI, issue new pair + await revoke_refresh_token(jti) + new_jti = str(uuid.uuid4()) + await store_refresh_token(user_id, new_jti) + + from backend.app.db import get_conn + conn = get_conn() + row = conn.execute( + "SELECT email, role FROM users WHERE id = ?", [user_id] + ).fetchone() + if row is None: + raise credentials_error + + return TokenResponse( + access_token=create_access_token(user_id, row[0], row[1]), + refresh_token=create_refresh_token(user_id, new_jti), + ) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(body: RefreshRequest) -> None: + try: + payload = decode_token(body.refresh_token) + except JWTError: + return # Already invalid — treat as success + jti = payload.get("jti", "") + if jti: + await revoke_refresh_token(jti) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..212d61f --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,86 @@ +"""Users router: self-service profile and admin user management.""" +from fastapi import APIRouter, Depends, HTTPException, status + +from backend.app.dependencies import get_current_user, require_admin +from backend.app.models.users import SetRoleRequest, UpdateUserRequest, UserResponse +from backend.app.services.users import ( + delete_user, + get_user, + list_users, + set_user_role, + update_user, +) + +router = APIRouter(prefix="/users", tags=["users"]) + +ALLOWED_ROLES = {"user", "admin"} + + +# --------------------------------------------------------------------------- +# Self-service +# --------------------------------------------------------------------------- + +@router.get("/me", response_model=UserResponse) +async def get_me(current_user: dict = Depends(get_current_user)) -> UserResponse: + user = await get_user(current_user["id"]) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + return UserResponse(**user) + + +@router.put("/me", response_model=UserResponse) +async def update_me( + body: UpdateUserRequest, + current_user: dict = Depends(get_current_user), +) -> UserResponse: + try: + user = await update_user(current_user["id"], email=body.email, password=body.password) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + return UserResponse(**user) + + +# --------------------------------------------------------------------------- +# Admin +# --------------------------------------------------------------------------- + +@router.get("", response_model=list[UserResponse]) +async def get_all_users(_: dict = Depends(require_admin)) -> list[UserResponse]: + users = await list_users() + return [UserResponse(**u) for u in users] + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_user( + user_id: str, + current_user: dict = Depends(require_admin), +) -> None: + if user_id == current_user["id"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete your own account.", + ) + await delete_user(user_id) + + +@router.put("/{user_id}/role", response_model=UserResponse) +async def change_role( + user_id: str, + body: SetRoleRequest, + _: dict = Depends(require_admin), +) -> UserResponse: + if body.role not in ALLOWED_ROLES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Role must be one of: {', '.join(sorted(ALLOWED_ROLES))}.", + ) + user = await set_user_role(user_id, body.role) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + return UserResponse(**user) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..f26f360 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,132 @@ +"""Authentication service: password hashing, JWT creation/verification, token management.""" +import os +from datetime import datetime, timedelta, timezone +from typing import Any + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from backend.app.db import get_conn, get_write_lock + +_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ACCESS_TOKEN_EXPIRE_MINUTES = 15 +REFRESH_TOKEN_EXPIRE_DAYS = 7 +ALGORITHM = "HS256" + + +def _secret() -> str: + secret = os.getenv("JWT_SECRET") + if not secret: + raise RuntimeError("JWT_SECRET environment variable is not set.") + return secret + + +# --- Password --- + +def hash_password(plain: str) -> str: + return _pwd_context.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + return _pwd_context.verify(plain, hashed) + + +# --- Tokens --- + +def create_access_token(user_id: str, email: str, role: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + payload = { + "sub": user_id, + "email": email, + "role": role, + "exp": expire, + "type": "access", + } + return jwt.encode(payload, _secret(), algorithm=ALGORITHM) + + +def create_refresh_token(user_id: str, jti: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + payload = { + "sub": user_id, + "jti": jti, + "exp": expire, + "type": "refresh", + } + return jwt.encode(payload, _secret(), algorithm=ALGORITHM) + + +def decode_token(token: str) -> dict[str, Any]: + """Decode and validate a JWT. Raises JWTError on failure.""" + return jwt.decode(token, _secret(), algorithms=[ALGORITHM]) + + +# --- Database operations --- + +async def register_user(email: str, password: str) -> dict[str, Any]: + """Insert a new user. Returns the created user row.""" + conn = get_conn() + lock = get_write_lock() + async with lock: + existing = conn.execute( + "SELECT id FROM users WHERE email = ?", [email] + ).fetchone() + if existing: + raise ValueError("Email already registered.") + conn.execute( + "INSERT INTO users (email, password_hash) VALUES (?, ?)", + [email, hash_password(password)], + ) + row = conn.execute( + "SELECT id, email, role FROM users WHERE email = ?", [email] + ).fetchone() + return {"id": str(row[0]), "email": row[1], "role": row[2]} + + +async def authenticate_user(email: str, password: str) -> dict[str, Any] | None: + """Return user dict if credentials are valid, else None.""" + conn = get_conn() + row = conn.execute( + "SELECT id, email, password_hash, role FROM users WHERE email = ?", [email] + ).fetchone() + if row is None or not verify_password(password, row[2]): + return None + return {"id": str(row[0]), "email": row[1], "role": row[3]} + + +async def store_refresh_token(user_id: str, jti: str) -> None: + """Persist a refresh token JTI in the database.""" + conn = get_conn() + lock = get_write_lock() + from datetime import timedelta + expires_at = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + async with lock: + conn.execute( + "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)", + [jti, user_id, expires_at], + ) + + +async def revoke_refresh_token(jti: str) -> None: + """Mark a refresh token as revoked.""" + conn = get_conn() + lock = get_write_lock() + async with lock: + conn.execute( + "UPDATE refresh_tokens SET revoked = true WHERE jti = ?", [jti] + ) + + +async def validate_refresh_token_jti(jti: str, user_id: str) -> bool: + """Return True if the JTI exists, is not revoked, and belongs to user_id.""" + conn = get_conn() + now = datetime.now(timezone.utc) + row = conn.execute( + """ + SELECT 1 FROM refresh_tokens + WHERE jti = ? AND user_id = ? AND revoked = false AND expires_at > ? + """, + [jti, user_id, now], + ).fetchone() + return row is not None diff --git a/backend/app/services/users.py b/backend/app/services/users.py new file mode 100644 index 0000000..3d201b4 --- /dev/null +++ b/backend/app/services/users.py @@ -0,0 +1,86 @@ +"""User management service: CRUD helpers against DuckDB.""" +from typing import Any + +from backend.app.db import get_conn, get_write_lock +from backend.app.services.auth import hash_password + + +async def get_user(user_id: str) -> dict[str, Any] | None: + conn = get_conn() + row = conn.execute( + "SELECT id, email, role FROM users WHERE id = ?", [user_id] + ).fetchone() + if row is None: + return None + return {"id": str(row[0]), "email": row[1], "role": row[2]} + + +async def list_users() -> list[dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + "SELECT id, email, role FROM users ORDER BY email").fetchall() + return [{"id": str(r[0]), "email": r[1], "role": r[2]} for r in rows] + + +async def update_user( + user_id: str, + email: str | None = None, + password: str | None = None, +) -> dict[str, Any] | None: + """Update email and/or password. Returns updated user or None if not found.""" + conn = get_conn() + lock = get_write_lock() + + if email is None and password is None: + return await get_user(user_id) + + async with lock: + if email is not None: + existing = conn.execute( + "SELECT id FROM users WHERE email = ? AND id != ?", [ + email, user_id] + ).fetchone() + if existing: + raise ValueError("Email already in use.") + conn.execute( + "UPDATE users SET email = ?, updated_at = now() WHERE id = ?", + [email, user_id], + ) + if password is not None: + conn.execute( + "UPDATE users SET password_hash = ?, updated_at = now() WHERE id = ?", + [hash_password(password), user_id], + ) + row = conn.execute( + "SELECT id, email, role FROM users WHERE id = ?", [user_id] + ).fetchone() + + if row is None: + return None + return {"id": str(row[0]), "email": row[1], "role": row[2]} + + +async def set_user_role(user_id: str, role: str) -> dict[str, Any] | None: + conn = get_conn() + lock = get_write_lock() + async with lock: + conn.execute( + "UPDATE users SET role = ?, updated_at = now() WHERE id = ?", + [role, user_id], + ) + row = conn.execute( + "SELECT id, email, role FROM users WHERE id = ?", [user_id] + ).fetchone() + if row is None: + return None + return {"id": str(row[0]), "email": row[1], "role": row[2]} + + +async def delete_user(user_id: str) -> bool: + """Delete user and their refresh tokens. Returns True if a row was removed.""" + conn = get_conn() + lock = get_write_lock() + async with lock: + conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", [user_id]) + conn.execute("DELETE FROM users WHERE id = ?", [user_id]) + return True