diff --git a/backend/app.py b/backend/app.py index 4d031f8..251d6e7 100644 --- a/backend/app.py +++ b/backend/app.py @@ -12,6 +12,7 @@ from uvicorn.logging import DefaultFormatter from backend.core.config import Settings from backend.core.database import close_engine, close_valkey, init_db from backend.core.dependencies import get_agent_service +from backend.routes.auth import router as auth_router from backend.routes.chat import router as chat_router from backend.routes.health import router as health_router from backend.routes.users import router as users_router @@ -63,6 +64,7 @@ class AppBuilder: allow_headers=["*"], ) + app.include_router(auth_router) app.include_router(health_router) app.include_router(chat_router) app.include_router(users_router) diff --git a/backend/core/database.py b/backend/core/database.py index 73280dc..24a2e26 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -36,7 +36,7 @@ from datetime import datetime, timezone from typing import AsyncIterator from redis.asyncio import Redis -from sqlalchemy import select +from sqlalchemy import select, text from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, @@ -63,10 +63,21 @@ _session_factory = async_sessionmaker( async def init_db() -> None: - """Create all tables if they don't exist (call on startup).""" + """Create all tables if they don't exist, then apply missing-column migrations. + + Safe to call repeatedly — uses ``IF NOT EXISTS`` for columns and tables. + """ async with _engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + # ── Migrations: add columns that may be missing on existing tables ── + migrations = [ + "ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash VARCHAR(128)", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS team VARCHAR(100)", + ] + for stmt in migrations: + await conn.execute(text(stmt)) + async def get_session() -> AsyncIterator[AsyncSession]: """Yield an async SQLAlchemy session (for FastAPI dependency injection).""" @@ -222,6 +233,104 @@ async def update_user( return user +# ── Authentication (Valkey tokens + bcrypt) ─────────────────────────────── + +_AUTH_TOKEN_KEY = "auth_token:{token}" +_AUTH_TOKEN_TTL = 86400 * 7 # 7 days + + +async def create_user_with_password( + username: str, + display_name: str, + password: str, + role: str = "user", + team: str | None = None, + session: AsyncSession | None = None, +) -> User: + """Create a new user with a hashed password.""" + if session is None: + async with _session_factory() as session: + user = User( + username=username, + display_name=display_name, + role=role, + team=team, + ) + user.set_password(password) + session.add(user) + await session.commit() + await session.refresh(user) + return user + else: + user = User( + username=username, + display_name=display_name, + role=role, + team=team, + ) + user.set_password(password) + session.add(user) + await session.flush() + await session.refresh(user) + return user + + +async def authenticate_user( + username: str, + password: str, + session: AsyncSession | None = None, +) -> User | None: + """Verify username/password. Returns the User on success, ``None`` on failure.""" + user = await get_user_by_username(username, session=session) + if user is None or not user.is_active: + return None + if user.check_password(password): + return user + return None + + +async def create_auth_token( + user_id: str, + valkey: Redis | None = None, +) -> str: + """Create an auth token for a user, stored in Valkey with TTL. + + Returns the token string. + """ + if valkey is None: + valkey = await get_valkey() + + token = uuid.uuid4().hex + key = _AUTH_TOKEN_KEY.format(token=token) + await valkey.setex(key, _AUTH_TOKEN_TTL, user_id) + return token + + +async def resolve_auth_token( + token: str, + valkey: Redis | None = None, +) -> str | None: + """Resolve an auth token to a user_id string, or ``None`` if invalid/expired.""" + if valkey is None: + valkey = await get_valkey() + + key = _AUTH_TOKEN_KEY.format(token=token) + user_id = await valkey.get(key) + return user_id + + +async def revoke_auth_token( + token: str, + valkey: Redis | None = None, +) -> None: + """Delete an auth token (logout).""" + if valkey is None: + valkey = await get_valkey() + + key = _AUTH_TOKEN_KEY.format(token=token) + await valkey.delete(key) + + # ── Chat persistence (Valkey) ───────────────────────────────────────────── _MESSAGES_KEY = "chat:{session_id}:messages" diff --git a/backend/core/models.py b/backend/core/models.py index 49736c2..2238abc 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -5,6 +5,7 @@ from __future__ import annotations import uuid from datetime import datetime, timezone +import bcrypt from sqlalchemy import Boolean, DateTime, String from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -15,9 +16,15 @@ class Base(DeclarativeBase): class User(Base): - """Application user with role-based access control. + """Application user with role-based access control and password auth. Roles: ``admin``, ``user``, ``viewer`` + + Usage:: + + user = User(username="alice", display_name="Alice") + user.set_password("secret123") + assert user.check_password("secret123") is True """ __tablename__ = "users" @@ -31,6 +38,9 @@ class User(Base): String(100), unique=True, nullable=False ) display_name: Mapped[str] = mapped_column(String(200), nullable=False) + password_hash: Mapped[str | None] = mapped_column( + String(128), nullable=True, default=None + ) role: Mapped[str] = mapped_column( String(20), nullable=False, default="user" ) @@ -52,5 +62,20 @@ class User(Base): onupdate=lambda: datetime.now(timezone.utc), ) + def set_password(self, password: str) -> None: + """Hash and store the password using bcrypt.""" + self.password_hash = bcrypt.hashpw( + password.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + + def check_password(self, password: str) -> bool: + """Verify a password against the stored hash.""" + if self.password_hash is None: + return False + return bcrypt.checkpw( + password.encode("utf-8"), + self.password_hash.encode("utf-8"), + ) + def __repr__(self) -> str: return f"" diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..de065da --- /dev/null +++ b/backend/routes/auth.py @@ -0,0 +1,192 @@ +"""Authentication API routes — login, register, and token management. + +Users authenticate with username + password and receive a bearer token +(stored in Valkey with a 7-day TTL) for subsequent requests. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Header, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.core.database import ( + authenticate_user, + create_auth_token, + create_user_with_password, + get_user_by_username, + resolve_auth_token, + revoke_auth_token, +) +from backend.core.dependencies import get_db_session + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +# ── Schemas ──────────────────────────────────────────────────────────────── + + +class RegisterRequest(BaseModel): + username: str + display_name: str + password: str + role: str = "user" + team: str | None = None + + +class LoginRequest(BaseModel): + username: str + password: str + + +class AuthResponse(BaseModel): + token: str + user_id: str + username: str + display_name: str + role: str + team: str | None = None + + +class MeResponse(BaseModel): + user_id: str + username: str + display_name: str + role: str + team: str | None = None + + +async def _get_token_from_header( + authorization: str | None = Header(None), +) -> str: + """Extract the bearer token from the Authorization header.""" + if authorization is None: + raise HTTPException( + status_code=401, + detail="Missing Authorization header", + ) + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + raise HTTPException( + status_code=401, + detail="Invalid Authorization header. Use: Bearer ", + ) + return token + + +# ── Routes ───────────────────────────────────────────────────────────────── + + +@router.post("/register", response_model=AuthResponse, status_code=201) +async def register_endpoint( + body: RegisterRequest, + session: AsyncSession = Depends(get_db_session), +) -> AuthResponse: + """Register a new user with a password.""" + # Check for duplicate username. + existing = await get_user_by_username(body.username, session=session) + if existing is not None: + raise HTTPException( + status_code=409, + detail=f"User '{body.username}' already exists", + ) + + user = await create_user_with_password( + username=body.username, + display_name=body.display_name, + password=body.password, + role=body.role, + team=body.team, + session=session, + ) + + token = await create_auth_token(str(user.id)) + return AuthResponse( + token=token, + user_id=str(user.id), + username=user.username, + display_name=user.display_name, + role=user.role, + team=user.team, + ) + + +@router.post("/login", response_model=AuthResponse) +async def login_endpoint( + body: LoginRequest, + session: AsyncSession = Depends(get_db_session), +) -> AuthResponse: + """Authenticate with username + password. Returns a bearer token.""" + user = await authenticate_user(body.username, body.password, session=session) + if user is None: + raise HTTPException( + status_code=401, + detail="Invalid username or password", + ) + + token = await create_auth_token(str(user.id)) + return AuthResponse( + token=token, + user_id=str(user.id), + username=user.username, + display_name=user.display_name, + role=user.role, + team=user.team, + ) + + +@router.get("/me", response_model=MeResponse) +async def me_endpoint( + token: str = Depends(_get_token_from_header), +) -> MeResponse: + """Return the current authenticated user's profile. + + Requires ``Authorization: Bearer `` header. + """ + from backend.core.database import get_session as _get_db + from backend.core.database import get_user + + user_id = await resolve_auth_token(token) + if user_id is None: + raise HTTPException( + status_code=401, + detail="Invalid or expired token", + ) + + async with _get_db() as session: + user = await get_user(uuid_obj(user_id), session=session) + + if user is None or not user.is_active: + raise HTTPException( + status_code=401, + detail="User not found or inactive", + ) + + return MeResponse( + user_id=str(user.id), + username=user.username, + display_name=user.display_name, + role=user.role, + team=user.team, + ) + + +@router.post("/logout", status_code=204) +async def logout_endpoint( + token: str = Depends(_get_token_from_header), +) -> None: + """Revoke the current auth token (logout). + + Requires ``Authorization: Bearer `` header. + """ + await revoke_auth_token(token) + + +# ── Helpers ──────────────────────────────────────────────────────────────── + + +def uuid_obj(value: str) -> object: + """Convert a string UUID to a UUID object for DB queries.""" + import uuid as _uuid + + return _uuid.UUID(value) diff --git a/backend/routes/chat.py b/backend/routes/chat.py index 6322921..c87c3c8 100644 --- a/backend/routes/chat.py +++ b/backend/routes/chat.py @@ -8,7 +8,7 @@ from __future__ import annotations import uuid -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, Header, HTTPException, Query from pydantic import BaseModel from backend.core.agent import AgentService @@ -16,6 +16,7 @@ from backend.core.database import ( get_session_messages, get_session_user_id, list_sessions, + resolve_auth_token, save_message, ) from backend.core.dependencies import get_agent_service @@ -46,6 +47,20 @@ class SessionOut(BaseModel): user_id: str | None = None +async def _resolve_user_id( + user_id: str | None = None, + authorization: str | None = Header(None), +) -> str | None: + """Resolve the effective user_id from explicit param or auth token.""" + if user_id is not None: + return user_id + if authorization is not None: + scheme, _, token = authorization.partition(" ") + if scheme.lower() == "bearer" and token: + return await resolve_auth_token(token) + return None + + # ── POST /api/chat ──────────────────────────────────────────────────────── @@ -53,22 +68,25 @@ class SessionOut(BaseModel): async def chat_endpoint( body: ChatRequest, agent: AgentService = Depends(get_agent_service), + user_id: str | None = Depends(_resolve_user_id), ) -> ChatResponse: """Send a user message to the agent and return its reply. Saves both the user message and the assistant reply to Valkey. Assigns a new UUID ``session_id`` if none was provided. - If ``user_id`` is provided, the session is linked to that user. + The chat session is automatically assigned to the authenticated user + (via ``Authorization: Bearer `` header) or to the explicit + ``user_id`` field in the request body. """ session_id = body.session_id or uuid.uuid4().hex try: - # Persist the user message (linked to user if provided). + # Persist the user message (linked to authenticated user). await save_message( session_id, "user", body.message, - user_id=body.user_id, + user_id=user_id, ) # Ask the agent. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index b12feb2..99a9f16 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -6,7 +6,9 @@ server { index index.html; # ── API proxy ───────────────────────────────────────────────── - # Resolved at container startup (depends_on ensures backend is up). + # Container runtime (Docker/Podman) resolves backend:8000 via + # its built-in DNS. nginx caches the resolved IP at startup, so + # restarting the backend container needs a frontend restart too. location /api/ { proxy_pass http://backend:8000; proxy_set_header Host $host; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f1cddd4..f8bbb16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,11 +2,15 @@ import { useState, useRef, useEffect } from "react"; import { sendMessage, getHistory, - listUsers, - createUser, getUserSessions, + login, + register, + logout, + getAuthToken, + setAuthToken, + clearAuthToken, type MessageData, - type UserData, + type AuthResponse, type UserSessionData, } from "./api"; @@ -20,7 +24,6 @@ interface Message { } const STORAGE_SESSION_KEY = "agent_alpha_session_id"; -const STORAGE_USER_KEY = "agent_alpha_user_id"; const ROLE_BADGES: Record = { admin: "bg-purple-600", @@ -29,7 +32,177 @@ const ROLE_BADGES: Record = { }; /* ------------------------------------------------------------------ */ -/* Component */ +/* Auth Page */ +/* ------------------------------------------------------------------ */ + +function AuthPage({ onAuth }: { onAuth: (user: AuthResponse) => void }) { + const [mode, setMode] = useState<"login" | "register">("login"); + const [username, setUsername] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [password, setPassword] = useState(""); + const [team, setTeam] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setBusy(true); + + try { + let result: AuthResponse; + if (mode === "login") { + result = await login(username, password); + } else { + result = await register( + username, + displayName || username, + password, + "user", + team || undefined, + ); + } + setAuthToken(result.token); + onAuth(result); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Authentication failed"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+ α +
+
+

Agent Alpha

+

powered by pydantic-ai

+
+
+ +
+

+ {mode === "login" ? "Sign in to your account" : "Create a new account"} +

+ + {error && ( +

+ {error} +

+ )} + +
+ + setUsername(e.target.value)} + className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 outline-none transition focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500" + placeholder="alice" + required + autoFocus + /> +
+ + {mode === "register" && ( + <> +
+ + setDisplayName(e.target.value)} + className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 outline-none transition focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500" + placeholder="Alice" + /> +
+
+ + setTeam(e.target.value)} + className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 outline-none transition focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500" + placeholder="Engineering" + /> +
+ + )} + +
+ + setPassword(e.target.value)} + className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 outline-none transition focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500" + placeholder="••••••••" + required + minLength={4} + /> +
+ + + +

