- Add image intake service with format validation (JPEG, PNG, WebP) and EXIF/GPS stripping - Enforce strict single-image rule across Web and Signal attachment channels - Implement token-optimized vision downscaling and JPEG compression - Add IMAGE_CONTEXT pipeline stage with OmniRoute vision routing and resilient failover - Seed and manage versioned idea-image-interpreter prompt in catalog - Update Web UI with responsive image picker, preview chip, and Visual Context tab - Add comprehensive automated test suite in test_image_intake.py - Update README and Labyricorn devlog
158 lines
5.7 KiB
Python
158 lines
5.7 KiB
Python
"""
|
|
ThinkStorm Authentication API Router
|
|
Handles login, logout, current user session status, and Gitea OAuth2 flow.
|
|
"""
|
|
|
|
import json
|
|
import requests
|
|
import urllib.request
|
|
import urllib.parse
|
|
from typing import Optional, Dict, Any
|
|
from fastapi import APIRouter, Request, Response, HTTPException, status, Depends
|
|
from fastapi.responses import RedirectResponse
|
|
from pydantic import BaseModel
|
|
|
|
from ..config import config
|
|
from ..database import get_db, hash_password, get_utc_now
|
|
from ..models import User, UserRole
|
|
from ..auth import (
|
|
authenticate_local, create_session, destroy_session,
|
|
get_current_user, get_or_create_gitea_user
|
|
)
|
|
from ..services.gitea import GiteaAdapter
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
gitea_svc = GiteaAdapter()
|
|
|
|
class LocalLoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
@router.post("/login")
|
|
async def login_local(payload: LocalLoginRequest, response: Response):
|
|
"""Authenticates local user or admin bootstrap."""
|
|
user = authenticate_local(payload.username, payload.password)
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Invalid username or password.")
|
|
|
|
token = create_session(user.id, user.username, user.role.value)
|
|
response.set_cookie(
|
|
key="thinkstorm_session",
|
|
value=token,
|
|
httponly=True,
|
|
samesite="lax",
|
|
max_age=86400 * 7
|
|
)
|
|
return {
|
|
"message": "Login successful.",
|
|
"token": token,
|
|
"user": {
|
|
"id": user.id,
|
|
"username": user.username,
|
|
"role": user.role.value
|
|
}
|
|
}
|
|
|
|
@router.post("/logout")
|
|
async def logout(request: Request, response: Response):
|
|
token = request.cookies.get("thinkstorm_session")
|
|
if token:
|
|
destroy_session(token)
|
|
response.delete_cookie("thinkstorm_session")
|
|
return {"message": "Logged out successfully."}
|
|
|
|
@router.get("/me")
|
|
async def get_me(current_user: User = Depends(get_current_user)):
|
|
return {
|
|
"id": current_user.id,
|
|
"username": current_user.username,
|
|
"role": current_user.role.value,
|
|
"is_authenticated": current_user.role != UserRole.ANONYMOUS,
|
|
"is_admin": current_user.role == UserRole.ADMIN
|
|
}
|
|
|
|
def get_effective_base_url(request: Request) -> str:
|
|
if config.base_url and config.base_url != "http://localhost:8000":
|
|
return config.base_url.rstrip("/")
|
|
proto = request.headers.get("x-forwarded-proto", "http")
|
|
host = request.headers.get("x-forwarded-host") or request.headers.get("host")
|
|
if host:
|
|
return f"{proto}://{host}"
|
|
return str(request.base_url).rstrip("/")
|
|
|
|
@router.get("/gitea/url")
|
|
async def get_gitea_oauth_url(request: Request):
|
|
"""Returns Gitea OAuth authorization redirect URL."""
|
|
base_url = get_effective_base_url(request)
|
|
redirect_uri = f"{base_url}/auth/gitea/callback"
|
|
client_id = config.services.gitea_client_id or "thinkstorm-oauth"
|
|
|
|
auth_url = (
|
|
f"{config.services.gitea_url}/login/oauth/authorize?"
|
|
f"client_id={urllib.parse.quote(client_id)}&"
|
|
f"redirect_uri={urllib.parse.quote(redirect_uri)}&"
|
|
f"response_type=code&state=thinkstorm"
|
|
)
|
|
return {"auth_url": auth_url, "redirect_uri": redirect_uri}
|
|
|
|
@router.get("/gitea/callback")
|
|
async def gitea_oauth_callback(request: Request, code: str = "", error: str = ""):
|
|
"""Exchanges Gitea authorization code for access token and creates user session."""
|
|
if error or not code:
|
|
print(f"[Gitea OAuth] Callback returned error or empty code: {error}")
|
|
return RedirectResponse(url="/login?error=oauth_failed", status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
base_url = get_effective_base_url(request)
|
|
redirect_uri = f"{base_url}/auth/gitea/callback"
|
|
|
|
user_info = None
|
|
client_id = config.services.gitea_client_id
|
|
client_secret = config.services.gitea_client_secret
|
|
|
|
if client_secret and client_id:
|
|
try:
|
|
token_url = f"{config.services.gitea_url}/login/oauth/access_token"
|
|
payload = {
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"code": code,
|
|
"grant_type": "authorization_code",
|
|
"redirect_uri": redirect_uri
|
|
}
|
|
headers = {
|
|
"Accept": "application/json",
|
|
"User-Agent": "ThinkStorm-Orchestrator/0.1"
|
|
}
|
|
resp = requests.post(token_url, data=payload, headers=headers, timeout=10.0)
|
|
if resp.status_code == 200:
|
|
try:
|
|
token_data = resp.json()
|
|
except Exception:
|
|
token_data = urllib.parse.parse_qs(resp.text)
|
|
token_data = {k: v[0] for k, v in token_data.items()}
|
|
|
|
access_token = token_data.get("access_token")
|
|
if access_token:
|
|
user_info = await gitea_svc.verify_oauth_user(access_token)
|
|
else:
|
|
print(f"[Gitea OAuth] Token exchange returned status {resp.status_code}: {resp.text[:150]}")
|
|
except Exception as e:
|
|
print(f"[Gitea OAuth] Token exchange error: {e}")
|
|
|
|
if not user_info:
|
|
print("[Gitea OAuth] Could not fetch valid user profile from Gitea OAuth access token.")
|
|
return RedirectResponse(url="/login?error=oauth_verify_failed", status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
user = get_or_create_gitea_user(user_info)
|
|
token = create_session(user.id, user.username, user.role.value)
|
|
|
|
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
|
response.set_cookie(
|
|
key="thinkstorm_session",
|
|
value=token,
|
|
httponly=True,
|
|
samesite="lax",
|
|
max_age=86400 * 7
|
|
)
|
|
return response
|