diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index a80b1e8..10b3bf6 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Optional import duckdb -from fastapi import Depends, HTTPException, Header, status +from fastapi import Depends, HTTPException, Header, Request, status from app.auth.jwt import verify_token from src.db import get_system_db @@ -35,16 +35,26 @@ def _get_db(): async def get_current_user( + request: Request = None, authorization: Optional[str] = Header(None), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ) -> dict: - """Extract and validate JWT from Authorization header. Returns user dict.""" - if not authorization or not authorization.startswith("Bearer "): + """Extract and validate JWT from Authorization header or cookie. Returns user dict.""" + token = None + + # Try Authorization header first + if authorization and authorization.startswith("Bearer "): + token = authorization.removeprefix("Bearer ") + + # Fallback to cookie (for web UI after OAuth redirect) + if not token and request: + token = request.cookies.get("access_token") + + if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid Authorization header", ) - token = authorization.removeprefix("Bearer ") payload = verify_token(token) if not payload: raise HTTPException( diff --git a/app/auth/providers/__init__.py b/app/auth/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/providers/email.py b/app/auth/providers/email.py new file mode 100644 index 0000000..ff112d5 --- /dev/null +++ b/app/auth/providers/email.py @@ -0,0 +1,126 @@ +"""Email magic link auth provider for FastAPI.""" + +import logging +import os +import secrets +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +import duckdb + +from app.auth.jwt import create_access_token +from app.auth.dependencies import _get_db +from src.repositories.users import UserRepository + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth/email", tags=["auth"]) + +MAGIC_LINK_EXPIRY = 3600 # 1 hour + + +class MagicLinkRequest(BaseModel): + email: str + + +class MagicLinkVerify(BaseModel): + email: str + token: str + + +def is_available() -> bool: + return bool(os.environ.get("SMTP_HOST") or os.environ.get("SENDGRID_API_KEY")) + + +@router.post("/send-link") +async def send_magic_link( + request: MagicLinkRequest, + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Send a magic link to the user's email.""" + repo = UserRepository(conn) + user = repo.get_by_email(request.email) + + # Always return success to prevent email enumeration + if not user: + return {"message": "If this email is registered, you will receive a login link."} + + # Generate token + token = secrets.token_urlsafe(32) + repo.update( + id=user["id"], + reset_token=token, + reset_token_created=datetime.now(timezone.utc), + ) + + # Send email (best effort) + try: + _send_email(request.email, token) + except Exception as e: + logger.error(f"Failed to send magic link email: {e}") + + return {"message": "If this email is registered, you will receive a login link."} + + +@router.post("/verify") +async def verify_magic_link( + request: MagicLinkVerify, + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Verify a magic link token and issue JWT.""" + repo = UserRepository(conn) + user = repo.get_by_email(request.email) + if not user: + raise HTTPException(status_code=401, detail="Invalid link") + + if user.get("reset_token") != request.token: + raise HTTPException(status_code=401, detail="Invalid or expired link") + + # Check expiry + created = user.get("reset_token_created") + if created: + if isinstance(created, str): + created = datetime.fromisoformat(created) + if (datetime.now(timezone.utc) - created).total_seconds() > MAGIC_LINK_EXPIRY: + raise HTTPException(status_code=401, detail="Link expired") + + # Clear token (one-time use) + repo.update(id=user["id"], reset_token=None, reset_token_created=None) + + jwt_token = create_access_token(user["id"], user["email"], user["role"]) + return {"access_token": jwt_token, "token_type": "bearer", "email": user["email"], "role": user["role"]} + + +def _send_email(email: str, token: str): + """Send magic link email via SMTP or SendGrid.""" + sendgrid_key = os.environ.get("SENDGRID_API_KEY") + if sendgrid_key: + import sendgrid + from sendgrid.helpers.mail import Mail + sg = sendgrid.SendGridAPIClient(api_key=sendgrid_key) + server_url = os.environ.get("SERVER_URL", "http://localhost:8000") + message = Mail( + from_email=os.environ.get("EMAIL_FROM_ADDRESS", "noreply@example.com"), + to_emails=email, + subject="Login Link", + html_content=f'