+ {mode === "login" ? ( + <> + Don't have an account?{" "} + + + ) : ( + <> + Already have an account?{" "} + + + )} +

+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Main App */ /* ------------------------------------------------------------------ */ export default function App() { @@ -40,17 +213,11 @@ export default function App() { return localStorage.getItem(STORAGE_SESSION_KEY) ?? ""; }); - /* ── User state ──────────────────────────────────────────────────── */ - const [users, setUsers] = useState([]); - const [currentUser, setCurrentUser] = useState(null); - const [showUserPanel, setShowUserPanel] = useState(false); - const [showCreateUser, setShowCreateUser] = useState(false); - - /* ── Create user form state ──────────────────────────────────────── */ - const [newUsername, setNewUsername] = useState(""); - const [newDisplayName, setNewDisplayName] = useState(""); - const [newRole, setNewRole] = useState("user"); - const [newTeam, setNewTeam] = useState(""); + /* ── Auth state ──────────────────────────────────────────────────── */ + const [authenticated, setAuthenticated] = useState( + null, + ); + const [authReady, setAuthReady] = useState(false); /* ── Session sidebar state ───────────────────────────────────────── */ const [userSessions, setUserSessions] = useState([]); @@ -58,25 +225,29 @@ export default function App() { const bottomRef = useRef(null); - /* Load users on mount and restore selected user */ + /* Restore auth session from stored token */ useEffect(() => { - const storedUserId = localStorage.getItem(STORAGE_USER_KEY); - - listUsers() - .then((allUsers) => { - setUsers(allUsers); - if (storedUserId) { - const found = allUsers.find((u) => u.id === storedUserId); - if (found) setCurrentUser(found); - } - }) - .catch(() => { - // Users table may not exist yet — that's OK. - }); + (async () => { + const token = getAuthToken(); + if (!token) { + setAuthReady(true); + return; + } + try { + const { getMe } = await import("./api"); + const user = await getMe(); + setAuthenticated(user); + } catch { + clearAuthToken(); + } finally { + setAuthReady(true); + } + })(); }, []); /* Restore chat history on mount or session change */ useEffect(() => { + if (!authenticated) return; const sid = localStorage.getItem(STORAGE_SESSION_KEY); if (sid) { getHistory(sid) @@ -112,51 +283,33 @@ export default function App() { }, ]); } - }, []); + }, [authenticated]); /* Auto-scroll on new messages */ useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); - /* ── User handlers ────────────────────────────────────────────────── */ + /* ── Auth handlers ────────────────────────────────────────────────── */ - const handleSelectUser = (user: UserData) => { - setCurrentUser(user); - localStorage.setItem(STORAGE_USER_KEY, user.id); - setShowUserPanel(false); + const handleAuth = (user: AuthResponse) => { + setAuthenticated(user); }; - const handleCreateUser = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newUsername.trim() || !newDisplayName.trim()) return; - - try { - const user = await createUser({ - username: newUsername.trim(), - display_name: newDisplayName.trim(), - role: newRole, - team: newTeam.trim() || null, - }); - setUsers((prev) => [...prev, user]); - setCurrentUser(user); - localStorage.setItem(STORAGE_USER_KEY, user.id); - setNewUsername(""); - setNewDisplayName(""); - setNewRole("user"); - setNewTeam(""); - setShowCreateUser(false); - setShowUserPanel(false); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "Unknown error"; - alert(`Failed to create user: ${msg}`); - } + const handleLogout = async () => { + await logout(); + setAuthenticated(null); + setMessages([]); + setSessionId(""); + localStorage.removeItem(STORAGE_SESSION_KEY); }; + /* ── Session handlers ────────────────────────────────────────────── */ + const handleViewSessions = async () => { - if (!currentUser) return; + if (!authenticated) return; try { - const sessions = await getUserSessions(currentUser.id); + const sessions = await getUserSessions(authenticated.user_id); setUserSessions(sessions); setShowSessions(true); } catch { @@ -169,7 +322,6 @@ export default function App() { localStorage.setItem(STORAGE_SESSION_KEY, sid); setSessionId(sid); setShowSessions(false); - // Reload messages for this session. getHistory(sid) .then((history: MessageData[]) => { if (history.length > 0) { @@ -203,11 +355,8 @@ export default function App() { setLoading(true); try { - const data = await sendMessage( - text, - sessionId || undefined, - currentUser?.id, - ); + // Auth token is sent automatically via api.ts authHeaders() + const data = await sendMessage(text, sessionId || undefined); localStorage.setItem(STORAGE_SESSION_KEY, data.session_id); setSessionId(data.session_id); setMessages((prev) => [ @@ -225,7 +374,23 @@ export default function App() { } }; - /* ── Render ──────────────────────────────────────────────────────── */ + /* ── Loading splash ──────────────────────────────────────────────── */ + + if (!authReady) { + return ( +
+

Loading…

+
+ ); + } + + /* ── Auth gate ──────────────────────────────────────────────────── */ + + if (!authenticated) { + return ; + } + + /* ── Chat UI ─────────────────────────────────────────────────────── */ return (
@@ -242,179 +407,47 @@ export default function App() {
{/* User badge */} -
- - - {/* User dropdown panel */} - {showUserPanel && ( -
-

- Users -

- {users.length === 0 && ( -

- No users yet. Create one below. -

- )} -
- {users.map((user) => ( - - ))} -
- -
- )} +
+ + + {authenticated.display_name} + + + {authenticated.team ? `${authenticated.team} · ` : ""} + {authenticated.role} +
{/* Sessions button */} - {currentUser && ( - - )} + + + {/* Logout button */} + - {/* ---- Create User Modal ---- */} - {showCreateUser && ( -
-
-

- Create User -

- - - setNewUsername(e.target.value)} - className="mb-3 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500" - placeholder="alice" - required - /> - - - setNewDisplayName(e.target.value)} - className="mb-3 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500" - placeholder="Alice" - required - /> - - - setNewTeam(e.target.value)} - className="mb-3 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500" - placeholder="Engineering (optional)" - /> - - - - -
- - -
-
-
- )} - {/* ---- Sessions Sidebar ---- */} {showSessions && (

- Sessions — {currentUser?.display_name} + Sessions — {authenticated.display_name}