feat: add auth providers (Google OAuth, Password, Email magic link) + web UI fixes

- Google OAuth with authlib + auto user creation + cookie-based JWT
- Password auth with argon2 hash + setup token flow
- Email magic link with SMTP/SendGrid support
- Cookie-based auth for web UI (after OAuth redirect)
- Dashboard template compatibility (user_info, activity, desktop status)
- 150 tests passing
This commit is contained in:
ZdenekSrotyr 2026-03-27 17:07:59 +01:00
parent fb1e60d8e1
commit 1a7939c594
8 changed files with 481 additions and 4 deletions

View file

@ -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(

View file

126
app/auth/providers/email.py Normal file
View file

@ -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'<p>Click to login: <a href="{server_url}/auth/email/verify?email={email}&token={token}">Login</a></p>',
)
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)

View file

@ -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")

View file

@ -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"}

View file

@ -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)

View file

@ -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)

View file

@ -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