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:
parent
fb1e60d8e1
commit
1a7939c594
8 changed files with 481 additions and 4 deletions
|
|
@ -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(
|
||||
|
|
|
|||
0
app/auth/providers/__init__.py
Normal file
0
app/auth/providers/__init__.py
Normal file
126
app/auth/providers/email.py
Normal file
126
app/auth/providers/email.py
Normal 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)
|
||||
101
app/auth/providers/google.py
Normal file
101
app/auth/providers/google.py
Normal 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")
|
||||
81
app/auth/providers/password.py
Normal file
81
app/auth/providers/password.py
Normal 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"}
|
||||
17
app/main.py
17
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
119
tests/test_auth_providers.py
Normal file
119
tests/test_auth_providers.py
Normal 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
|
||||
Loading…
Reference in a new issue