Click to login: Login

', + ) + sg.send(message) + return + + smtp_host = os.environ.get("SMTP_HOST") + if smtp_host: + import smtplib + from email.mime.text import MIMEText + server_url = os.environ.get("SERVER_URL", "http://localhost:8000") + msg = MIMEText(f"Login link: {server_url}/auth/email/verify?email={email}&token={token}") + msg["Subject"] = "Login Link" + msg["From"] = os.environ.get("SMTP_FROM", "noreply@example.com") + msg["To"] = email + with smtplib.SMTP(smtp_host, int(os.environ.get("SMTP_PORT", "587"))) as s: + if os.environ.get("SMTP_USE_TLS", "true").lower() == "true": + s.starttls() + smtp_user = os.environ.get("SMTP_USER") + if smtp_user: + s.login(smtp_user, os.environ.get("SMTP_PASSWORD", "")) + s.send_message(msg) diff --git a/app/auth/providers/google.py b/app/auth/providers/google.py new file mode 100644 index 0000000..aeeb8c5 --- /dev/null +++ b/app/auth/providers/google.py @@ -0,0 +1,101 @@ +"""Google OAuth provider for FastAPI.""" + +import os +import logging + +from authlib.integrations.starlette_client import OAuth +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from starlette.config import Config as StarletteConfig + +from app.auth.jwt import create_access_token +from app.instance_config import get_allowed_domains + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth/google", tags=["auth"]) + +oauth = OAuth() + +GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") +GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") + + +def is_available() -> bool: + return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) + + +def _setup_oauth(): + if not is_available(): + return + oauth.register( + name="google", + client_id=GOOGLE_CLIENT_ID, + client_secret=GOOGLE_CLIENT_SECRET, + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={"scope": "openid email profile"}, + ) + + +_setup_oauth() + + +@router.get("/login") +async def google_login(request: Request): + """Redirect to Google OAuth.""" + if not is_available(): + return RedirectResponse(url="/login?error=google_not_configured") + redirect_uri = str(request.url_for("google_callback")) + return await oauth.google.authorize_redirect(request, redirect_uri) + + +@router.get("/callback") +async def google_callback(request: Request): + """Handle Google OAuth callback.""" + if not is_available(): + return RedirectResponse(url="/login?error=google_not_configured") + + try: + token = await oauth.google.authorize_access_token(request) + user_info = token.get("userinfo", {}) + email = user_info.get("email", "") + name = user_info.get("name", "") + + if not email: + return RedirectResponse(url="/login?error=no_email") + + # Domain check + allowed = get_allowed_domains() + if allowed: + domain = email.split("@")[-1] + if domain not in allowed: + return RedirectResponse(url="/login?error=domain_not_allowed") + + # Find or create user + from src.db import get_system_db + from src.repositories.users import UserRepository + import uuid + + conn = get_system_db() + repo = UserRepository(conn) + user = repo.get_by_email(email) + if not user: + user_id = str(uuid.uuid4()) + repo.create(id=user_id, email=email, name=name, role="analyst") + user = repo.get_by_email(email) + conn.close() + + # Issue JWT + jwt_token = create_access_token(user["id"], user["email"], user["role"]) + + # Redirect to dashboard with token in cookie + response = RedirectResponse(url="/dashboard", status_code=302) + response.set_cookie( + key="access_token", value=jwt_token, + httponly=True, max_age=86400 * 30, samesite="lax", + ) + return response + + except Exception as e: + logger.error(f"Google OAuth error: {e}") + return RedirectResponse(url="/login?error=oauth_failed") diff --git a/app/auth/providers/password.py b/app/auth/providers/password.py new file mode 100644 index 0000000..3cf81a6 --- /dev/null +++ b/app/auth/providers/password.py @@ -0,0 +1,81 @@ +"""Password auth provider for FastAPI.""" + +import logging +import os + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +import duckdb + +from app.auth.jwt import create_access_token +from app.auth.dependencies import _get_db +from src.repositories.users import UserRepository + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth/password", tags=["auth"]) + + +class PasswordLoginRequest(BaseModel): + email: str + password: str + + +class PasswordSetupRequest(BaseModel): + email: str + token: str + password: str + + +def is_available() -> bool: + return True # Always available + + +@router.post("/login") +async def password_login( + request: PasswordLoginRequest, + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Login with email + password.""" + repo = UserRepository(conn) + user = repo.get_by_email(request.email) + if not user or not user.get("password_hash"): + raise HTTPException(status_code=401, detail="Invalid email or password") + + # Verify password + try: + from argon2 import PasswordHasher + ph = PasswordHasher() + ph.verify(user["password_hash"], request.password) + except Exception: + raise HTTPException(status_code=401, detail="Invalid email or password") + + token = create_access_token(user["id"], user["email"], user["role"]) + return {"access_token": token, "token_type": "bearer", "email": user["email"], "role": user["role"]} + + +@router.post("/setup") +async def password_setup( + request: PasswordSetupRequest, + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Set initial password using setup token.""" + repo = UserRepository(conn) + user = repo.get_by_email(request.email) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.get("setup_token") != request.token: + raise HTTPException(status_code=400, detail="Invalid setup token") + + # Hash and save password + try: + from argon2 import PasswordHasher + ph = PasswordHasher() + hashed = ph.hash(request.password) + except ImportError: + import hashlib + hashed = hashlib.sha256(request.password.encode()).hexdigest() + + repo.update(id=user["id"], password_hash=hashed, setup_token=None) + token = create_access_token(user["id"], user["email"], user["role"]) + return {"access_token": token, "token_type": "bearer", "message": "Password set successfully"} diff --git a/app/main.py b/app/main.py index c45ce32..025dbcb 100644 --- a/app/main.py +++ b/app/main.py @@ -3,9 +3,12 @@ import logging from pathlib import Path +import os + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware from app.auth.router import router as auth_router from app.api.health import router as health_router @@ -32,6 +35,12 @@ def create_app() -> FastAPI: version="2.0.0", ) + # Session middleware (required for OAuth state) + app.add_middleware( + SessionMiddleware, + secret_key=os.environ.get("JWT_SECRET_KEY", "dev-session-secret"), + ) + # CORS for CLI and external clients app.add_middleware( CORSMiddleware, @@ -54,8 +63,16 @@ def create_app() -> FastAPI: if static_dir.exists(): app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + # Auth providers (conditional registration) + from app.auth.providers.google import router as google_auth_router, is_available as google_available + from app.auth.providers.password import router as password_auth_router + from app.auth.providers.email import router as email_auth_router, is_available as email_available + # API routers app.include_router(auth_router) + app.include_router(google_auth_router) + app.include_router(password_auth_router) + app.include_router(email_auth_router) # Always register, check availability per-request app.include_router(health_router) app.include_router(sync_router) app.include_router(data_router) diff --git a/app/web/router.py b/app/web/router.py index 05cf7a0..bc1684f 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -103,14 +103,37 @@ async def dashboard( total_tables = len(all_states) total_rows = sum(s.get("rows", 0) or 0 for s in all_states) + # Build user_info object expected by dashboard template + class UserInfo: + def __init__(self): + self.exists = True + self.is_admin = user.get("role") == "admin" + self.is_analyst = user.get("role") in ("analyst", "admin", "km_admin") + self.is_privileged = user.get("role") == "admin" + self.username = user.get("email", "").split("@")[0] + self.home_dir = "" + self.groups = [] + ctx = _build_context( request, user=user, + user_info=UserInfo(), + username=user.get("email", "").split("@")[0], total_tables=total_tables, total_rows=total_rows, sync_states=all_states, enabled_datasets=enabled_datasets, datasets=datasets, account_status="active", + account_details=None, + telegram_status={"linked": False}, + setup_instructions="Use 'da login' to connect your CLI tool.", + data_stats={"total_tables": total_tables, "total_rows": total_rows}, + categories=[], + metrics_data=[], + desktop_status={"linked": False}, + activity_summary={"total_sessions": 0, "total_queries": 0}, + knowledge_stats={"total": 0, "approved": 0}, + user_knowledge_stats={"authored": 0, "votes_given": 0}, ) return templates.TemplateResponse(request, "dashboard.html", ctx) diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py new file mode 100644 index 0000000..6ffb2f4 --- /dev/null +++ b/tests/test_auth_providers.py @@ -0,0 +1,119 @@ +"""Tests for auth providers — password, email magic link, google OAuth.""" + +import os +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(tmp_path): + os.environ["DATA_DIR"] = str(tmp_path) + os.environ["JWT_SECRET_KEY"] = "test-secret-32chars-minimum!!!!!" + + from app.main import create_app + from src.db import get_system_db + from src.repositories.users import UserRepository + + conn = get_system_db() + ur = UserRepository(conn) + # User with password + try: + from argon2 import PasswordHasher + ph = PasswordHasher() + pw_hash = ph.hash("testpass123") + except ImportError: + import hashlib + pw_hash = hashlib.sha256(b"testpass123").hexdigest() + + ur.create(id="pw1", email="pw@test.com", name="PW User", role="analyst", password_hash=pw_hash) + # User with setup token + ur.create(id="setup1", email="setup@test.com", name="Setup User", role="analyst") + ur.update(id="setup1", setup_token="setup-token-123") + # User for magic link + ur.create(id="ml1", email="ml@test.com", name="ML User", role="analyst") + conn.close() + + app = create_app() + return TestClient(app) + + +class TestPasswordAuth: + def test_login_success(self, client): + resp = client.post("/auth/password/login", json={ + "email": "pw@test.com", "password": "testpass123", + }) + assert resp.status_code == 200 + assert "access_token" in resp.json() + + def test_login_wrong_password(self, client): + resp = client.post("/auth/password/login", json={ + "email": "pw@test.com", "password": "wrongpass", + }) + assert resp.status_code == 401 + + def test_login_unknown_user(self, client): + resp = client.post("/auth/password/login", json={ + "email": "unknown@test.com", "password": "test", + }) + assert resp.status_code == 401 + + def test_setup_password(self, client): + resp = client.post("/auth/password/setup", json={ + "email": "setup@test.com", "token": "setup-token-123", "password": "newpass456", + }) + assert resp.status_code == 200 + assert "access_token" in resp.json() + + def test_setup_wrong_token(self, client): + resp = client.post("/auth/password/setup", json={ + "email": "setup@test.com", "token": "wrong-token", "password": "newpass", + }) + assert resp.status_code == 400 + + +class TestEmailAuth: + def test_send_link_registered(self, client): + resp = client.post("/auth/email/send-link", json={"email": "ml@test.com"}) + assert resp.status_code == 200 + # Always returns same message (anti-enumeration) + assert "If this email" in resp.json()["message"] + + def test_send_link_unregistered(self, client): + resp = client.post("/auth/email/send-link", json={"email": "nobody@test.com"}) + assert resp.status_code == 200 + assert "If this email" in resp.json()["message"] + + def test_verify_invalid_token(self, client): + resp = client.post("/auth/email/verify", json={ + "email": "ml@test.com", "token": "invalid", + }) + assert resp.status_code == 401 + + +class TestGoogleOAuth: + def test_google_login_not_configured(self, client): + """Without GOOGLE_CLIENT_ID, should redirect to login with error.""" + resp = client.get("/auth/google/login", follow_redirects=False) + assert resp.status_code == 302 or resp.status_code == 307 + assert "error" in resp.headers.get("location", "") + + +class TestCookieAuth: + def test_web_ui_with_cookie(self, client): + """Test that web UI routes accept JWT from cookie.""" + from app.auth.jwt import create_access_token + from src.db import get_system_db + from src.repositories.users import UserRepository + + conn = get_system_db() + ur = UserRepository(conn) + # Use existing user + user = ur.get_by_email("pw@test.com") + conn.close() + + token = create_access_token(user["id"], user["email"], user["role"]) + # Set cookie and access dashboard + client.cookies.set("access_token", token) + resp = client.get("/dashboard") + # Should not be 401 — cookie auth works + assert resp.status_code != 401