diff --git a/.github/workflows/pm-cli-release.yml b/.github/workflows/pm-cli-release.yml new file mode 100644 index 0000000..3dc8b32 --- /dev/null +++ b/.github/workflows/pm-cli-release.yml @@ -0,0 +1,86 @@ +# pm CLI 多平台构建 + Release 发布 +# +# 触发方式: +# 1. 推送 tag pm-v*(如 pm-v0.1.0)→ 构建四平台二进制并发布到 GitHub Release +# 2. workflow_dispatch 手动触发 → 只构建并上传 workflow artifact(用于验证) +# +# 产物命名:pm-darwin-arm64 / pm-darwin-x86_64 / pm-linux-x86_64 / pm-windows-x86_64.exe +# 注意:macOS 产物未做 Apple 公证,用户首次运行需 +# xattr -d com.apple.quarantine pm (或在「系统设置 → 隐私与安全性」放行) +name: pm-cli-release + +on: + push: + tags: + - "pm-v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 # Apple Silicon + target: darwin-arm64 + - os: macos-13 # Intel + target: darwin-x86_64 + - os: ubuntu-22.04 + target: linux-x86_64 + - os: windows-latest + target: windows-x86_64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # CLI 只依赖 typer + httpx,无需安装整个 papermind 包(避免拖入 fastapi/pgvector 等) + - name: Install build dependencies + run: pip install "typer>=0.12" "httpx>=0.28.1" "pyinstaller>=6.0" + + - name: Build binary + run: > + pyinstaller --onefile --clean --noconfirm --name pm + --paths . + --hidden-import "apps.cli.main" + --hidden-import "apps.cli.client" + --hidden-import "apps.cli.config" + --collect-data certifi + apps/cli/__main__.py + + - name: Smoke test (--version, unix) + if: runner.os != 'Windows' + run: dist/pm --version + + - name: Smoke test (--version, windows) + if: runner.os == 'Windows' + shell: pwsh + run: dist\pm.exe --version + + - name: Rename artifact (unix) + if: runner.os != 'Windows' + run: mv dist/pm "dist/pm-${{ matrix.target }}" + + - name: Rename artifact (windows) + if: runner.os == 'Windows' + shell: pwsh + run: Move-Item dist\pm.exe "dist/pm-${{ matrix.target }}.exe" + + - uses: actions/upload-artifact@v4 + with: + name: pm-${{ matrix.target }} + path: dist/pm-* + if-no-files-found: error + + - name: Attach to GitHub Release + if: startsWith(github.ref, 'refs/tags/pm-v') + uses: softprops/action-gh-release@v2 + with: + files: dist/pm-* + generate_release_notes: true diff --git a/README.md b/README.md index b1bcf77..51b00d2 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,14 @@ PaperMind 是一个面向科研工作者的 AI 增强平台,帮你从「搜索 - 🎫 **JWT Token** —— 7 天有效期,自动续期 - 🛡️ **全站保护** —— 所有 API 都需要认证 +### 🔑 API 令牌与 pm CLI + +让 pm CLI、Claude Code / ZCode 等 AI harness 安全访问你的 PaperMind: + +- 📱 **设备码授权登录** —— `pm login` 打开浏览器确认设备码,自动签发长期令牌(类似 `gh auth login`) +- 🛡️ **细粒度权限** —— read / write scope 按 HTTP 方法强制,网页端随时创建/吊销 +- 🩺 **pm doctor** —— 一条命令体检连通性与认证状态 + ### ⚙️ LLM 模型管理 灵活控制成本,按场景分配模型: @@ -193,8 +201,67 @@ PaperMind 是一个面向科研工作者的 AI 增强平台,帮你从「搜索 --- +## 🖥️ pm CLI 与 API 令牌 + +### 安装(你的电脑上,无需 Python) + +```bash +# macOS / Linux:一键安装(自动识别平台,从 GitHub Releases 下载) +curl -fsSL https://raw.githubusercontent.com/Color2333/PaperMind/main/scripts/install-pm.sh | bash + +# Windows(PowerShell): +irm https://raw.githubusercontent.com/Color2333/PaperMind/main/scripts/install-pm.ps1 | iex +``` + +或到 [Releases](https://github.com/Color2333/PaperMind/releases) 手动下载对应平台二进制 +(`pm-darwin-arm64` / `pm-darwin-x86_64` / `pm-linux-x86_64` / `pm-windows-x86_64.exe`), +放到 PATH 目录并 `chmod +x`。 + +> macOS 首次运行如被 Gatekeeper 拦截:`xattr -d com.apple.quarantine pm` + +开发者也可以从源码安装:`pipx install /path/to/PaperMind`,或本地构建单文件二进制 +`bash scripts/build-pm-cli.sh`(输出 `dist/pm`)。 + +### 登录 + +```bash +# 设备码授权(推荐):浏览器确认设备码后自动完成 +pm login --server https://pm.your-domain.com + +# 兜底:网页「设置 → API 令牌」创建后粘贴(SSH 等无浏览器环境) +pm login --token pmt_xxx + +pm whoami # 查看身份与权限 +pm doctor # 连通性体检 +pm logout # 吊销令牌并清除本地配置 +``` + +配置存于 `~/.config/papermind/config.toml`(权限 0600),也可用环境变量 `PAPERMIND_SERVER_URL` / `PAPERMIND_TOKEN` 覆盖。 + +### 权限模型 + +| 凭证 | 来源 | 权限 | +|------|------|------| +| JWT(7 天) | 网页密码登录 | 全部,含令牌管理 | +| API 令牌 `pmt_` | 网页创建 / 设备码签发 | scope 强制:GET → `read`,变更操作 → `write`;仅能吊销自己 | + +数据库只存令牌 SHA-256 哈希,明文仅创建时返回一次;令牌管理接口(创建/列表/吊销他人)仅限网页会话。 + +### 接入 Claude Code / ZCode(MCP) + +服务端 `/mcp` 同时接受静态 `MCP_AUTH_TOKEN` 与数据库 API 令牌: + +```bash +# 用 pm login 签发的令牌接入 Claude Code +claude mcp add --transport http papermind https://pm.your-domain.com/mcp \ + --header "Authorization: Bearer pmt_xxx" +``` + +--- + ## 🏗️ 架构总览 + ``` ┌─────────────────────────────────────────────────────────────┐ │ Frontend (React 18) │ diff --git a/apps/api/main.py b/apps/api/main.py index 150a55e..d929e85 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -15,7 +15,8 @@ from starlette.middleware.gzip import GZipMiddleware from apps.api.middleware.demo_mode import DemoModeMiddleware -from packages.auth import decode_access_token +from apps.api.token_auth import lookup_api_token, required_scope_for_method +from packages.auth import API_TOKEN_PREFIX, decode_access_token from packages.config import get_settings from packages.domain.exceptions import AppError from packages.logging_setup import setup_logging @@ -51,13 +52,21 @@ async def dispatch(self, request: Request, call_next): class AuthMiddleware(BaseHTTPMiddleware): - """认证中间件 - 保护所有 API(白名单除外)""" + """认证中间件 - 保护所有 API(白名单除外) + + 支持两种凭证: + - JWT(网页会话,/auth/login 签发,7 天有效):完整权限 + - API 令牌(pmt_ 前缀,DB 校验,read/write scope):按方法强制 scope + GET/HEAD → read,其余方法 → write + """ # 白名单路径(无需认证) WHITELIST = { "/health", "/auth/login", "/auth/status", + "/auth/device/start", + "/auth/device/poll", "/mcp", } @@ -88,13 +97,40 @@ async def dispatch(self, request: Request, call_next): token = request.query_params.get("token") if not token: + api_logger.warning("[%s] 401 缺少凭证 %s", request.url.path, request.client) return JSONResponse( status_code=401, content={"detail": "Not authenticated"}, ) + # API 令牌:pmt_ 前缀,查 DB 校验 + scope 强制 + if token.startswith(API_TOKEN_PREFIX): + info = lookup_api_token(token) + if info is None: + api_logger.warning("401 无效 API 令牌 %s", request.url.path) + return JSONResponse( + status_code=401, + content={"detail": "Invalid or expired token"}, + ) + required = required_scope_for_method(request.method) + if required not in info.scopes: + api_logger.warning( + "403 API 令牌 %s 缺少 %s scope %s", info.prefix, required, request.url.path + ) + return JSONResponse( + status_code=403, + content={"detail": f"令牌缺少 {required} 权限"}, + ) + request.state.auth_method = "api_token" + request.state.auth_scopes = info.scopes + request.state.token_id = info.token_id + request.state.token_name = info.name + request.state.token_prefix = info.prefix + return await call_next(request) + payload = decode_access_token(token) if not payload: + api_logger.warning("401 无效 JWT %s", request.url.path) return JSONResponse( status_code=401, content={"detail": "Invalid or expired token"}, @@ -102,6 +138,7 @@ async def dispatch(self, request: Request, call_next): # 将用户信息存入 request.state request.state.user = payload + request.state.auth_method = "jwt" return await call_next(request) diff --git a/apps/api/mcp.py b/apps/api/mcp.py index e116f75..d0f7b30 100644 --- a/apps/api/mcp.py +++ b/apps/api/mcp.py @@ -1,6 +1,10 @@ -"""PaperMind MCP server —— 挂载到现有 FastAPI,供 hermes agent 接入。 +"""PaperMind MCP server —— 挂载到现有 FastAPI,供 hermes agent / pm CLI 接入。 + +Streamable HTTP transport(MCP 2025-06-18 规范)。鉴权两级: +1. DB API 令牌(pmt_ 前缀,pm login / 网页签发,带 scope) +2. 静态 MCP_AUTH_TOKEN(hermes 常驻应急用) +两者都未配置时不启用鉴权(仅开发用)。 -Streamable HTTP transport(MCP 2025-06-18 规范),Bearer 静态 token 鉴权。 暴露论文查询 / 每日简报 / 论文推荐 / 触发处理任务四类工具,复用现有 service 单例。 @author Color2333 @@ -8,29 +12,58 @@ from __future__ import annotations +import hmac import os from fastmcp import FastMCP +from fastmcp.server.auth.auth import AccessToken, TokenVerifier -# 独立静态 token(hermes 常驻用,免续期)。未配置时 MCP 端点不启用鉴权(仅开发用)。 +# 静态令牌(hermes 常驻用,免续期)。未配置时仅依赖 DB 令牌;都没有则不鉴权(仅开发用)。 _MCP_TOKEN = os.environ.get("MCP_AUTH_TOKEN", "") -if _MCP_TOKEN: - from fastmcp.server.auth.providers.jwt import StaticTokenVerifier - - _verifier = StaticTokenVerifier( - tokens={ - _MCP_TOKEN: { - "client_id": "hermes-agent", - "sub": "hermes", - "scopes": ["read", "write"], - } - }, - required_scopes=["read"], - ) + +class _DbFallbackVerifier(TokenVerifier): + """先查 DB API 令牌(pm login / 网页签发,哈希存储 + scope),失败回落静态令牌。""" + + def __init__(self, static_token: str, **kwargs): + super().__init__(**kwargs) + self._static_token = static_token + + async def verify_token(self, token: str) -> AccessToken | None: + from starlette.concurrency import run_in_threadpool + + from apps.api.token_auth import lookup_api_token + + info = await run_in_threadpool(lookup_api_token, token) + if info is not None: + access = AccessToken( + token=token, + client_id=info.name, + scopes=info.scopes, + claims={"sub": info.name, "auth_method": "api_token", "scopes": info.scopes}, + ) + elif self._static_token and hmac.compare_digest(token, self._static_token): + access = AccessToken( + token=token, + client_id="hermes-agent", + scopes=["read", "write"], + claims={"sub": "hermes", "auth_method": "static", "scopes": ["read", "write"]}, + ) + else: + return None + # required_scopes 子集校验(与 StaticTokenVerifier 行为一致) + if self.required_scopes and not set(self.required_scopes) <= set(access.scopes or []): + return None + return access + + +from packages.config import get_settings # noqa: E402 + +if _MCP_TOKEN or get_settings().auth_password: + _verifier = _DbFallbackVerifier(static_token=_MCP_TOKEN, required_scopes=["read"]) mcp = FastMCP("papermind-mcp", auth=_verifier) else: - # 未配 token:开发模式不鉴权(生产必须配 MCP_AUTH_TOKEN) + # 未配任何凭证:开发模式不鉴权(生产必须配 MCP_AUTH_TOKEN 或使用 DB 令牌) mcp = FastMCP("papermind-mcp") diff --git a/apps/api/routers/auth.py b/apps/api/routers/auth.py index e2383ba..0fcb70e 100644 --- a/apps/api/routers/auth.py +++ b/apps/api/routers/auth.py @@ -1,13 +1,22 @@ """ -认证路由 - 登录接口 +认证路由 - 登录接口、API 令牌管理、设备码授权流程 @author Color2333 """ -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +import time +from datetime import UTC, datetime, timedelta -from packages.auth import authenticate_user, create_access_token +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from packages.auth import ( + DEVICE_POLL_INTERVAL_SECONDS, + authenticate_user, + create_access_token, +) from packages.config import get_settings +from packages.storage.db import session_scope +from packages.storage.repositories import ApiTokenRepository, DeviceAuthRequestRepository router = APIRouter(prefix="/auth", tags=["auth"]) @@ -25,6 +34,109 @@ class AuthStatusResponse(BaseModel): auth_enabled: bool +class MeResponse(BaseModel): + auth_method: str # jwt | api_token | disabled + sub: str | None = None + scopes: list[str] = [] + token_id: str | None = None + token_name: str | None = None + token_prefix: str | None = None + + +class TokenCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=128) + scopes: list[str] = Field(default=["read", "write"]) + expires_in_days: int | None = Field(default=None, ge=1, le=3650) + + +class TokenCreatedResponse(BaseModel): + id: str + name: str + token: str # 明文仅此一次返回 + token_prefix: str + scopes: list[str] + expires_at: str | None = None + + +class TokenItem(BaseModel): + id: str + name: str + token_prefix: str + scopes: list[str] + created_by: str + created_at: str + last_used_at: str | None = None + expires_at: str | None = None + + +class DeviceStartRequest(BaseModel): + client_name: str = Field(default="pm-cli", max_length=128) + + +class DeviceStartResponse(BaseModel): + device_code: str + user_code: str + verification_url: str + expires_in: int + interval: int + + +class DevicePollRequest(BaseModel): + device_code: str + + +class DevicePollResponse(BaseModel): + status: str # pending | approved | denied | expired | delivered + access_token: str | None = None + token_name: str | None = None + + +class DeviceRequestInfo(BaseModel): + user_code: str + client_name: str + status: str + expires_in: int + + +class DeviceDecisionResponse(BaseModel): + status: str + + +# ---------- 内部工具 ---------- + + +def require_web_session(request: Request) -> None: + """令牌管理 / 设备授权页专用:只允许网页 JWT 会话(auth 关闭时放行)。 + + 防止低权限 API 令牌自我复制/管理令牌。 + """ + if not get_settings().auth_password: + return + if getattr(request.state, "auth_method", "") != "jwt": + raise HTTPException(status_code=403, detail="此操作需要网页登录会话") + + +def _iso(dt) -> str | None: + return dt.isoformat() if dt else None + + +# 轮询节流(进程内):同一 device_code 最小间隔,防暴力枚举 +_poll_last: dict[str, float] = {} +_POLL_MIN_INTERVAL = 2.0 + + +def _poll_throttled(device_code: str) -> bool: + now = time.monotonic() + if len(_poll_last) > 4096: + _poll_last.clear() + last = _poll_last.get(device_code) + _poll_last[device_code] = now + return last is not None and (now - last) < _POLL_MIN_INTERVAL + + +# ---------- 基础认证 ---------- + + @router.post("/login", response_model=LoginResponse) async def login(request: LoginRequest): """ @@ -53,3 +165,207 @@ async def auth_status(): """ settings = get_settings() return AuthStatusResponse(auth_enabled=bool(settings.auth_password)) + + +@router.get("/me", response_model=MeResponse) +async def me(request: Request): + """当前调用者身份(pm whoami / pm doctor 用)""" + settings = get_settings() + if not settings.auth_password: + return MeResponse(auth_method="disabled") + method = getattr(request.state, "auth_method", "") + if method == "api_token": + return MeResponse( + auth_method="api_token", + scopes=getattr(request.state, "auth_scopes", []), + token_id=getattr(request.state, "token_id", None), + token_name=getattr(request.state, "token_name", None), + token_prefix=getattr(request.state, "token_prefix", None), + ) + if method == "jwt": + return MeResponse(auth_method="jwt", sub="papermind-user") + raise HTTPException(status_code=401, detail="Not authenticated") + + +# ---------- API 令牌管理(仅网页 JWT 会话) ---------- + + +@router.post("/tokens", response_model=TokenCreatedResponse) +async def create_token(body: TokenCreateRequest, request: Request): + """创建 API 令牌。明文 token 仅在响应中返回一次。""" + require_web_session(request) + valid_scopes = [s for s in body.scopes if s in ("read", "write")] + if not valid_scopes: + raise HTTPException(status_code=422, detail="scopes 至少包含 read 或 write") + expires_at = None + if body.expires_in_days: + expires_at = datetime.now(UTC) + timedelta(days=body.expires_in_days) + + with session_scope() as session: + token, raw = ApiTokenRepository(session).create( + name=body.name, scopes=valid_scopes, created_by="web", expires_at=expires_at + ) + return TokenCreatedResponse( + id=token.id, + name=token.name, + token=raw, + token_prefix=token.token_prefix, + scopes=token.scopes, + expires_at=_iso(token.expires_at), + ) + + +@router.get("/tokens", response_model=list[TokenItem]) +async def list_tokens(request: Request): + """列出未吊销的 API 令牌(脱敏,只显示前缀)""" + require_web_session(request) + with session_scope() as session: + tokens = ApiTokenRepository(session).list_all() + return [ + TokenItem( + id=t.id, + name=t.name, + token_prefix=t.token_prefix, + scopes=t.scopes, + created_by=t.created_by, + created_at=_iso(t.created_at) or "", + last_used_at=_iso(t.last_used_at), + expires_at=_iso(t.expires_at), + ) + for t in tokens + ] + + +@router.delete("/tokens/{token_id}", response_model=DeviceDecisionResponse) +async def revoke_token(token_id: str, request: Request): + """吊销 API 令牌。网页会话可吊销任意令牌;API 令牌仅可吊销自己(pm logout)。""" + settings = get_settings() + if settings.auth_password: + method = getattr(request.state, "auth_method", "") + is_self = method == "api_token" and getattr(request.state, "token_id", None) == token_id + if method != "jwt" and not is_self: + raise HTTPException(status_code=403, detail="此操作需要网页登录会话") + with session_scope() as session: + ok = ApiTokenRepository(session).revoke(token_id) + if not ok: + raise HTTPException(status_code=404, detail="令牌不存在或已吊销") + return DeviceDecisionResponse(status="revoked") + + +# ---------- 设备码授权流程(pm login) ---------- + + +@router.post("/device/start", response_model=DeviceStartResponse) +async def device_start(body: DeviceStartRequest): + """CLI 发起设备码授权(无需认证,device_code 是后续轮询凭证)""" + settings = get_settings() + if not settings.auth_password: + raise HTTPException(status_code=403, detail="Authentication is disabled") + with session_scope() as session: + req, device_code = DeviceAuthRequestRepository(session).create(body.client_name) + return DeviceStartResponse( + device_code=device_code, + user_code=req.user_code, + verification_url=f"{settings.site_url.rstrip('/')}/device?user_code={req.user_code}", + expires_in=int( + ( + req.expires_at.replace(tzinfo=None) - req.created_at.replace(tzinfo=None) + ).total_seconds() + ), + interval=DEVICE_POLL_INTERVAL_SECONDS, + ) + + +@router.post("/device/poll", response_model=DevicePollResponse) +async def device_poll(body: DevicePollRequest): + """CLI 轮询授权结果。批准后的首次轮询创建并返回令牌(明文仅此一次)。""" + settings = get_settings() + if not settings.auth_password: + raise HTTPException(status_code=403, detail="Authentication is disabled") + if _poll_throttled(body.device_code): + raise HTTPException(status_code=429, detail="轮询过于频繁,请按 interval 间隔轮询") + + with session_scope() as session: + repo = DeviceAuthRequestRepository(session) + req = repo.get_by_device_code(body.device_code) + if req is None: + raise HTTPException(status_code=404, detail="无效的 device_code") + if req.status == "pending" and repo.is_expired(req): + req = repo.expire_stale(req) + if req.status == "pending": + return DevicePollResponse(status="pending") + if req.status == "denied": + return DevicePollResponse(status="denied") + if req.status == "expired": + return DevicePollResponse(status="expired") + # approved:首次轮询创建令牌并交付;之后返回 delivered + if req.api_token_id is None: + token, raw = ApiTokenRepository(session).create( + name=f"{req.client_name} ({req.user_code})", + scopes=["read", "write"], + created_by="device", + device_request_id=req.id, + ) + req.api_token_id = token.id + session.flush() + return DevicePollResponse(status="approved", access_token=raw, token_name=token.name) + return DevicePollResponse(status="delivered", token_name=req.client_name) + + +@router.get("/device/{user_code}", response_model=DeviceRequestInfo) +async def device_info(user_code: str, request: Request): + """授权页查询待授权设备信息(仅网页 JWT 会话)""" + require_web_session(request) + with session_scope() as session: + repo = DeviceAuthRequestRepository(session) + req = repo.get_by_user_code(user_code) + if req is None: + raise HTTPException(status_code=404, detail="授权请求不存在") + if req.status == "pending" and repo.is_expired(req): + req = repo.expire_stale(req) + expires_in = max( + 0, + int( + ( + req.expires_at.replace(tzinfo=None) - datetime.now(UTC).replace(tzinfo=None) + ).total_seconds() + ), + ) + return DeviceRequestInfo( + user_code=req.user_code, + client_name=req.client_name, + status=req.status, + expires_in=expires_in, + ) + + +@router.post("/device/{user_code}/authorize", response_model=DeviceDecisionResponse) +async def device_authorize(user_code: str, request: Request): + """网页端批准设备授权(仅网页 JWT 会话)""" + require_web_session(request) + with session_scope() as session: + repo = DeviceAuthRequestRepository(session) + req = repo.get_by_user_code(user_code) + if req is None: + raise HTTPException(status_code=404, detail="授权请求不存在") + if req.status == "pending" and repo.is_expired(req): + req = repo.expire_stale(req) + approved = repo.approve(req.id) + if approved is None: + raise HTTPException(status_code=409, detail="请求已过期或已处理") + return DeviceDecisionResponse(status="approved") + + +@router.post("/device/{user_code}/deny", response_model=DeviceDecisionResponse) +async def device_deny(user_code: str, request: Request): + """网页端拒绝设备授权(仅网页 JWT 会话)""" + require_web_session(request) + with session_scope() as session: + repo = DeviceAuthRequestRepository(session) + req = repo.get_by_user_code(user_code) + if req is None: + raise HTTPException(status_code=404, detail="授权请求不存在") + denied = repo.deny(req.id) + if denied is None: + raise HTTPException(status_code=409, detail="请求已处理") + return DeviceDecisionResponse(status="denied") diff --git a/apps/api/token_auth.py b/apps/api/token_auth.py new file mode 100644 index 0000000..d0a2ddc --- /dev/null +++ b/apps/api/token_auth.py @@ -0,0 +1,65 @@ +""" +API 令牌校验共享层 —— AuthMiddleware 与 MCP verifier 共用 +@author Color2333 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from packages.auth import API_TOKEN_PREFIX, hash_token + +logger = logging.getLogger(__name__) + +# last_used_at 更新节流:距上次超过该秒数才写库,避免每个请求都写 +_LAST_USED_THROTTLE_SECONDS = 60 + + +@dataclass +class TokenInfo: + """已验证的 API 令牌信息""" + + token_id: str + name: str + prefix: str + scopes: list[str] = field(default_factory=list) + + +def lookup_api_token(raw_token: str) -> TokenInfo | None: + """按明文令牌查库校验。无效/吊销/过期返回 None;非 pmt_ 前缀直接返回 None(不查库)。 + + 同时节流更新 last_used_at。 + """ + if not raw_token.startswith(API_TOKEN_PREFIX): + return None + + from packages.storage.db import session_scope + from packages.storage.repositories import ApiTokenRepository + + try: + with session_scope() as session: + token = ApiTokenRepository(session).get_by_hash(hash_token(raw_token)) + if token is None or not token.is_active(): + return None + now = datetime.now(UTC).replace(tzinfo=None) + last_used = token.last_used_at.replace(tzinfo=None) if token.last_used_at else None + if last_used is None or (now - last_used).total_seconds() > _LAST_USED_THROTTLE_SECONDS: + token.last_used_at = now + session.flush() + return TokenInfo( + token_id=token.id, + name=token.name, + prefix=token.token_prefix, + scopes=list(token.scopes), + ) + except Exception: + # 表不存在(迁移未跑)等情况下不阻断请求,按无效令牌处理 + logger.debug("API 令牌查询失败", exc_info=True) + return None + + +def required_scope_for_method(method: str) -> str: + """HTTP 方法 → 所需 scope:读方法需 read,写方法需 write""" + return "read" if method in ("GET", "HEAD", "OPTIONS") else "write" diff --git a/apps/cli/__init__.py b/apps/cli/__init__.py new file mode 100644 index 0000000..2907a82 --- /dev/null +++ b/apps/cli/__init__.py @@ -0,0 +1,4 @@ +""" +PaperMind CLI(pm 命令)—— 登录 / 身份 / 体检 +@author Color2333 +""" diff --git a/apps/cli/__main__.py b/apps/cli/__main__.py new file mode 100644 index 0000000..ee4c9a2 --- /dev/null +++ b/apps/cli/__main__.py @@ -0,0 +1,9 @@ +""" +pm CLI 可执行入口(PyInstaller 打包 / python -m apps.cli 双用) +@author Color2333 +""" + +from apps.cli.main import app + +if __name__ == "__main__": + app(prog_name="pm") diff --git a/apps/cli/client.py b/apps/cli/client.py new file mode 100644 index 0000000..740a428 --- /dev/null +++ b/apps/cli/client.py @@ -0,0 +1,76 @@ +""" +PaperMind CLI - HTTP 客户端(服务端 REST API 封装) +@author Color2333 +""" + +from __future__ import annotations + +import httpx + +DEFAULT_TIMEOUT = 30.0 + + +class ApiError(Exception): + """非 2xx 响应""" + + def __init__(self, status_code: int, message: str): + super().__init__(message) + self.status_code = status_code + self.message = message + + +class ApiClient: + """轻量 REST 客户端;token 为空时也可调用无鉴权端点(/health、设备码流程)""" + + def __init__(self, server_url: str, token: str | None = None, timeout: float = DEFAULT_TIMEOUT): + self.base_url = server_url.rstrip("/") + self.token = token + self._timeout = timeout + + def request(self, method: str, path: str, json_body: dict | None = None) -> dict: + headers = {"Content-Type": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + try: + resp = httpx.request( + method, + f"{self.base_url}{path}", + json=json_body, + headers=headers, + timeout=self._timeout, + ) + except httpx.HTTPError as e: + raise ApiError(0, f"无法连接 {self.base_url}({e.__class__.__name__})") from e + if resp.status_code >= 400: + raise ApiError(resp.status_code, _extract_error(resp)) + if resp.status_code == 204 or not resp.content: + return {} + return resp.json() + + # ---- 便捷封装 ---- + + def health(self) -> dict: + return self.request("GET", "/health") + + def me(self) -> dict: + return self.request("GET", "/auth/me") + + def device_start(self, client_name: str) -> dict: + return self.request("POST", "/auth/device/start", {"client_name": client_name}) + + def device_poll(self, device_code: str) -> dict: + return self.request("POST", "/auth/device/poll", {"device_code": device_code}) + + def revoke_token(self, token_id: str) -> dict: + return self.request("DELETE", f"/auth/tokens/{token_id}") + + +def _extract_error(resp: httpx.Response) -> str: + try: + body = resp.json() + detail = body.get("detail") if isinstance(body, dict) else None + if detail: + return str(detail) + except Exception: + pass + return f"HTTP {resp.status_code}" diff --git a/apps/cli/config.py b/apps/cli/config.py new file mode 100644 index 0000000..d7b3b13 --- /dev/null +++ b/apps/cli/config.py @@ -0,0 +1,94 @@ +""" +PaperMind CLI 配置管理 +@author Color2333 +""" + +from __future__ import annotations + +import os +import stat +from dataclasses import dataclass +from pathlib import Path + + +def _config_dir() -> Path: + return Path(os.environ.get("PAPERMIND_CONFIG_DIR", Path.home() / ".config" / "papermind")) + + +def config_file() -> Path: + return _config_dir() / "config.toml" + + +@dataclass +class Config: + server_url: str + token: str + client_name: str = "pm-cli" + + +def load_config() -> Config | None: + """读取 ~/.config/papermind/config.toml;文件缺失/损坏返回 None""" + path = config_file() + if not path.exists(): + return None + try: + values: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, raw = line.partition("=") + values[key.strip()] = raw.strip().strip('"') + if not values.get("server_url") or not values.get("token"): + return None + return Config( + server_url=values["server_url"].rstrip("/"), + token=values["token"], + client_name=values.get("client_name", "pm-cli"), + ) + except OSError: + return None + + +def save_config(config: Config) -> Path: + """写入配置文件,权限 0600(令牌是敏感凭证)""" + directory = _config_dir() + directory.mkdir(parents=True, exist_ok=True) + path = config_file() + content = ( + "# PaperMind CLI 配置(pm login 生成)\n" + f'server_url = "{config.server_url.rstrip("/")}"\n' + f'token = "{config.token}"\n' + f'client_name = "{config.client_name}"\n' + ) + path.write_text(content, encoding="utf-8") + path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600 + return path + + +def clear_config() -> bool: + path = config_file() + if path.exists(): + path.unlink() + return True + return False + + +def resolve_server_url(explicit: str | None = None) -> str | None: + """优先级:命令行参数 > 环境变量 > 配置文件""" + if explicit: + return explicit.rstrip("/") + env = os.environ.get("PAPERMIND_SERVER_URL") + if env: + return env.rstrip("/") + cfg = load_config() + return cfg.server_url if cfg else None + + +def resolve_token() -> str | None: + """优先级:环境变量 > 配置文件""" + env = os.environ.get("PAPERMIND_TOKEN") + if env: + return env + cfg = load_config() + return cfg.token if cfg else None diff --git a/apps/cli/main.py b/apps/cli/main.py new file mode 100644 index 0000000..2ec3467 --- /dev/null +++ b/apps/cli/main.py @@ -0,0 +1,211 @@ +""" +PaperMind CLI 入口 —— pm 命令 +登录(设备码授权 / 令牌粘贴)、登出、身份查看、连通性体检。 + +安装:pipx install /path/to/PaperMind (或 uv tool install -e /path/to/PaperMind) +@author Color2333 +""" + +from __future__ import annotations + +import time +import webbrowser + +import typer + +from apps.cli.client import ApiClient, ApiError +from apps.cli.config import ( + Config, + clear_config, + config_file, + load_config, + resolve_server_url, + resolve_token, + save_config, +) + +app = typer.Typer( + help="PaperMind CLI —— 命令行访问你的 PaperMind 实例", + no_args_is_help=True, + add_completion=False, +) + +__version__ = "0.1.0" + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"pm {__version__}") + raise typer.Exit + + +@app.callback() +def _main( + version: bool | None = typer.Option( + None, "--version", "-v", callback=_version_callback, is_eager=True, help="显示版本" + ), +) -> None: + """PaperMind CLI""" + + +def _fail(message: str) -> None: + typer.secho(message, fg=typer.colors.RED, err=True) + raise typer.Exit(1) + + +def _ok(message: str) -> None: + typer.secho(message, fg=typer.colors.GREEN) + + +def _client(require_token: bool = True) -> ApiClient: + server = resolve_server_url() + if not server: + _fail("未配置服务地址。先执行: pm login --server https://your-server.com") + token = resolve_token() + if require_token and not token: + _fail("未登录。先执行: pm login") + return ApiClient(server or "", token) + + +@app.command() +def login( + token: str | None = typer.Option( + None, "--token", help="直接粘贴已有令牌(SSH 等无浏览器环境用)" + ), + server: str | None = typer.Option(None, "--server", help="PaperMind 服务地址"), + client_name: str = typer.Option("pm-cli", "--name", help="设备名称(授权页展示用)"), +) -> None: + """登录 PaperMind(默认走设备码授权,类似 gh auth login)""" + server_url = resolve_server_url(server) + if not server_url: + _fail("缺少服务地址,请加 --server https://your-server.com 或设置 PAPERMIND_SERVER_URL") + + if token: + raw = token.strip() + client = ApiClient(server_url, raw) + try: + me = client.me() + except ApiError as e: + _fail(f"令牌验证失败:{e.message}") + _save_and_greet(server_url, raw, client_name, me) + return + + # 设备码授权流程 + client = ApiClient(server_url) + try: + start = client.device_start(client_name) + except ApiError as e: + _fail(f"发起授权失败:{e.message}") + + typer.echo() + typer.secho(" 1. 在浏览器中打开并登录:", bold=True) + typer.secho(f" {start['verification_url']}", fg=typer.colors.CYAN) + typer.secho(" 2. 确认设备码:", bold=True) + typer.secho(f" {start['user_code']}", fg=typer.colors.CYAN, bold=True) + typer.echo() + + if typer.confirm("现在打开浏览器?", default=True): + webbrowser.open(start["verification_url"]) + + typer.echo("等待授权中(按 Ctrl+C 取消)...") + interval = max(1, int(start.get("interval", 5))) + deadline = time.monotonic() + max(30, int(start.get("expires_in", 900))) + while time.monotonic() < deadline: + time.sleep(interval) + try: + poll = client.device_poll(start["device_code"]) + except ApiError as e: + if e.status_code == 429: + continue # 轮询过快,下一个间隔重试 + _fail(f"轮询失败:{e.message}") + status = poll.get("status") + if status == "pending": + continue + if status == "approved" and poll.get("access_token"): + authed = ApiClient(server_url, poll["access_token"]) + _save_and_greet(server_url, poll["access_token"], client_name, authed.me()) + return + if status == "denied": + _fail("授权被拒绝。") + if status in ("expired", "delivered"): + _fail("授权请求已过期,请重新执行 pm login。") + _fail("等待授权超时,请重新执行 pm login。") + + +def _save_and_greet(server_url: str, token: str, client_name: str, me: dict) -> None: + path = save_config(Config(server_url=server_url, token=token, client_name=client_name)) + _ok(f"✓ 已登录:{me.get('token_name') or me.get('sub') or 'papermind-user'}") + scopes = ", ".join(me.get("scopes") or ["read", "write"]) + typer.echo(f" 权限: {scopes}") + typer.echo(f" 配置: {path}") + + +@app.command() +def logout() -> None: + """登出:吊销远端令牌并清除本地配置""" + cfg = load_config() + if not cfg: + typer.echo("本地没有登录配置。") + return + client = ApiClient(cfg.server_url, cfg.token) + try: + me = client.me() + token_id = me.get("token_id") + if token_id: + client.revoke_token(token_id) + _ok("✓ 已吊销服务端令牌") + except ApiError: + typer.secho("! 服务端吊销失败(令牌可能已失效),继续清除本地配置", fg=typer.colors.YELLOW) + clear_config() + _ok(f"✓ 本地配置已清除({config_file()})") + + +@app.command() +def whoami() -> None: + """查看当前登录身份与权限""" + client = _client() + try: + me = client.me() + except ApiError as e: + if e.status_code == 401: + _fail("令牌无效或已过期,请重新执行 pm login") + _fail(e.message) + typer.echo(f"服务: {client.base_url}") + typer.echo(f"身份: {me.get('auth_method')}") + if me.get("token_name"): + typer.echo(f"令牌: {me['token_name']} ({me.get('token_prefix')}…)") + if me.get("scopes"): + typer.echo(f"权限: {', '.join(me['scopes'])}") + + +@app.command() +def doctor() -> None: + """连通性体检:服务可达性、认证状态、令牌有效性""" + server = resolve_server_url() + if not server: + _fail("未配置服务地址。先执行: pm login --server https://your-server.com") + typer.echo(f"目标: {server}") + + client = ApiClient(server) + try: + client.health() + _ok("✓ /health 正常") + except ApiError as e: + _fail(f"✗ /health 不可达:{e.message}") + + token = resolve_token() + if not token: + typer.secho("✗ 未登录(执行 pm login)", fg=typer.colors.RED) + raise typer.Exit(1) + authed = ApiClient(server, token) + try: + me = authed.me() + _ok(f"✓ 认证有效:{me.get('auth_method')},权限 {', '.join(me.get('scopes') or [])}") + except ApiError as e: + if e.status_code == 401: + _fail("✗ 令牌无效或已吊销,请重新执行 pm login") + _fail(f"✗ 认证检查失败:{e.message}") + + +if __name__ == "__main__": + app() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0bdd6f4..e31650d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ const EmailSettings = lazy(() => import("@/pages/EmailSettings")); const Writing = lazy(() => import("@/pages/Writing")); const Statistics = lazy(() => import("@/pages/Statistics")); const Settings = lazy(() => import("@/pages/Settings")); +const DeviceAuthorize = lazy(() => import("@/pages/DeviceAuthorize")); import LoginPage from "@/pages/Login"; import { isAuthenticated as checkAuth, clearAuth } from "@/services/api"; @@ -84,8 +85,8 @@ export default function App() { setIsAuthed(false); }, []); - // 未认证时显示登录页 - if (!isAuthed) { + // 未认证时显示登录页(/device 设备授权页自行内嵌登录,放行深链) + if (!isAuthed && !window.location.pathname.startsWith("/device")) { return ( @@ -101,6 +102,8 @@ export default function App() { + {/* 设备码授权页:独立布局(pm login 打开的 verification_url) */} + }>} /> }> } /> }>} /> diff --git a/frontend/src/components/settings/TokensSettingsTab.tsx b/frontend/src/components/settings/TokensSettingsTab.tsx new file mode 100644 index 0000000..64e9e99 --- /dev/null +++ b/frontend/src/components/settings/TokensSettingsTab.tsx @@ -0,0 +1,360 @@ +/** + * 设置 - API 令牌管理(pm CLI / MCP 接入凭证) + * @author Color2333 + */ +import { useState, useCallback, useEffect } from "react"; +import { KeyRound, Plus, Trash2, Copy, Check } from "lucide-react"; +import { useToast } from "@/contexts/ToastContext"; +import { Button } from "@/components/ui/Button"; +import { Badge } from "@/components/ui/Badge"; +import { Spinner } from "@/components/ui/Spinner"; +import { Modal } from "@/components/ui/Modal"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import { tokenApi } from "@/services/api"; +import { getErrorMessage } from "@/lib/errorHandler"; +import { cn } from "@/lib/utils"; +import type { ApiTokenItem } from "@/types"; + +/** 服务端返回 naive UTC ISO 字符串,补 Z 后按本地时区展示 */ +function formatDate(iso: string | null): string { + if (!iso) return "—"; + const normalized = /[Z+]/.test(iso) ? iso : `${iso}Z`; + return new Date(normalized).toLocaleString("zh-CN", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +const EXPIRY_OPTIONS = [ + { value: 0, label: "永不过期" }, + { value: 7, label: "7 天" }, + { value: 30, label: "30 天" }, + { value: 90, label: "90 天" }, + { value: 365, label: "1 年" }, +]; + +export function TokensSettingsTab() { + const { toast } = useToast(); + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [created, setCreated] = useState<{ name: string; token: string } | null>(null); + const [revokeTarget, setRevokeTarget] = useState(null); + const [actionId, setActionId] = useState(null); + + const load = useCallback(async () => { + try { + setTokens(await tokenApi.list()); + } catch { + toast("error", "加载令牌列表失败"); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + load(); + }, [load]); + + const handleRevoke = async () => { + if (!revokeTarget) return; + setActionId(revokeTarget.id); + try { + await tokenApi.revoke(revokeTarget.id); + await load(); + toast("success", `已吊销「${revokeTarget.name}」`); + } catch (err) { + toast("error", getErrorMessage(err)); + } finally { + setActionId(null); + setRevokeTarget(null); + } + }; + + if (loading) + return ( +
+ +
+ ); + + return ( +
+
+

API 令牌

+

+ 供 pm CLI、MCP 或其他外部工具访问 PaperMind。令牌明文只在创建时显示一次,请妥善保存。 +

+
+ + {/* 令牌列表 */} +
+
+

全部令牌

+ +
+ + {tokens.length === 0 ? ( +
+ +

暂无令牌,点击「新建令牌」创建

+
+ ) : ( +
+ {tokens.map((t) => ( +
+
+
+ +
+
+
+ {t.name} + {t.scopes.map((s) => ( + + {s === "write" ? "读写" : "只读"} + + ))} + {t.created_by === "device" && 设备授权} +
+
+ {t.token_prefix}… + 创建于 {formatDate(t.created_at)} + 最近使用 {formatDate(t.last_used_at)} + {t.expires_at && 过期于 {formatDate(t.expires_at)}} +
+
+
+ +
+ ))} +
+ )} +
+ + setShowCreate(false)} + onCreated={(name, token) => { + setShowCreate(false); + setCreated({ name, token }); + load(); + }} + /> + + {/* 创建成功:明文仅此一次 */} + setCreated(null)} title="令牌已创建" maxWidth="md"> + {created && ( +
+
+ 请立即复制保存「{created.name}」的令牌,关闭后将无法再次查看。 +
+
+ + {created.token} + + +
+
+ +
+
+ )} +
+ + setRevokeTarget(null)} + /> +
+ ); +} + +function CopyButton({ text }: { text: string }) { + const { toast } = useToast(); + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function CreateTokenModal({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + onCreated: (name: string, token: string) => void; +}) { + const { toast } = useToast(); + const [name, setName] = useState(""); + const [readScope, setReadScope] = useState(true); + const [writeScope, setWriteScope] = useState(true); + const [expiryDays, setExpiryDays] = useState(0); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const reset = () => { + setName(""); + setReadScope(true); + setWriteScope(true); + setExpiryDays(0); + setError(""); + }; + + const handleSubmit = async () => { + if (!name.trim()) { + setError("请输入令牌名称"); + return; + } + const scopes = [readScope ? "read" : "", writeScope ? "write" : ""].filter(Boolean); + if (scopes.length === 0) { + setError("至少选择一种权限"); + return; + } + setSubmitting(true); + setError(""); + try { + const result = await tokenApi.create({ + name: name.trim(), + scopes, + expires_in_days: expiryDays > 0 ? expiryDays : null, + }); + reset(); + onCreated(result.name, result.token); + } catch (err) { + toast("error", getErrorMessage(err)); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+ {error &&
{error}
} +
+ + setName(e.target.value)} + className="w-full rounded-lg border border-border bg-page px-3 py-2 text-sm text-ink outline-none focus:border-primary" + placeholder="如:我的 MacBook CLI" + autoFocus + /> +
+
+ 权限 +
+ + +
+
+
+ + +
+
+ + +
+
+
+ ); +} + +function ScopeCheckbox({ + checked, + onChange, + title, + desc, +}: { + checked: boolean; + onChange: (v: boolean) => void; + title: string; + desc: string; +}) { + return ( + + ); +} diff --git a/frontend/src/pages/DeviceAuthorize.tsx b/frontend/src/pages/DeviceAuthorize.tsx new file mode 100644 index 0000000..8d1b0b0 --- /dev/null +++ b/frontend/src/pages/DeviceAuthorize.tsx @@ -0,0 +1,232 @@ +/** + * 设备码授权页 —— pm login 时用户在浏览器打开 verification_url 完成授权 + * 流程:打开链接 → 站点密码登录(未登录时)→ 确认设备码 → 批准/拒绝 → 回到 CLI + * @author Color2333 + */ +import { useCallback, useEffect, useState } from "react"; +import { MonitorSmartphone, CheckCircle2, XCircle, Clock, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { Spinner } from "@/components/ui/Spinner"; +import LoginPage from "@/pages/Login"; +import { authApi, deviceAuthApi, isAuthenticated } from "@/services/api"; +import { getErrorMessage } from "@/lib/errorHandler"; + +interface DeviceInfo { + user_code: string; + client_name: string; + status: string; + expires_in: number; +} + +function readUserCode(): string { + const params = new URLSearchParams(window.location.search); + return (params.get("user_code") || "").trim().toUpperCase(); +} + +export default function DeviceAuthorize() { + const [authed, setAuthed] = useState(() => isAuthenticated()); + const [authEnabled, setAuthEnabled] = useState(true); + const [checked, setChecked] = useState(false); + const [userCode, setUserCode] = useState(readUserCode); + const [info, setInfo] = useState(null); + const [loadError, setLoadError] = useState(""); + const [deciding, setDeciding] = useState(false); + const [decision, setDecision] = useState<"approved" | "denied" | null>(null); + + // 认证未启用时无需登录,直接进入授权流程 + useEffect(() => { + authApi + .status() + .then((s) => setAuthEnabled(s.auth_enabled)) + .catch(() => setAuthEnabled(true)) + .finally(() => setChecked(true)); + }, []); + + const loadInfo = useCallback(async (code: string) => { + if (!code) return; + setLoadError(""); + try { + setInfo(await deviceAuthApi.info(code)); + } catch (err) { + setInfo(null); + setLoadError(getErrorMessage(err)); + } + }, []); + + useEffect(() => { + if (checked && (authed || !authEnabled)) { + loadInfo(userCode); + } + }, [checked, authed, authEnabled, userCode, loadInfo]); + + const decide = async (action: "authorize" | "deny") => { + setDeciding(true); + try { + await (action === "authorize" + ? deviceAuthApi.authorize(userCode) + : deviceAuthApi.deny(userCode)); + setDecision(action === "authorize" ? "approved" : "denied"); + } catch (err) { + setLoadError(getErrorMessage(err)); + await loadInfo(userCode); + } finally { + setDeciding(false); + } + }; + + // 未认证:内嵌登录页(登录成功后回到本页授权流程) + if (!checked) { + return ( +
+ +
+ ); + } + if (authEnabled && !authed) { + return setAuthed(true)} />; + } + + const expired = info?.status === "expired"; + const finalized = decision !== null || info?.status === "denied" || expired; + + return ( +
+
+
+
+ +
+

设备登录授权

+

确认下面设备码与你终端 pm login 显示的一致

+
+ +
+ {/* 设备码输入(URL 未带或需要更正时) */} + {!info && !loadError && ( +
+ setUserCode(e.target.value.toUpperCase())} + placeholder="输入设备码,如 ABKQ-WERT" + className="w-full rounded-xl border border-slate-600 bg-slate-900/50 px-4 py-3 text-center font-mono text-lg tracking-widest text-white placeholder-slate-500 outline-none focus:border-primary" + maxLength={9} + /> + +
+ )} + + {loadError && ( +
+ {loadError} + +
+ )} + + {/* 待确认 */} + {info && !finalized && ( +
+
+
+ {info.user_code} +
+

+ 设备 {info.client_name} 请求登录 + PaperMind +

+

+ + {Math.floor(info.expires_in / 60)} 分{" "} + {String(info.expires_in % 60).padStart(2, "0")} 秒后过期 +

+
+
+ + +
+
+ )} + + {/* 结果态 */} + {decision === "approved" && ( + } + title="已批准" + desc="回到终端,pm login 会自动完成登录" + /> + )} + {decision === "denied" && ( + } + title="已拒绝" + desc="该设备登录请求已被拒绝" + /> + )} + {info?.status === "denied" && decision === null && ( + } + title="已拒绝" + desc="该设备登录请求已被拒绝" + /> + )} + {expired && ( + } + title="已过期" + desc="授权请求超时,请在终端重新执行 pm login" + /> + )} +
+ +

