feat: v3.1.0 — Live Platform Foundation (Track E.1-E.3)
Major release introducing the Live Platform architecture:
- SQLite database backend with async operations and migrations (E.1)
- AES-256-GCM credential encryption with HKDF key derivation (E.1)
- OAuth Social Login with GitHub and Google (E.2)
- Site management API with encrypted credential storage (E.3)
- Per-user MCP endpoints at /u/{user_id}/{alias}/mcp (E.3)
- User API keys with bcrypt hashing (E.3)
- Auto-generated config snippets for 5 MCP clients (E.3)
- Dashboard: dark/light mode, RBAC, My Sites, Connect page
- Active background health checks
- 452 tests (up from 303), all passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -89,8 +89,9 @@ class DashboardAuth:
|
||||
if not api_key:
|
||||
return False, "", None
|
||||
|
||||
api_key_clean = api_key.strip()
|
||||
# Check master API key (from env var)
|
||||
if self.master_api_key and secrets.compare_digest(api_key, self.master_api_key):
|
||||
if self.master_api_key and secrets.compare_digest(api_key_clean, self.master_api_key.strip()):
|
||||
return True, "master", None
|
||||
|
||||
# Check AuthManager's master key (covers auto-generated temp keys)
|
||||
@@ -202,6 +203,9 @@ class DashboardAuth:
|
||||
user_type=payload["type"],
|
||||
key_id=payload.get("kid"),
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.debug(f"Invalid dashboard session payload (missing key): {e}")
|
||||
return None
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.debug("Dashboard session expired")
|
||||
return None
|
||||
@@ -224,6 +228,27 @@ class DashboardAuth:
|
||||
return None
|
||||
return self.validate_session(token)
|
||||
|
||||
def get_user_session_from_request(self, request: Request) -> dict | None:
|
||||
"""Extract and validate an OAuth user session from request.
|
||||
|
||||
Args:
|
||||
request: Starlette request object.
|
||||
|
||||
Returns:
|
||||
User session dict (user_id, email, name, role, type)
|
||||
or None.
|
||||
"""
|
||||
token = request.cookies.get(self.COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
from core.user_auth import get_user_auth
|
||||
|
||||
user_auth = get_user_auth()
|
||||
return user_auth.validate_user_session(token)
|
||||
except (RuntimeError, Exception):
|
||||
return None
|
||||
|
||||
def set_session_cookie(self, response: Response, token: str) -> Response:
|
||||
"""
|
||||
Set session cookie on response.
|
||||
@@ -273,13 +298,15 @@ class DashboardAuth:
|
||||
RedirectResponse to login page if not authenticated, None if OK.
|
||||
"""
|
||||
session = self.get_session_from_request(request)
|
||||
if not session:
|
||||
user_session = self.get_user_session_from_request(request)
|
||||
|
||||
if not session and not user_session:
|
||||
# Store original URL for redirect after login
|
||||
next_url = str(request.url.path)
|
||||
if request.url.query:
|
||||
next_url += f"?{request.url.query}"
|
||||
return RedirectResponse(
|
||||
url=f"/dashboard/login?next={next_url}",
|
||||
url=f"/auth/login?next={next_url}",
|
||||
status_code=303,
|
||||
)
|
||||
return None
|
||||
@@ -291,3 +318,57 @@ def get_dashboard_auth() -> DashboardAuth:
|
||||
if _dashboard_auth is None:
|
||||
_dashboard_auth = DashboardAuth()
|
||||
return _dashboard_auth
|
||||
|
||||
|
||||
# ── Role-checking helpers ──────────────────────────────────────
|
||||
|
||||
|
||||
def is_admin_session(session) -> bool:
|
||||
"""Check if session is admin (master key or API key with admin scope).
|
||||
|
||||
Args:
|
||||
session: DashboardSession or OAuth user dict.
|
||||
|
||||
Returns:
|
||||
True if admin session.
|
||||
"""
|
||||
if isinstance(session, DashboardSession):
|
||||
return session.user_type in ("master", "api_key")
|
||||
if isinstance(session, dict):
|
||||
return session.get("type") == "master" or session.get("role") == "admin"
|
||||
return False
|
||||
|
||||
|
||||
def get_session_display_info(session) -> dict:
|
||||
"""Get display info for header/UI.
|
||||
|
||||
Args:
|
||||
session: DashboardSession or OAuth user dict.
|
||||
|
||||
Returns:
|
||||
Dict with name, type, email, avatar keys.
|
||||
"""
|
||||
if isinstance(session, DashboardSession):
|
||||
return {"name": "Admin", "type": "admin", "email": None, "avatar": None}
|
||||
if isinstance(session, dict):
|
||||
return {
|
||||
"name": session.get("name") or session.get("email", "User"),
|
||||
"type": "user",
|
||||
"email": session.get("email"),
|
||||
"avatar": None,
|
||||
}
|
||||
return {"name": "Unknown", "type": "unknown", "email": None, "avatar": None}
|
||||
|
||||
|
||||
def get_session_user_id(session) -> str | None:
|
||||
"""Get user_id for OAuth sessions, None for admin.
|
||||
|
||||
Args:
|
||||
session: DashboardSession or OAuth user dict.
|
||||
|
||||
Returns:
|
||||
User UUID string or None.
|
||||
"""
|
||||
if isinstance(session, dict):
|
||||
return session.get("user_id")
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user