This commit is contained in:
tao.chen
2026-07-30 18:57:18 +08:00
parent ff7fc4ef1d
commit c1e15758a3
31 changed files with 2648 additions and 3356 deletions
+221 -66
View File
@@ -1,84 +1,239 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import re
import time
from typing import Optional
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from backend.dependencies import (
RequestContext,
request_context,
)
from backend.dependencies import database_session
from backend.runtime_client import RuntimeClientError
from backend.schemas import (
CreateJupyterAccessTicketRequest,
)
from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(tags=["jupyter"])
security = HTTPBearer(auto_error=False)
def cookie_secure() -> bool:
return os.getenv("COOKIE_SECURE", "false").strip().lower() in {
"1",
"true",
"yes",
"on",
}
JWT_SECRET = os.environ.get("JWT_SECRET", "dev-only-not-for-production")
JWT_ALGORITHM = "HS256"
@router.post(
"/api/v1/jupyter/access-tickets",
status_code=status.HTTP_201_CREATED,
)
async def create_jupyter_access_ticket(
payload: CreateJupyterAccessTicketRequest,
request: Request,
context: RequestContext = Depends(request_context),
) -> JSONResponse:
def _b64decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(value + padding)
def verify_jwt_token(token: str) -> dict:
"""Parse a signed JWT from the access_token cookie.
Returns a payload dict containing at least ``sub`` (user_id) and
``exp``; raises 401 on missing, malformed, or expired tokens. The
current implementation is intentionally minimal — replace with a
real verification once a public-key/HSM source is wired in.
"""
if not token:
raise HTTPException(
status_code=401,
detail="Missing Authentication Token",
)
try:
ticket_data = (
await request.app.state.runtime_client
.create_jupyter_access_ticket(
payload.edit_session_id,
{
"workspace_id": context.workspace.workspace_id,
"user_id": context.user.user_id,
"lock_token": payload.lock_token,
},
)
)
except RuntimeClientError as exc:
error = exc.detail
if not isinstance(error, dict) or "code" not in error:
error = {
"code": "JUPYTER_TICKET_FAILED",
"message": str(error),
"retryable": exc.status_code >= 500,
"details": {},
}
return JSONResponse(
status_code=exc.status_code,
content={"request_id": context.request_id, "error": error},
header_b64, payload_b64, signature_b64 = token.split(".", 2)
except ValueError as exc:
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
) from exc
signing_input = f"{header_b64}.{payload_b64}".encode()
expected = hmac.new(
JWT_SECRET.encode(),
signing_input,
hashlib.sha256,
).digest()
try:
signature = _b64decode(signature_b64)
except Exception as exc: # pragma: no cover - malformed b64
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
) from exc
if not hmac.compare_digest(expected, signature):
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
)
raw_ticket = ticket_data.pop("ticket")
max_age = int(ticket_data.pop("expires_in_seconds"))
response = JSONResponse(
status_code=status.HTTP_201_CREATED,
content={
"request_id": context.request_id,
"data": ticket_data,
"meta": {},
},
try:
payload = json.loads(_b64decode(payload_b64))
except Exception as exc:
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
) from exc
exp = payload.get("exp")
if not isinstance(exp, (int, float)) or exp < time.time():
raise HTTPException(
status_code=401,
detail="Token expired",
)
return payload
def extract_notebook_path(
uri: str,
workspace_id: str,
) -> Optional[str]:
"""Pull the relative notebook path out of the original request URI.
Only ``/jupyter/{workspace_id}/notebooks/*.ipynb`` requests are
subject to file-level lock checks; everything else (tree views,
``/api/contents`` and WebSocket upgrades) bypasses the lock.
"""
pattern = rf"^/jupyter/{re.escape(workspace_id)}/notebooks/(.+\.ipynb)"
match = re.match(pattern, uri)
if match:
return match.group(1)
return None
async def check_notebook_is_locked(
session: AsyncSession,
workspace_id: str,
notebook_path: str,
user_id: str,
) -> bool:
"""Decide whether the request is allowed to read the notebook.
Returns ``True`` only when the notebook exists, is owned by a
different user, and is currently locked. The owner is always let
through; a missing row is treated as "not owned yet" and allowed.
"""
statement = select(Scripts.owner_user_id, Scripts.is_locked).where(
Scripts.workspace_id == workspace_id,
Scripts.script_name == notebook_path,
Scripts.script_type == "notebook",
Scripts.is_deleted == 0,
)
response.set_cookie(
key="jupyter_access",
value=raw_ticket,
max_age=max_age,
path="/jupyter/",
secure=cookie_secure(),
httponly=True,
samesite="lax",
row = (await session.execute(statement)).one_or_none()
if row is None:
return False
owner_user_id, is_locked = row
if owner_user_id == user_id:
return False
return bool(is_locked)
async def load_active_membership(
session: AsyncSession,
user_id: str,
workspace_id: str,
) -> tuple[Users, Workspaces]:
"""Resolve the user's active membership in the workspace."""
statement = (
select(Users, Workspaces)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
)
.join(
Workspaces,
Workspaces.workspace_id == WorkspaceMembers.workspace_id,
)
.where(
Users.user_id == user_id,
Users.status == "active",
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.member_status == "active",
Workspaces.status == "active",
)
)
return response
row = (await session.execute(statement)).one_or_none()
if row is None:
raise HTTPException(
status_code=403,
detail="active workspace membership is required",
)
return row
@router.get("/api/v1/auth/jupyter")
async def verify_jupyter_access(
request: Request,
response: Response,
auth: Optional[HTTPAuthorizationCredentials] = Depends(security),
session: AsyncSession = Depends(database_session),
) -> dict:
"""Nginx auth_request subrequest handler.
Returns 200 with two response headers so Nginx can proxy the
request to the user's Jupyter instance:
* ``x-upstream-addr`` = ``{base_url}:{port}``
* ``x-jupyter-internal-token`` = the runtime-issued token
"""
workspace_id = request.headers.get("X-Original-Workspace-Id")
original_uri = request.headers.get("X-Original-URI", "")
if not workspace_id:
raise HTTPException(
status_code=400,
detail="Missing Workspace ID",
)
cookie_token = request.cookies.get("access_token")
bearer_token = auth.credentials if auth else None
token = cookie_token or bearer_token
payload = verify_jwt_token(token)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
)
await load_active_membership(session, user_id, workspace_id)
notebook_path = extract_notebook_path(original_uri, workspace_id)
if notebook_path and await check_notebook_is_locked(
session, workspace_id, notebook_path, user_id,
):
raise HTTPException(
status_code=403,
detail=f"Notebook '{notebook_path}' is currently locked",
)
runtime_client = request.app.state.runtime_client
ws_info = await runtime_client.get_workspace(workspace_id)
if not ws_info or ws_info.get("status") != "running":
try:
ws_info = await runtime_client.start_workspace(workspace_id)
except RuntimeClientError as exc:
raise HTTPException(
status_code=exc.status_code,
detail=exc.detail,
) from exc
target_port = ws_info.get("port")
jupyter_token = ws_info.get("token")
jupyter_base_url = ws_info.get("base_url")
if not target_port:
raise HTTPException(
status_code=500,
detail="Jupyter instance returned no port",
)
response.headers["x-upstream-addr"] = f"{jupyter_base_url}:{target_port}"
response.headers["x-jupyter-internal-token"] = jupyter_token or ""
return {"status": "ok"}
-147
View File
@@ -1,147 +0,0 @@
# coding=utf-8
"""
@Time :2026/7/27
@Author :tao.chen
"""
import os
import re
import httpx
from fastapi import FastAPI, Request, Response, HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Optional
from loguru import logger
app = FastAPI(title="Jupyter Auth & Router Backend")
RUNTIME_BASE_URL = os.getenv("RUNTIME_BASE_URL", "http://127.0.0.1:8001")
security = HTTPBearer(auto_error=False)
# ------------------------------------------------------------------
# 1. Runtime 交互 Client
# ------------------------------------------------------------------
class RuntimeClient:
"""与 Runtime 进程管理器服务交互"""
@staticmethod
async def get_workspace(workspace_id: str) -> Optional[dict]:
"""按需查询单个 workspace 进程"""
async with httpx.AsyncClient(base_url=RUNTIME_BASE_URL) as client:
try:
resp = await client.post(
"/api/v1/jupyter",
json={"action": "get", "workspace_id": workspace_id},
timeout=3.0,
)
if resp.status_code == 200:
return resp.json()
return None
except httpx.RequestError:
return None
@staticmethod
async def start_workspace(workspace_id: str) -> dict:
"""进程未运行时主动触发启动"""
async with httpx.AsyncClient(base_url=RUNTIME_BASE_URL) as client:
resp = await client.post(
"/api/v1/jupyter",
json={"action": "start", "workspace_id": workspace_id},
timeout=10.0,
)
if resp.status_code == 200:
return resp.json()
raise HTTPException(
status_code=500, detail="Failed to start Jupyter instance"
)
# ------------------------------------------------------------------
# 2. 数据库与权限模拟 (请根据实际 MySQL ORM 修改)
# ------------------------------------------------------------------
async def check_notebook_is_locked(workspace_id: str, notebook_path: str) -> bool:
"""
查数据库:判断特定 Notebook 文件是否被锁定
:param workspace_id: 工作区 ID
:param notebook_path: 相对路径,如 "test.ipynb""folder/demo.ipynb"
"""
# 模拟锁定数据库:假定 test_locked.ipynb 被锁定
locked_notebooks = {
("test1234", "test_locked.ipynb"): True,
}
return locked_notebooks.get((workspace_id, notebook_path), False)
def verify_jwt_token(token: str) -> str:
"""校验 JWT 令牌"""
if token == "invalid-token":
raise HTTPException(status_code=401, detail="Invalid Authentication Token")
return "user_001"
def extract_notebook_path(uri: str, workspace_id: str) -> Optional[str]:
"""
从原始请求 URI 中提取请求的 .ipynb 文件相对路径
例如: /jupyter/test1234/notebooks/folder/test.ipynb -> folder/test.ipynb
"""
pattern = rf"^/jupyter/{re.escape(workspace_id)}/notebooks/(.+\.ipynb)"
match = re.match(pattern, uri)
if match:
return match.group(1)
return None
# ------------------------------------------------------------------
# 3. 核心 Auth 接口 (针对 Nginx auth_request)
# ------------------------------------------------------------------
@app.get("/api/v1/auth/jupyter")
async def verify_jupyter_access(
request: Request,
response: Response,
auth: Optional[HTTPAuthorizationCredentials] = Depends(security),
):
# 获取 Nginx 传入的元数据
workspace_id = request.headers.get("X-Original-Workspace-Id")
original_uri = request.headers.get("X-Original-URI", "")
cookie_token = request.cookies.get("access_token")
bearer_token = auth.credentials if auth else None
token = bearer_token or cookie_token
# if not token:
# raise HTTPException(status_code=401, detail="Missing Authentication Token")
if not workspace_id:
raise HTTPException(status_code=400, detail="Missing Workspace ID")
# 基础身份认证
# current_user_id = verify_jwt_token(token)
# 精准锁校验:只有在访问 .ipynb 文件时才检查 is_locked
notebook_path = extract_notebook_path(original_uri, workspace_id)
if notebook_path:
is_locked = await check_notebook_is_locked(workspace_id, notebook_path)
if is_locked:
raise HTTPException(
status_code=403,
detail=f"Notebook '{notebook_path}' is currently locked",
)
# 获取或启动 Jupyter 子进程
ws_info = await RuntimeClient.get_workspace(workspace_id)
if not ws_info or ws_info.get("status") != "running":
ws_info = await RuntimeClient.start_workspace(workspace_id)
target_port = ws_info.get("port")
jupyter_token = ws_info.get("token")
jupyter_base_url = ws_info.get("base_url")
if not target_port:
raise HTTPException(
status_code=500, detail="Jupyter instance returned no port"
)
# 通过 Response Header 返回 Upstream 地址与 Token 给 Nginx
response.headers["x-upstream-addr"] = f"{jupyter_base_url}:{target_port}"
response.headers["x-jupyter-internal-token"] = jupyter_token or ""
return {"status": "ok"}
+28 -9
View File
@@ -90,15 +90,34 @@ class RuntimeClient:
)
)["data"]
async def create_jupyter_access_ticket(
async def get_workspace(
self,
edit_session_id: str,
payload: dict[str, Any],
) -> dict[str, Any]:
return (
await self._request(
workspace_id: str,
) -> dict[str, Any] | None:
"""Ask Runtime for a live workspace.
Returns the workspace descriptor when the Jupyter process is
``running``; returns ``None`` when the workspace is not yet
started or has been torn down so callers can fall through to
:meth:`start_workspace`.
"""
try:
return await self._request(
"POST",
f"/internal/v1/jupyter/access-tickets/{edit_session_id}",
payload,
"/api/v1/jupyter",
{"action": "get", "workspace_id": workspace_id},
)
)["data"]
except RuntimeClientError as exc:
if exc.status_code == 404:
return None
raise
async def start_workspace(
self,
workspace_id: str,
) -> dict[str, Any]:
return await self._request(
"POST",
"/api/v1/jupyter",
{"action": "start", "workspace_id": workspace_id},
)
-5
View File
@@ -65,8 +65,3 @@ class DownloadUrlRequest(StrictModel):
class FileLockTokenRequest(StrictModel):
lock_token: str = Field(min_length=32, max_length=256)
class CreateJupyterAccessTicketRequest(StrictModel):
edit_session_id: str = Field(min_length=26, max_length=26)
lock_token: str = Field(min_length=32, max_length=256)