+ PaperMind · 设备码授权(类似 GitHub CLI 登录流程) +

+
+
+ ); +} + +function ResultPanel({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) { + return ( +
+
{icon}
+

{title}

+

{desc}

+
+ ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 1dffeb6..f630b31 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,19 +2,21 @@ * Claude 风格的设置页面 - 左侧导航 + 右侧内容 */ import { useState } from "react"; -import { Cpu, Mail, GitBranch, Settings, ChevronRight } from "lucide-react"; +import { Cpu, Mail, GitBranch, Settings, ChevronRight, KeyRound } from "lucide-react"; import { cn } from "@/lib/utils"; import { LLMSettingsTab } from "@/components/settings/LLMSettingsTab"; import { EmailSettingsTab } from "@/components/settings/EmailSettingsTab"; import { PipelineSettingsTab } from "@/components/settings/PipelineSettingsTab"; import { OpsSettingsTab } from "@/components/settings/OpsSettingsTab"; +import { TokensSettingsTab } from "@/components/settings/TokensSettingsTab"; -type SettingsTab = "llm" | "email" | "pipeline" | "ops"; +type SettingsTab = "llm" | "email" | "pipeline" | "ops" | "tokens"; const NAV_ITEMS: { key: SettingsTab; label: string; icon: typeof Cpu }[] = [ { key: "llm", label: "LLM 配置", icon: Cpu }, { key: "email", label: "邮箱与报告", icon: Mail }, { key: "pipeline", label: "Pipeline", icon: GitBranch }, + { key: "tokens", label: "API 令牌", icon: KeyRound }, { key: "ops", label: "运维", icon: Settings }, ]; @@ -59,6 +61,7 @@ export default function SettingsPage() { {activeTab === "llm" && } {activeTab === "email" && } {activeTab === "pipeline" && } + {activeTab === "tokens" && } {activeTab === "ops" && } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b01c042..9e3782d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -58,6 +58,13 @@ import type { ActiveTaskInfo, LoginResponse, AuthStatusResponse, + MeResponse, + ApiTokenItem, + ApiTokenCreated, + ApiTokenCreateRequest, + DeviceStartResponse, + DevicePollResponse, + DeviceRequestInfo, MultiSourceSearchResult, ChannelSuggestion, Tag, @@ -714,4 +721,21 @@ export const tasksApi = { export const authApi = { login: (password: string) => post("/auth/login", { password }), status: () => get("/auth/status"), + me: () => get("/auth/me"), +}; + +/* ========== API 令牌(CLI / MCP 接入) ========== */ +export const tokenApi = { + list: () => get("/auth/tokens"), + create: (body: ApiTokenCreateRequest) => post("/auth/tokens", body), + revoke: (id: string) => del<{ status: string }>(`/auth/tokens/${id}`), +}; + +/* ========== 设备码授权(pm login 时网页端批准用) ========== */ +export const deviceAuthApi = { + info: (userCode: string) => get(`/auth/device/${encodeURIComponent(userCode)}`), + authorize: (userCode: string) => + post<{ status: string }>(`/auth/device/${encodeURIComponent(userCode)}/authorize`), + deny: (userCode: string) => + post<{ status: string }>(`/auth/device/${encodeURIComponent(userCode)}/deny`), }; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ca51151..b9a6162 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1058,6 +1058,61 @@ export interface AuthStatusResponse { auth_enabled: boolean; } +export interface MeResponse { + auth_method: "jwt" | "api_token" | "disabled"; + sub?: string | null; + scopes?: string[]; + token_name?: string | null; + token_prefix?: string | null; +} + +export interface ApiTokenItem { + id: string; + name: string; + token_prefix: string; + scopes: string[]; + created_by: string; + created_at: string; + last_used_at: string | null; + expires_at: string | null; +} + +export interface ApiTokenCreated { + id: string; + name: string; + token: string; // 明文仅创建时返回一次 + token_prefix: string; + scopes: string[]; + expires_at: string | null; +} + +export interface ApiTokenCreateRequest { + name: string; + scopes?: string[]; + expires_in_days?: number | null; +} + +export interface DeviceStartResponse { + device_code: string; + user_code: string; + verification_url: string; + expires_in: number; + interval: number; +} + +export interface DevicePollResponse { + status: "pending" | "approved" | "denied" | "expired" | "delivered"; + access_token?: string | null; + token_name?: string | null; +} + +export interface DeviceRequestInfo { + user_code: string; + client_name: string; + status: string; + expires_in: number; +} + export type SSEEventType = | "conversation_init" | "text_delta" diff --git a/infra/migrations/versions/b8c9d0e1f2a3_add_api_tokens.py b/infra/migrations/versions/b8c9d0e1f2a3_add_api_tokens.py new file mode 100644 index 0000000..bb2b48a --- /dev/null +++ b/infra/migrations/versions/b8c9d0e1f2a3_add_api_tokens.py @@ -0,0 +1,78 @@ +"""add api_tokens and device_auth_requests + +Revision ID: b8c9d0e1f2a3 +Revises: f6a7b8c9d0e1 +Create Date: 2026-09-02 12:00:00.000000 + +目的:登录/权限系统升级 —— 数据库化 API 令牌(CLI / MCP / 外部 harness 用, +哈希存储 + read/write scope + 可吊销)与设备码授权请求(pm login 设备码流程)。 + +- api_tokens:DB 只存 SHA-256 哈希,明文仅创建时返回一次 +- device_auth_requests:状态机 pending → approved/denied/expired, + 批准后关联生成的 ApiToken +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +# revision identifiers, used by Alembic. +revision = "b8c9d0e1f2a3" +down_revision = "f6a7b8c9d0e1" +branch_labels = None +depends_on = None + + +def _json_type() -> sa.types.TypeEngine: + if op.get_bind().dialect.name == "postgresql": + return JSONB() + return sa.JSON() + + +def upgrade() -> None: + op.create_table( + "api_tokens", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("token_prefix", sa.String(16), nullable=False), + sa.Column("token_hash", sa.String(64), nullable=False), + sa.Column("scopes", _json_type(), nullable=False), + sa.Column("created_by", sa.String(16), nullable=False, server_default="web"), + sa.Column("device_request_id", sa.String(36), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + ) + op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"], unique=True) + op.create_index("ix_api_tokens_created_at", "api_tokens", ["created_at"]) + + op.create_table( + "device_auth_requests", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("device_code_hash", sa.String(64), nullable=False), + sa.Column("user_code", sa.String(9), nullable=False), + sa.Column("client_name", sa.String(128), nullable=False, server_default="pm-cli"), + sa.Column("status", sa.String(16), nullable=False, server_default="pending"), + sa.Column( + "api_token_id", + sa.String(36), + sa.ForeignKey("api_tokens.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("approved_at", sa.DateTime(), nullable=True), + ) + op.create_index( + "ix_device_auth_requests_device_code_hash", + "device_auth_requests", + ["device_code_hash"], + unique=True, + ) + op.create_index("ix_device_auth_requests_user_code", "device_auth_requests", ["user_code"]) + op.create_index("ix_device_auth_requests_status", "device_auth_requests", ["status"]) + + +def downgrade() -> None: + op.drop_table("device_auth_requests") + op.drop_table("api_tokens") diff --git a/packages/auth.py b/packages/auth.py index 4b81bad..bbbbf69 100644 --- a/packages/auth.py +++ b/packages/auth.py @@ -1,10 +1,12 @@ """ -认证工具模块 - JWT 生成/验证,密码验证 +认证工具模块 - JWT 生成/验证,密码验证,API 令牌生成/哈希 @author Color2333 """ import hmac +import secrets from datetime import UTC, datetime, timedelta +from hashlib import sha256 from typing import Any from jose import JWTError, jwt @@ -17,6 +19,51 @@ ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_HOURS = 24 * 7 # 7天有效期 +# ---------- API 令牌(CLI / MCP / 外部 harness 用) ---------- + +API_TOKEN_PREFIX = "pmt_" +# 设备码授权流程参数 +DEVICE_CODE_EXPIRE_SECONDS = 15 * 60 +DEVICE_POLL_INTERVAL_SECONDS = 5 +# user_code 去混淆字母表(无 0/O/1/I) +_USER_CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" + + +def hash_token(raw_token: str) -> str: + """令牌 SHA-256 哈希(DB 只存哈希,不存明文)""" + return sha256(raw_token.encode("utf-8")).hexdigest() + + +def generate_api_token() -> tuple[str, str, str]: + """生成 API 令牌。 + + Returns: + (raw, prefix, token_hash) —— raw 仅在创建时返回一次; + prefix 用于列表展示;token_hash 入库。 + """ + raw = API_TOKEN_PREFIX + secrets.token_urlsafe(32) + return raw, raw[:12], hash_token(raw) + + +def generate_user_code() -> str: + """生成设备授权 user_code,格式 XXXX-XXXX""" + body = "".join(secrets.choice(_USER_CODE_ALPHABET) for _ in range(8)) + return f"{body[:4]}-{body[4:]}" + + +def generate_device_code() -> tuple[str, str]: + """生成设备码。 + + Returns: + (raw, device_code_hash) —— raw 给 CLI 轮询用,哈希入库。 + """ + raw = secrets.token_urlsafe(32) + return raw, hash_token(raw) + + +def utc_now() -> datetime: + return datetime.now(UTC) + def verify_password(plain_password: str, hashed_password: str) -> bool: """验证密码""" diff --git a/packages/storage/db.py b/packages/storage/db.py index 427cf02..7a5580e 100644 --- a/packages/storage/db.py +++ b/packages/storage/db.py @@ -139,18 +139,35 @@ def _safe_create_index(conn, idx_name: str, table: str, column: str) -> None: conn.rollback() +def _run_alembic_upgrade() -> None: + """PG:启动时执行 alembic upgrade head,让 auto_deploy 合并即上线新表。 + + 失败不阻断启动(schema 可能已是最新的情况),仅记日志。 + """ + try: + from pathlib import Path + + from alembic import command + from alembic.config import Config + + repo_root = Path(__file__).resolve().parents[2] + cfg = Config(str(repo_root / "alembic.ini")) + cfg.set_main_option("script_location", str(repo_root / "infra" / "migrations")) + command.upgrade(cfg, "head") + logger.info("alembic upgrade head 完成(PG)") + except Exception: + logger.exception("alembic upgrade head 失败(启动继续,schema 可能需要手动迁移)") + + def run_migrations() -> None: """启动时执行轻量级数据库迁移 - 注意:Alembic(infra/migrations)是 schema 迁移的权威路径,新库应通过 - `alembic upgrade head` 建表。本函数仅作运行时增量兜底,处理历史遗留库的 - 列/索引补齐,不替代 alembic 工作流。新表应通过 alembic 迁移添加。 - - PostgreSQL 等远程库由 alembic 管理schema,此处跳过 DDL 兜底 - (其内部含 sqlite_master、DATETIME DEFAULT、JSON 等 SQLite 专属写法)。 + 注意:Alembic(infra/migrations)是 schema 迁移的权威路径。PG 启动时自动 + `alembic upgrade head`(幂等);SQLite 走运行时增量兜底,处理历史遗留库的 + 列/索引补齐,不替代 alembic 工作流。 """ if not _is_sqlite: - logger.info("非 SQLite 库,跳过 run_migrations 兜底(由 alembic 管理 schema)") + _run_alembic_upgrade() return with engine.connect() as conn: _safe_add_column( diff --git a/packages/storage/models.py b/packages/storage/models.py index 85a42ae..2b49258 100644 --- a/packages/storage/models.py +++ b/packages/storage/models.py @@ -671,3 +671,53 @@ class BatchJob(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False) started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + +# ========== 认证:API 令牌 / 设备码授权 ========== + + +class ApiToken(Base): + """API 令牌(CLI / MCP / 外部 harness 用)。DB 只存 SHA-256 哈希,明文仅创建时返回一次。""" + + __tablename__ = "api_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(128), nullable=False) + token_prefix: Mapped[str] = mapped_column(String(16), nullable=False) # 列表展示用 + token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + scopes: Mapped[list] = mapped_column( + JSONB_or_JSON(), nullable=False, default=lambda: ["read", "write"] + ) + created_by: Mapped[str] = mapped_column(String(16), nullable=False, default="web") # web|device + device_request_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + def is_active(self) -> bool: + """是否可用(未吊销且未过期)。读回的 datetime 为 naive UTC,统一归一化后比较。""" + now = _utcnow().replace(tzinfo=None) + if self.revoked_at is not None: + return False + return not (self.expires_at is not None and self.expires_at.replace(tzinfo=None) < now) + + +class DeviceAuthRequest(Base): + """设备码授权请求(CLI pm login 流程),状态机 pending → approved/denied/expired""" + + __tablename__ = "device_auth_requests" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + device_code_hash: Mapped[str] = mapped_column( + String(64), nullable=False, unique=True, index=True + ) + user_code: Mapped[str] = mapped_column(String(9), nullable=False, unique=True, index=True) + client_name: Mapped[str] = mapped_column(String(128), nullable=False, default="pm-cli") + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True) + api_token_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("api_tokens.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + approved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/packages/storage/repositories/__init__.py b/packages/storage/repositories/__init__.py index 107c2f3..783680f 100644 --- a/packages/storage/repositories/__init__.py +++ b/packages/storage/repositories/__init__.py @@ -14,10 +14,12 @@ AgentPendingActionRepository, ) from packages.storage.repositories.analysis import AnalysisRepository +from packages.storage.repositories.api_token import ApiTokenRepository from packages.storage.repositories.batch import BatchJobRepository from packages.storage.repositories.citation import CitationRepository from packages.storage.repositories.cs_feed import CSFeedRepository from packages.storage.repositories.daily_report import DailyReportConfigRepository +from packages.storage.repositories.device_auth import DeviceAuthRequestRepository from packages.storage.repositories.email_config import EmailConfigRepository from packages.storage.repositories.generated_content import GeneratedContentRepository from packages.storage.repositories.ieee_quota import IeeeQuotaRepository @@ -48,4 +50,6 @@ "AgentPendingActionRepository", "CSFeedRepository", "BatchJobRepository", + "ApiTokenRepository", + "DeviceAuthRequestRepository", ] diff --git a/packages/storage/repositories/api_token.py b/packages/storage/repositories/api_token.py new file mode 100644 index 0000000..57261a0 --- /dev/null +++ b/packages/storage/repositories/api_token.py @@ -0,0 +1,82 @@ +""" +API 令牌仓储 +@author Color2333 +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from sqlalchemy import select + +from packages.auth import generate_api_token +from packages.storage.models import ApiToken + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + +class ApiTokenRepository: + """API 令牌数据仓储""" + + def __init__(self, session: Session): + self.session = session + + def create( + self, + name: str, + scopes: list[str], + created_by: str = "web", + expires_at: datetime | None = None, + device_request_id: str | None = None, + ) -> tuple[ApiToken, str]: + """创建令牌。 + + Returns: + (token, raw) —— raw 明文仅此处返回一次,DB 存哈希。 + """ + raw, prefix, token_hash = generate_api_token() + token = ApiToken( + name=name, + token_prefix=prefix, + token_hash=token_hash, + scopes=scopes, + created_by=created_by, + expires_at=expires_at, + device_request_id=device_request_id, + ) + self.session.add(token) + self.session.flush() + return token, raw + + def get_by_hash(self, token_hash: str) -> ApiToken | None: + """按哈希查令牌(中间件 / MCP verifier 用)""" + q = select(ApiToken).where(ApiToken.token_hash == token_hash) + return self.session.execute(q).scalar_one_or_none() + + def get_by_id(self, token_id: str) -> ApiToken | None: + return self.session.get(ApiToken, token_id) + + def list_all(self, include_revoked: bool = False) -> list[ApiToken]: + """列出令牌,创建时间倒序""" + q = select(ApiToken).order_by(ApiToken.created_at.desc()) + if not include_revoked: + q = q.where(ApiToken.revoked_at.is_(None)) + return list(self.session.execute(q).scalars().all()) + + def revoke(self, token_id: str) -> bool: + """吊销令牌(软删除,保留审计记录)""" + token = self.get_by_id(token_id) + if token is None or token.revoked_at is not None: + return False + token.revoked_at = datetime.now(UTC) + self.session.flush() + return True + + def touch_last_used(self, token_id: str) -> None: + """更新最近使用时间(调用方负责节流)""" + token = self.get_by_id(token_id) + if token is not None: + token.last_used_at = datetime.now(UTC) + self.session.flush() diff --git a/packages/storage/repositories/device_auth.py b/packages/storage/repositories/device_auth.py new file mode 100644 index 0000000..f0d39af --- /dev/null +++ b/packages/storage/repositories/device_auth.py @@ -0,0 +1,102 @@ +""" +设备码授权请求仓储(pm login 设备码流程) +@author Color2333 +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import delete, select + +from packages.auth import ( + DEVICE_CODE_EXPIRE_SECONDS, + generate_device_code, + generate_user_code, +) +from packages.storage.models import DeviceAuthRequest + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + +def naive_utc() -> datetime: + """DB 读回的是 naive UTC,写比较值也用 naive 保持一致""" + return datetime.now(UTC).replace(tzinfo=None) + + +class DeviceAuthRequestRepository: + """设备码授权请求数据仓储""" + + def __init__(self, session: Session): + self.session = session + + def create(self, client_name: str) -> tuple[DeviceAuthRequest, str]: + """创建授权请求。 + + Returns: + (request, device_code) —— device_code 明文给 CLI 轮询用,DB 存哈希。 + """ + self.cleanup_expired() + raw_code, code_hash = generate_device_code() + req = DeviceAuthRequest( + device_code_hash=code_hash, + user_code=generate_user_code(), + client_name=client_name or "pm-cli", + expires_at=naive_utc() + timedelta(seconds=DEVICE_CODE_EXPIRE_SECONDS), + ) + self.session.add(req) + self.session.flush() + return req, raw_code + + def get_by_device_code(self, device_code: str) -> DeviceAuthRequest | None: + from packages.auth import hash_token + + q = select(DeviceAuthRequest).where( + DeviceAuthRequest.device_code_hash == hash_token(device_code) + ) + return self.session.execute(q).scalar_one_or_none() + + def get_by_user_code(self, user_code: str) -> DeviceAuthRequest | None: + user_code = user_code.strip().upper() + q = select(DeviceAuthRequest).where(DeviceAuthRequest.user_code == user_code) + return self.session.execute(q).scalar_one_or_none() + + def approve(self, request_id: str) -> DeviceAuthRequest | None: + """批准授权请求(令牌在 CLI 首次轮询时创建并交付)""" + req = self.session.get(DeviceAuthRequest, request_id) + if req is None or req.status != "pending" or self.is_expired(req): + return None + req.status = "approved" + req.approved_at = naive_utc() + self.session.flush() + return req + + def deny(self, request_id: str) -> DeviceAuthRequest | None: + req = self.session.get(DeviceAuthRequest, request_id) + if req is None or req.status != "pending": + return None + req.status = "denied" + self.session.flush() + return req + + def is_expired(self, req: DeviceAuthRequest) -> bool: + return req.expires_at.replace(tzinfo=None) < naive_utc() + + def expire_stale(self, req: DeviceAuthRequest) -> DeviceAuthRequest: + """把已过期的 pending 请求标记为 expired""" + req.status = "expired" + self.session.flush() + return req + + def cleanup_expired(self) -> int: + """删除已终结(expired/denied)或创建超过 24h 的请求""" + cutoff = naive_utc() - timedelta(hours=24) + q = delete(DeviceAuthRequest).where( + (DeviceAuthRequest.status.in_(["expired", "denied"])) + | (DeviceAuthRequest.created_at < cutoff) + ) + result = self.session.execute(q) + self.session.flush() + return result.rowcount or 0 diff --git a/pyproject.toml b/pyproject.toml index a6e8d7a..3f516ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "python-jose[cryptography]>=3.3.0", "passlib[bcrypt]>=1.7.4", "sqlalchemy>=2.0.43", + "typer>=0.12", "uvicorn>=0.35.0", ] @@ -26,6 +27,7 @@ dev = [ "pytest>=8.4.1", "ruff>=0.12.12", "pre-commit>=4.0.0", + "pyinstaller>=6.0", ] llm = [ "openai>=1.102.0", @@ -48,10 +50,14 @@ graph = [ requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" +[project.scripts] +pm = "apps.cli.main:app" + [tool.setuptools] packages = [ "apps", "apps.api", + "apps.cli", "apps.worker", "packages", "packages.ai", diff --git a/scripts/build-pm-cli.sh b/scripts/build-pm-cli.sh new file mode 100755 index 0000000..710de72 --- /dev/null +++ b/scripts/build-pm-cli.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# 本地构建 pm CLI 单文件可执行(各平台在自己机器上跑即可) +# 依赖: pip install pyinstaller (输出 dist/pm,Windows 为 dist/pm.exe) +set -euo pipefail +cd "$(dirname "$0")/.." + +pyinstaller --onefile --clean --noconfirm --name pm \ + --paths . \ + --hidden-import "apps.cli.main" \ + --hidden-import "apps.cli.client" \ + --hidden-import "apps.cli.config" \ + --collect-data certifi \ + apps/cli/__main__.py + +echo +echo "构建完成: dist/pm" diff --git a/scripts/install-pm.ps1 b/scripts/install-pm.ps1 new file mode 100644 index 0000000..6bb4fef --- /dev/null +++ b/scripts/install-pm.ps1 @@ -0,0 +1,29 @@ +# pm CLI 一键安装(Windows,PowerShell) +# +# 用法: +# irm https://raw.githubusercontent.com/Color2333/PaperMind/main/scripts/install-pm.ps1 | iex +# +# 从 GitHub Releases 下载 pm-windows-x86_64.exe 到 %LOCALAPPDATA%\Programs\pm +$ErrorActionPreference = "Stop" + +$Repo = "Color2333/PaperMind" +$InstallDir = "$env:LOCALAPPDATA\Programs\pm" +$Asset = "pm-windows-x86_64.exe" +$Url = "https://github.com/$Repo/releases/latest/download/$Asset" + +Write-Host "下载 $Url" +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +$Dest = Join-Path $InstallDir "pm.exe" +Invoke-WebRequest -Uri $Url -OutFile $Dest + +Write-Host "" +Write-Host "✓ 安装到 $Dest" +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($userPath -notlike "*$InstallDir*") { + [Environment]::SetEnvironmentVariable("Path", "$userPath;$InstallDir", "User") + Write-Host "✓ 已加入用户 PATH(新开终端生效)" +} + +& $Dest --version +Write-Host "" +Write-Host "接下来: pm login --server https://你的-papermind-地址" diff --git a/scripts/install-pm.sh b/scripts/install-pm.sh new file mode 100755 index 0000000..67a46bc --- /dev/null +++ b/scripts/install-pm.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# pm CLI 一键安装(macOS / Linux) +# +# 用法: +# curl -fsSL https://raw.githubusercontent.com/Color2333/PaperMind/main/scripts/install-pm.sh | bash +# +# 从 GitHub Releases 下载对应平台二进制到 ~/.local/bin/pm +set -euo pipefail + +REPO="Color2333/PaperMind" +INSTALL_DIR="${PM_INSTALL_DIR:-$HOME/.local/bin}" + +platform() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Darwin) os="darwin" ;; + Linux) os="linux" ;; + *) echo "不支持的系统: $os(Windows 请用 install-pm.ps1)" >&2; exit 1 ;; + esac + case "$arch" in + arm64 | aarch64) arch="arm64" ;; + x86_64 | amd64) arch="x86_64" ;; + *) echo "不支持的架构: $arch" >&2; exit 1 ;; + esac + echo "${os}-${arch}" +} + +TARGET="$(platform)" +ASSET="pm-${TARGET}" +URL="https://github.com/${REPO}/releases/latest/download/${ASSET}" + +echo "下载 ${URL}" +mkdir -p "$INSTALL_DIR" +if command -v curl >/dev/null 2>&1; then + curl -fsSL "$URL" -o "$INSTALL_DIR/pm" +elif command -v wget >/dev/null 2>&1; then + wget -qO "$INSTALL_DIR/pm" "$URL" +else + echo "需要 curl 或 wget" >&2 + exit 1 +fi +chmod +x "$INSTALL_DIR/pm" + +# macOS:去除隔离属性,避免 Gatekeeper 拦截未公证二进制 +if [ "$(uname -s)" = "Darwin" ] && command -v xattr >/dev/null 2>&1; then + xattr -d com.apple.quarantine "$INSTALL_DIR/pm" 2>/dev/null || true +fi + +echo +if echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then + echo "✓ 安装完成: $(command -v pm)" +else + echo "✓ 安装到 $INSTALL_DIR/pm" + echo " 请把它加入 PATH(加到 ~/.zshrc 或 ~/.bashrc):" + echo " export PATH=\"$INSTALL_DIR:\$PATH\"" +fi +"$INSTALL_DIR/pm" --version +echo +echo "接下来: pm login --server https://你的-papermind-地址" diff --git a/tests/test_auth_tokens.py b/tests/test_auth_tokens.py new file mode 100644 index 0000000..b59800e --- /dev/null +++ b/tests/test_auth_tokens.py @@ -0,0 +1,322 @@ +""" +API 令牌 / 设备码授权 / 中间件 scope 强制 测试 +@author Color2333 +""" + +from __future__ import annotations + +import os + +# 必须在导入 apps.api.main 之前强制开启认证并清 settings 缓存, +# 否则中间件读到"未启用认证"的缓存实例,401/403 行为测不了。 +os.environ["AUTH_PASSWORD"] = "pm-test-password" +os.environ["AUTH_SECRET_KEY"] = "0" * 64 # 强随机占位,避开启动弱密钥守卫 + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +import packages.config as _config # noqa: E402 + +_config.get_settings.cache_clear() + +from apps.api.main import AuthMiddleware # noqa: E402 +from apps.api.routers import auth as auth_router_module # noqa: E402 +from packages.auth import ( # noqa: E402 + API_TOKEN_PREFIX, + generate_api_token, + generate_user_code, + hash_token, +) +from packages.storage.db import session_scope # noqa: E402 +from packages.storage.repositories import ( # noqa: E402 + ApiTokenRepository, + DeviceAuthRequestRepository, +) + + +@pytest.fixture(scope="module", autouse=True) +def _restore_auth_env(): + """模块结束后恢复环境变量并清缓存,避免影响其他测试文件""" + yield + os.environ.pop("AUTH_PASSWORD", None) + os.environ.pop("AUTH_SECRET_KEY", None) + _config.get_settings.cache_clear() + + +@pytest.fixture +def client(isolated_db): + """挂了 AuthMiddleware + auth 路由的最小应用(不触发 lifespan/migrations)""" + app = FastAPI() + app.include_router(auth_router_module.router) + app.add_middleware(AuthMiddleware) + return TestClient(app) + + +@pytest.fixture +def jwt_headers(client): + resp = client.post("/auth/login", json={"password": "pm-test-password"}) + assert resp.status_code == 200 + return {"Authorization": f"Bearer {resp.json()['access_token']}"} + + +def _create_token(scopes: list[str], name: str = "test-token") -> tuple[str, str]: + """直接经仓库建令牌,返回 (raw, id)""" + with session_scope() as session: + token, raw = ApiTokenRepository(session).create(name=name, scopes=scopes) + return raw, token.id + + +# ---------- 令牌工具 ---------- + + +def test_generate_api_token_format(): + raw, prefix, token_hash = generate_api_token() + assert raw.startswith(API_TOKEN_PREFIX) + assert prefix == raw[:12] + assert token_hash == hash_token(raw) + assert token_hash != raw + assert len(token_hash) == 64 + raw2, _, hash2 = generate_api_token() + assert raw2 != raw and hash2 != token_hash + + +def test_generate_user_code_format(): + code = generate_user_code() + assert len(code) == 9 + assert code[4] == "-" + allowed = set("23456789ABCDEFGHJKMNPQRSTUVWXYZ") + assert set(code.replace("-", "")) <= allowed + + +# ---------- 令牌仓库 ---------- + + +def test_token_lifecycle_and_revocation(isolated_db): + raw, token_id = _create_token(["read", "write"]) + with session_scope() as session: + repo = ApiTokenRepository(session) + token = repo.get_by_hash(hash_token(raw)) + assert token is not None and token.is_active() + assert token.last_used_at is None + repo.touch_last_used(token_id) + assert repo.get_by_hash(hash_token(raw)).last_used_at is not None + assert repo.revoke(token_id) is True + assert repo.revoke(token_id) is False # 幂等 + assert repo.get_by_hash(hash_token(raw)).is_active() is False + assert lookup_api_token_helper(raw) is None # 吊销后拒绝 + + +def test_token_expiry(isolated_db): + from datetime import UTC, datetime, timedelta + + past = datetime.now(UTC) - timedelta(days=1) + with session_scope() as session: + token, _ = ApiTokenRepository(session).create( + name="expired", scopes=["read"], expires_at=past + ) + assert token.is_active() is False + + +def test_lookup_api_token(isolated_db): + raw, _ = _create_token(["read"], name="ro-token") + info = lookup_api_token_helper(raw) + assert info is not None + assert info.name == "ro-token" + assert info.scopes == ["read"] + assert lookup_api_token_helper("not-a-pmt-token") is None + assert lookup_api_token_helper(raw + "tampered") is None + + +def lookup_api_token_helper(raw: str): + from apps.api.token_auth import lookup_api_token + + return lookup_api_token(raw) + + +# ---------- 设备码授权流程(仓库层) ---------- + + +def test_device_flow_repository_lifecycle(isolated_db): + with session_scope() as session: + repo = DeviceAuthRequestRepository(session) + req, device_code = repo.create("my-macbook") + assert req.status == "pending" + assert "-" in req.user_code + found = repo.get_by_device_code(device_code) + assert found is not None and found.id == req.id + assert repo.get_by_user_code(req.user_code.lower()).id == req.id # 大小写归一 + assert repo.approve(req.id) is not None + assert repo.approve(req.id) is None # 非 pending 再批准失败 + assert repo.get_by_device_code(device_code).status == "approved" + + with session_scope() as session: + repo = DeviceAuthRequestRepository(session) + req2, _ = repo.create("denied-cli") + assert repo.deny(req2.id) is not None + assert repo.deny(req2.id) is None + + +# ---------- 中间件 + 路由(HTTP 层) ---------- + + +def test_login_and_me(client): + resp = client.post("/auth/login", json={"password": "wrong"}) + assert resp.status_code == 401 + + resp = client.get("/auth/me") + assert resp.status_code == 401 # 无凭证 + + resp = client.post("/auth/login", json={"password": "pm-test-password"}) + assert resp.status_code == 200 + headers = {"Authorization": f"Bearer {resp.json()['access_token']}"} + + resp = client.get("/auth/me", headers=headers) + assert resp.status_code == 200 + assert resp.json()["auth_method"] == "jwt" + + +def test_middleware_api_token_read_write_scope(client, isolated_db): + read_raw, _ = _create_token(["read"]) + write_raw, _ = _create_token(["write"]) + + # GET 需要 read:read 令牌可访问,write 令牌 403 + assert ( + client.get("/auth/me", headers={"Authorization": f"Bearer {read_raw}"}).status_code == 200 + ) + resp = client.get("/auth/me", headers={"Authorization": f"Bearer {write_raw}"}) + assert resp.status_code == 403 + + # POST 需要 write + resp = client.post( + "/auth/tokens", + json={"name": "x"}, + headers={"Authorization": f"Bearer {write_raw}"}, + ) + # 通过中间件 scope 检查,但被 require_web_session 拦截(API 令牌不能管理令牌) + assert resp.status_code == 403 + assert "网页登录会话" in resp.json()["detail"] + resp = client.post( + "/auth/tokens", json={"name": "x"}, headers={"Authorization": f"Bearer {read_raw}"} + ) + assert resp.status_code == 403 # 连 scope 检查都不过 + + # 无效令牌 401 + assert ( + client.get("/auth/me", headers={"Authorization": "Bearer pmt_invalid"}).status_code == 401 + ) + + +def test_token_management_endpoints(client, jwt_headers): + # 创建 + resp = client.post( + "/auth/tokens", + json={"name": "cli-macbook", "scopes": ["read"], "expires_in_days": 30}, + headers=jwt_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["token"].startswith(API_TOKEN_PREFIX) + raw = body["token"] + + # 新令牌立即可用,且 me 显示 api_token + me = client.get("/auth/me", headers={"Authorization": f"Bearer {raw}"}) + assert me.json()["auth_method"] == "api_token" + assert me.json()["scopes"] == ["read"] + assert me.json()["token_name"] == "cli-macbook" + + # 列表脱敏(不含明文) + resp = client.get("/auth/tokens", headers=jwt_headers) + assert resp.status_code == 200 + items = resp.json() + assert len(items) == 1 + assert items[0]["token_prefix"] == body["token_prefix"] + assert "token" not in items[0] + + # 明文 token 不能调管理接口 + assert client.get("/auth/tokens", headers={"Authorization": f"Bearer {raw}"}).status_code == 403 + + # 吊销后立即失效 + resp = client.delete(f"/auth/tokens/{body['id']}", headers=jwt_headers) + assert resp.status_code == 200 + assert client.get("/auth/me", headers={"Authorization": f"Bearer {raw}"}).status_code == 401 + assert client.get("/auth/tokens", headers=jwt_headers).json() == [] + + +def test_device_flow_end_to_end(client, jwt_headers): + # 1. CLI 发起 + resp = client.post("/auth/device/start", json={"client_name": "my-macbook"}) + assert resp.status_code == 200 + start = resp.json() + assert start["user_code"].count("-") == 1 + assert start["verification_url"].endswith(f"/device?user_code={start['user_code']}") + device_code = start["device_code"] + + def poll(): + auth_router_module._poll_last.clear() # 测试中绕过轮询节流 + return client.post("/auth/device/poll", json={"device_code": device_code}) + + # 2. 批准前轮询 → pending + assert poll().json()["status"] == "pending" + + # 3. 未登录不能查授权信息/批准 + assert client.get(f"/auth/device/{start['user_code']}").status_code == 401 + + # 4. 网页会话查看 + 批准 + info = client.get(f"/auth/device/{start['user_code']}", headers=jwt_headers) + assert info.status_code == 200 + assert info.json()["client_name"] == "my-macbook" + assert client.post(f"/auth/device/{start['user_code']}/deny").status_code == 401 + + resp = client.post(f"/auth/device/{start['user_code']}/authorize", headers=jwt_headers) + assert resp.json()["status"] == "approved" + + # 5. 首次轮询拿到令牌(明文仅一次),之后 delivered + first = poll().json() + assert first["status"] == "approved" and first["access_token"] + raw = first["access_token"] + assert poll().json()["status"] == "delivered" + + # 6. 设备签发的令牌可用,出现在令牌列表 + me = client.get("/auth/me", headers={"Authorization": f"Bearer {raw}"}) + assert me.json()["auth_method"] == "api_token" + assert "my-macbook" in me.json()["token_name"] + assert len(client.get("/auth/tokens", headers=jwt_headers).json()) == 1 + + +def test_device_flow_deny_and_invalid(client, jwt_headers): + resp = client.post("/auth/device/start", json={}) + start = resp.json() + + def poll(): + auth_router_module._poll_last.clear() + return client.post("/auth/device/poll", json={"device_code": start["device_code"]}) + + assert ( + client.post(f"/auth/device/{start['user_code']}/deny", headers=jwt_headers).json()["status"] + == "denied" + ) + assert poll().json()["status"] == "denied" + + assert client.post("/auth/device/poll", json={"device_code": "bogus"}).status_code == 404 + assert client.get("/auth/device/ZZZZ-ZZZZ", headers=jwt_headers).status_code == 404 + + +def test_token_can_revoke_itself(client, isolated_db): + """pm logout 场景:令牌可以吊销自己,但不能吊销别人/管理列表""" + raw_a, id_a = _create_token(["read", "write"], name="self-revoke") + _, id_b = _create_token(["read", "write"], name="other") + headers = {"Authorization": f"Bearer {raw_a}"} + + resp = client.delete(f"/auth/tokens/{id_b}", headers=headers) + assert resp.status_code == 403 # 不能吊销别的令牌 + + resp = client.delete(f"/auth/tokens/{id_a}", headers=headers) + assert resp.status_code == 200 # 可以吊销自己 + assert client.get("/auth/me", headers=headers).status_code == 401 + + +def test_device_start_poll_whitelisted_no_auth_required(client): + """start/poll 在认证中间件白名单内(无凭证也能访问)""" + resp = client.post("/auth/device/start", json={"client_name": "wh"}) + assert resp.status_code == 200