diff --git a/.env.docker.example b/.env.docker.example index 2162f948..2351eb16 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -7,9 +7,14 @@ FRONTEND_PORT=3010 API_PORT=8031 PUBLIC_URL=http://localhost:8031 -# Required. The installers generate all four values automatically. +# Required deployment credentials. The installers generate all five values automatically. API_AUTH_TOKEN= BOOTSTRAP_ADMIN_TOKEN= +# One-time, human-readable code used to claim a new local device. +DEVICE_CLAIM_CODE= +# Set true only when the console is exposed exclusively through an HTTPS +# reverse proxy. Keep false for direct localhost/LAN HTTP access. +LOCAL_SESSION_COOKIE_SECURE=false SECRET_KEY= CREDENTIAL_ENCRYPTION_KEY= diff --git a/.env.example b/.env.example index 795b6628..b3a0714a 100644 --- a/.env.example +++ b/.env.example @@ -17,26 +17,31 @@ CREDENTIAL_ENCRYPTION_KEY= # Fleet 鉴权(ADR-0005):所有 /api 与 /mcp 路由的静态 Bearer Token。 # 留空 = 关闭鉴权(开发姿态,启动时仅允许绑定 localhost); # 非 localhost 绑定(如 Docker 的 0.0.0.0)必须设置,否则启动直接拒绝。 -# 前端取值:构建期 VITE_API_AUTH_TOKEN,或浏览器 localStorage 'apiAuthToken'(优先)。 +# 机器客户端取值:API_AUTH_TOKEN;本地设备控制台不应把该值作为日常登录凭据。 # MCP server / CLI(backend/mcp_server.py、backend/cli.py)读同名环境变量。 # 一旦设置此值,边缘 Agent(backend/agent_server.py)也必须设置同值的 AGENT_API_TOKEN # (或直接复用 API_AUTH_TOKEN)——覆盖 HTTP 注册(/api/v1/nodes/register)和 # WS 反向通道(/api/v1/nodes/ws、/api/v1/browsers/agents/ws)两条握手路径。 API_AUTH_TOKEN= +# 首次安装的一次性设备认领码。安装器自动生成 10 位易读码;认领后仅保留恢复用途。 +DEVICE_CLAIM_CODE= +# 仅当控制台始终通过 HTTPS 访问、且 TLS 在反向代理终止时设为 true。 +# localhost/LAN 直接 HTTP 保持 false,否则浏览器不会发送 Secure 会话 Cookie。 +LOCAL_SESSION_COOKIE_SECURE=false +# 紧急恢复管理员令牌。它不是日常登录密码,安装器只把它保存在 .env。 +BOOTSTRAP_ADMIN_TOKEN= + # MCP Streamable HTTP 的 DNS-rebinding 允许列表;PUBLIC_URL 已自动加入。 # 仅在同一部署还通过额外域名访问 /mcp 时补充,逗号分隔。 OPENCLI_MCP_ALLOWED_HOSTS= OPENCLI_MCP_ALLOWED_ORIGINS= -# 组织身份验证(标准 OpenID Connect;提供方可为 Gitea、Keycloak、Authentik、 -# Azure AD 或任何兼容 OIDC 的企业身份源)。 +# 可选的组织身份验证(标准 OpenID Connect)。家庭/NAS 默认先使用本地设备认领; +# 需要多用户或企业身份源时再配置 Gitea、Keycloak、Authentik、Azure AD 等 OIDC。 OIDC_ISSUER= OIDC_AUDIENCE= # 可选;留空时后端通过 /.well-known/openid-configuration 发现 jwks_uri。 OIDC_JWKS_URL= -# 仅用于首次部署或紧急恢复,不替代正式 OIDC 登录。 -BOOTSTRAP_ADMIN_TOKEN= - # 服务端口(两种启动模式均生效) API_PORT=8031 # API 服务对外端口 NOVNC_PORT=6080 # Agent 实例 1 noVNC 对外端口(docker-compose port mapping) diff --git a/.env.nas.example b/.env.nas.example index 9085f811..04f3648d 100644 --- a/.env.nas.example +++ b/.env.nas.example @@ -1,16 +1,31 @@ -# NAS 24×7 部署模板 — III 控制面 + ODP 数据面 -# 路径: /volume1/docker/opencli-admin/ +# NAS 24×7 完整数据面参考 — III + ODP + Postgres + Redis。 +# 这不是家庭设备默认栈;普通 NAS/软路由优先运行安装器提供的 3 服务栈。 +# 为兼容既有部署,完整参考仍使用 profile 名 `nas`。 +# 路径示例: /volume1/docker/opencli-admin/ +# 使用前:复制为 .env,并把所有 CHANGE_*/REPLACE_* 哨兵值替换为独立随机值。 # 启动: docker compose --profile nas up -d --build COMPOSE_PROJECT_NAME=opencli-admin -IMAGE_TAG=0.3.6 +DOCKER_REGISTRY=ghcr.io/ +DOCKER_IMAGE_NAMESPACE=2233admin +IMAGE_TAG=0.4.0 -# 对外访问(LAN) -PUBLIC_URL=http://192.168.50.130:8031 +# 对外访问(LAN)。留空时由请求推断;反向代理或远程 Agent 场景请填写真实地址。 +PUBLIC_URL= API_PORT=8031 -FRONTEND_PORT=8030 +FRONTEND_PORT=3010 AGENT1_PORT=19823 +# 必需凭据。以下是明显的哨兵值,仅用于让 Compose 配置可解析,禁止原样启动。 +# 推荐先运行标准安装器生成安全值,再复制到这份完整栈配置。 +API_AUTH_TOKEN=CHANGE_ME_WITH_64_HEX_MACHINE_TOKEN +BOOTSTRAP_ADMIN_TOKEN=CHANGE_ME_WITH_64_HEX_RECOVERY_TOKEN +DEVICE_CLAIM_CODE=CHANGE2345 +# 仅在控制台始终经 HTTPS 反向代理访问时启用;局域网 HTTP 必须保持 false。 +LOCAL_SESSION_COOKIE_SECURE=false +SECRET_KEY=CHANGE_ME_WITH_64_HEX_APPLICATION_SECRET +CREDENTIAL_ENCRYPTION_KEY=REPLACE_WITH_URLSAFE_BASE64_FERNET_KEY + # 采集编排:III 负责 cron;API 仅 UI + 手动触发 COLLECTION_ORCHESTRATOR=iii COLLECTION_MODE=local @@ -32,10 +47,10 @@ DIFY_SANDBOX_API_KEY= DIFY_GRAPHON_SLIM_PATH= # 数据面 — Postgres + ODP ingest -DATABASE_URL=postgresql+asyncpg://opencli:opencli_secret@postgres:5432/opencli_admin POSTGRES_DB=opencli_admin POSTGRES_USER=opencli -POSTGRES_PASSWORD=change-me-in-production +POSTGRES_PASSWORD=CHANGE_ME_WITH_LONG_RANDOM_DATABASE_PASSWORD +DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} ODP_INGEST_URL=http://odp-ingest:8040 III_URL=ws://iii-engine:49134 @@ -52,7 +67,6 @@ OPENCLI_TIMEOUT=120 # III_URL=ws://192.168.50.130:49134 # ODP_INGEST_URL=http://192.168.50.130:8040 -SECRET_KEY=change-me-in-production-use-long-random-string DEBUG=false APP_ENV=production DEFAULT_TIMEZONE=Asia/Shanghai diff --git a/README.md b/README.md index b93b5cfc..9fc31be6 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,16 @@ opencli-Razormind 是一个开源、自托管的研究与情报管线。它把 **登录采集账号 → 创建研究项目 → 编排工作流 → 执行与追踪 → 查看记录和证据 → 定时运行 / 对外交付** -## 一条命令启动 +## 安装本地设备 前置要求:Docker 与 Docker Compose。 +> **发布状态**:本机管理员与一次性设备认领目前已在当前源码和本地镜像中完成验证,尚未发布到 +> `v0.4.0` 的 GHCR 镜像。下面的 `v0.4.0` 一键命令仍安装旧登录版本;不要用它验收本文的 +> 本机管理员体验。发布下一版本前,请从当前源码按“开发与构建”中的 Compose build 方式部署。 + +已发布的 v0.4.0(旧登录)命令保留如下,供现有部署复现: + Linux / macOS: ~~~bash @@ -47,30 +53,38 @@ Invoke-WebRequest https://raw.githubusercontent.com/2233admin/opencli-Razormind/ .\install.ps1 ~~~ -安装器会生成安全密钥、拉取公开的多架构 GHCR 镜像、启动服务并等待健康检查通过。 +下一公开版本的安装器会生成安全密钥和一次性设备认领码,拉取对应的多架构 GHCR 镜像,启动默认的 API、控制台和内置浏览器三个服务,并等待健康检查通过。发布前不得把当前源码行为描述成 v0.4.0 镜像能力。 | 入口 | 地址 | 用途 | | --- | --- | --- | -| 管理界面 | http://localhost:3010 | 项目、工作流、运行和数据 | +| 控制台 | http://localhost:3010 | 首次认领、本地管理、项目、工作流、运行和数据 | | API 文档 | http://localhost:8031/docs | REST API 与集成调试 | -| 内置浏览器 | http://localhost:6080 | 扫码或登录需要账号的平台 | +| 内置浏览器 | http://localhost:6080 | 同机扫码或登录需要账号的平台 | + +安装完成后,终端只突出显示控制台地址、可可靠探测时的局域网地址,以及 10 位一次性设备认领码。首次使用: + +1. 从本机或局域网浏览器打开控制台; +2. 输入终端显示的设备认领码; +3. 创建本地管理员,之后使用本地账号日常登录。 -安装完成后,终端会打印: +安装器仍会生成 `BOOTSTRAP_ADMIN_TOKEN` 和 `API_AUTH_TOKEN`,但不会把值打印到终端:前者只用于紧急恢复,后者用于 Fleet、Agent、API 和 MCP 等机器访问,两者仅保存在安装目录的 `.env`。不要公开 `.env`、noVNC 或浏览器调试端口;远程部署建议使用 HTTPS、反向代理或 SSH 隧道。 -- `BOOTSTRAP_ADMIN_TOKEN`:首次进入管理界面使用; -- `API_AUTH_TOKEN`:Fleet、Agent、API 和 MCP 访问使用。 +OIDC 是可选的组织登录方式。家庭 NAS、软路由和个人工作站默认不需要先部署身份提供方;需要多用户或企业统一身份时,再在高级配置中接入 OIDC。 -两者同时保存在安装目录的 `.env`。不要公开 noVNC、令牌或浏览器调试端口;远程部署建议使用 HTTPS、反向代理或 SSH 隧道。 +### NAS 与软路由 + +家庭设备默认仍使用上述三个服务栈。仓库中的 `.env.nas.example` 是包含 III、ODP、PostgreSQL、Redis、Kats 和 Graphon 的 24×7 完整数据面参考,保留 `nas` profile 名仅为兼容既有部署;它不是低资源 NAS/软路由的默认入口。使用完整参考前,必须把所有 `CHANGE_*` / `REPLACE_*` 哨兵值换成独立随机值,并确认设备资源足够。 ## 正常的研究流程 -1. 打开 `:6080`,在内置 Chromium 中扫码或登录目标平台。公开 RSS、API 和网页来源可跳过这一步。 -2. 在「插件中心」确认 OpenCLI、RSS、API 或工具能力,在「项目」中从模板或空白项目开始。 -3. 在 Dify 风格的画布中连接来源、处理、Agent、Gate 和交付节点;右侧参数面板配置当前节点实际声明的业务参数。 -4. 保存、验证并发布工作流,手动执行已发布版本;Webhook 也可直接提交 `workflowProject` 触发运行。 -5. 在运行记录中查看节点事件、Trace、错误、重试和输出;采集结果统一进入「成果与数据」。 -6. 在项目内查看数据、逻辑与证据、证据关系和 Galaxy 视图。Galaxy 是证据关系的一种查看方式,不是独立的项目模块。 -7. 配置 Webhook、飞书、钉钉、企业微信或 Email,将通过规则和质量门的数据交付出去。 +1. 首次打开控制台时完成设备认领并创建本地管理员。 +2. 同机打开 `:6080`,在内置 Chromium 中扫码或登录目标平台。公开 RSS、API 和网页来源可跳过这一步。 +3. 在「插件中心」确认 OpenCLI、RSS、API 或工具能力,在「项目」中从模板或空白项目开始。 +4. 在 Dify 风格的画布中连接来源、处理、Agent、Gate 和交付节点;右侧参数面板配置当前节点实际声明的业务参数。 +5. 保存、验证并发布工作流,手动执行已发布版本;Webhook 也可直接提交 `workflowProject` 触发运行。 +6. 在运行记录中查看节点事件、Trace、错误、重试和输出;采集结果统一进入「成果与数据」。 +7. 在项目内查看数据、逻辑与证据、证据关系和 Galaxy 视图。Galaxy 是证据关系的一种查看方式,不是独立的项目模块。 +8. 配置 Webhook、飞书、钉钉、企业微信或 Email,将通过规则和质量门的数据交付出去。 ## 产品界面 @@ -198,10 +212,21 @@ uv run pytest ~~~bash cp .env.docker.example .env -# 设置 API_AUTH_TOKEN、BOOTSTRAP_ADMIN_TOKEN、SECRET_KEY、CREDENTIAL_ENCRYPTION_KEY +# 设置 API_AUTH_TOKEN、BOOTSTRAP_ADMIN_TOKEN、DEVICE_CLAIM_CODE、SECRET_KEY、CREDENTIAL_ENCRYPTION_KEY docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d ~~~ +从未包含本机管理员的旧版本升级时,先备份 `.env` 和数据卷,并在 `.env` 增加一个随机的 +`DEVICE_CLAIM_CODE`:必须为 10 位,字符仅使用 +`0123456789ABCDEFGHJKMNPQRSTVWXYZ`。不要复用 API、Bootstrap 或其他长期密钥。随后执行 +`docker compose pull && docker compose up -d`,打开控制台用该码创建本机管理员。若遗漏此项, +控制台会进入可恢复的“认领码尚未配置”状态并给出主机侧提示;已有 OIDC 的部署仍可使用原有 +组织登录入口。认领成功后该码不再能创建第二位 owner,但仍建议从 `.env` 删除并重启 API。 + +局域网或 localhost 直接使用 HTTP 时保持 `LOCAL_SESSION_COOKIE_SECURE=false`。如果控制台只通过 +HTTPS 反向代理访问(TLS 在代理处终止),请设为 `true`,确保本地登录会话 Cookie 始终带 +`Secure` 属性;启用后不要再通过 HTTP 地址访问控制台。 + ## 发布镜像 v0.4.0 同时发布 `linux/amd64` 和 `linux/arm64`: diff --git a/backend/api/v1/identity.py b/backend/api/v1/identity.py index 362ad6fe..6fe79055 100644 --- a/backend/api/v1/identity.py +++ b/backend/api/v1/identity.py @@ -1,27 +1,212 @@ -"""Request identity endpoint.""" +"""OIDC/bootstrap identity and local appliance-owner authentication.""" from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from backend.config import Settings, get_settings +from backend.database import commit_session, get_db, rollback_session from backend.schemas.common import ApiResponse +from backend.schemas.local_auth import ( + AuthIdentity, + LocalAuthStatus, + LocalLogin, + LocalOwnerSetup, + LogoutResult, +) from backend.security.identity import RequestIdentity, get_request_identity +from backend.security.local_auth import ( + SESSION_COOKIE_NAME, + DeviceClaimUnavailable, + InvalidDeviceClaim, + InvalidLocalCredentials, + LocalOwnerAlreadyInitialized, + LocalSessionGrant, + LocalSessionIdentity, + authenticate_local_owner, + claim_local_owner, + create_local_session, + device_claim_available, + local_auth_initialized, + revoke_local_session, +) router = APIRouter(prefix="/auth", tags=["auth"]) -@router.get("/me", response_model=ApiResponse[dict]) +@router.get("/status", response_model=ApiResponse[LocalAuthStatus]) +async def read_auth_status(db: AsyncSession = Depends(get_db)) -> ApiResponse: + settings = get_settings() + initialized = await local_auth_initialized(db) + return ApiResponse.ok( + LocalAuthStatus( + initialized=initialized, + claim_available=device_claim_available(settings), + oidc_enabled=bool(settings.oidc_issuer and settings.oidc_audience), + local_login_enabled=initialized, + recovery_enabled=bool(settings.bootstrap_admin_token), + ) + ) + + +@router.post( + "/setup", + response_model=ApiResponse[AuthIdentity], + status_code=status.HTTP_201_CREATED, +) +async def setup_local_owner( + body: LocalOwnerSetup, + request: Request, + response: Response, + db: AsyncSession = Depends(get_db), +) -> ApiResponse: + settings = get_settings() + try: + identity = await claim_local_owner( + db, + claim_code=body.claim_code, + username=body.username, + display_name=body.display_name, + password=body.password.get_secret_value(), + settings=settings, + ) + grant = await create_local_session( + db, + identity=identity, + remember_device=body.remember_device, + settings=settings, + ) + # The browser can follow the 201 immediately; persist both owner and + # session before exposing the cookie to avoid a first-request race. + await commit_session(db) + except LocalOwnerAlreadyInitialized as exc: + raise HTTPException(status.HTTP_409_CONFLICT, "Local owner is already initialized") from exc + except DeviceClaimUnavailable as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Device claim is not configured", + ) from exc + except InvalidDeviceClaim as exc: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid device claim code") from exc + except IntegrityError as exc: + # The unique singleton key is the final arbiter for concurrent setup + # requests; translate the losing transaction into the same stable 409. + await rollback_session(db) + raise HTTPException(status.HTTP_409_CONFLICT, "Local owner is already initialized") from exc + + _set_session_cookie(response, request, grant, body.remember_device, settings) + return ApiResponse.ok(_auth_identity(identity)) + + +@router.post("/login", response_model=ApiResponse[AuthIdentity]) +async def login_local_owner( + body: LocalLogin, + request: Request, + response: Response, + db: AsyncSession = Depends(get_db), +) -> ApiResponse: + settings = get_settings() + try: + identity = await authenticate_local_owner( + db, + username=body.username, + password=body.password.get_secret_value(), + settings=settings, + ) + except InvalidLocalCredentials as exc: + # Failed-attempt/lockout changes must survive the 401. The shared DB + # dependency rolls back when an endpoint raises, so commit this narrow + # authentication transaction before returning the uniform failure. + await commit_session(db) + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + "Invalid username or password", + ) from exc + + grant = await create_local_session( + db, + identity=identity, + remember_device=body.remember_device, + settings=settings, + ) + await commit_session(db) + _set_session_cookie(response, request, grant, body.remember_device, settings) + return ApiResponse.ok(_auth_identity(identity)) + + +@router.post("/logout", response_model=ApiResponse[LogoutResult]) +async def logout_local_owner( + request: Request, + response: Response, + db: AsyncSession = Depends(get_db), +) -> ApiResponse: + settings = get_settings() + await revoke_local_session(db, request.cookies.get(SESSION_COOKIE_NAME, "")) + # Persist revocation before the response deletes the browser cookie so a + # captured token cannot win a post-logout request race. + await commit_session(db) + response.delete_cookie( + SESSION_COOKIE_NAME, + path="/", + secure=_session_cookie_secure(request, settings), + httponly=True, + samesite="lax", + ) + return ApiResponse.ok(LogoutResult()) + + +@router.get("/me", response_model=ApiResponse[AuthIdentity]) async def read_identity( identity: Annotated[RequestIdentity, Depends(get_request_identity)], ) -> ApiResponse: - return ApiResponse.ok( - { - "subject": identity.subject, - "email": identity.email, - "name": identity.name, - "username": identity.username, - "picture": identity.picture, - "is_platform_admin": identity.is_platform_admin, - "auth_method": identity.auth_method, - } + return ApiResponse.ok(_auth_identity(identity)) + + +def _set_session_cookie( + response: Response, + request: Request, + grant: LocalSessionGrant, + remember_device: bool, + settings: Settings, +) -> None: + max_age = None + if remember_device: + max_age = max(60, settings.local_remember_session_ttl_seconds) + response.set_cookie( + SESSION_COOKIE_NAME, + grant.token, + max_age=max_age, + path="/", + secure=_session_cookie_secure(request, settings), + httponly=True, + samesite="lax", + ) + + +def _session_cookie_secure(request: Request, settings: Settings) -> bool: + """Honor direct HTTPS and explicit TLS-terminating proxy deployments.""" + return settings.local_session_cookie_secure or request.url.scheme == "https" + + +def _auth_identity(identity: RequestIdentity | LocalSessionIdentity) -> AuthIdentity: + if isinstance(identity, RequestIdentity): + return AuthIdentity( + subject=identity.subject, + email=identity.email, + name=identity.name, + username=identity.username, + picture=identity.picture, + is_platform_admin=identity.is_platform_admin, + auth_method=identity.auth_method, + ) + return AuthIdentity( + subject=identity.subject, + email=identity.email, + name=identity.name, + username=identity.username, + picture=None, + is_platform_admin=True, + auth_method="local", ) diff --git a/backend/config.py b/backend/config.py index 36b32378..db833b0f 100644 --- a/backend/config.py +++ b/backend/config.py @@ -72,6 +72,21 @@ class Settings(BaseSettings): oidc_jwks_url: str = "" bootstrap_admin_token: str = "" + # Single-owner appliance login. DEVICE_CLAIM_CODE is a deployment-created, + # one-time 10-character Crockford code: once the local owner credential + # exists, the setup endpoint is permanently closed. Browser sessions are + # opaque database records; the short TTL is used for tab/session cookies, + # while remember-device cookies use the longer TTL. + device_claim_code: str = "" + # Keep false for direct localhost/LAN HTTP. Set true when the public + # console is served exclusively over HTTPS but TLS terminates at a reverse + # proxy, so the internal ASGI request may still appear to use HTTP. + local_session_cookie_secure: bool = False + local_session_ttl_seconds: int = 43_200 + local_remember_session_ttl_seconds: int = 2_592_000 + local_login_max_failures: int = 5 + local_login_lock_seconds: int = 300 + # CLI channel binary allowlist (ADR-0005, audit P0-4). The cli channel is # an arbitrary-binary-execution surface, so it only runs binaries the # operator explicitly listed here. Comma-separated binary paths/names, diff --git a/backend/migrations/versions/l9m0n1o2p3q4_add_local_owner_auth.py b/backend/migrations/versions/l9m0n1o2p3q4_add_local_owner_auth.py new file mode 100644 index 00000000..61bf5e10 --- /dev/null +++ b/backend/migrations/versions/l9m0n1o2p3q4_add_local_owner_auth.py @@ -0,0 +1,76 @@ +"""add local owner credentials and opaque sessions + +Revision ID: l9m0n1o2p3q4 +Revises: k8l9m0n1o2p3 +Create Date: 2026-08-19 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "l9m0n1o2p3q4" +down_revision = "k8l9m0n1o2p3" +branch_labels = None +depends_on = None + + +def _timestamps(): + return ( + sa.Column("id", sa.String(36), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + +def upgrade() -> None: + op.create_table( + "local_credentials", + sa.Column( + "user_id", + sa.String(36), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("singleton_key", sa.String(16), nullable=False), + sa.Column("username", sa.String(64), nullable=False), + sa.Column("password_hash", sa.String(255), nullable=False), + sa.Column( + "failed_attempts", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("user_id"), + sa.UniqueConstraint("singleton_key"), + sa.UniqueConstraint("username"), + *_timestamps(), + ) + op.create_table( + "local_auth_sessions", + sa.Column( + "user_id", + sa.String(36), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("token_hash", sa.String(64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("token_hash"), + *_timestamps(), + ) + op.create_index( + "ix_local_auth_sessions_expires_at", + "local_auth_sessions", + ["expires_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_local_auth_sessions_expires_at", table_name="local_auth_sessions") + op.drop_table("local_auth_sessions") + op.drop_table("local_credentials") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 0da337e8..50a86e45 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -8,6 +8,8 @@ from backend.models.cookie_jar import CookieJarEntry from backend.models.edge_node import EdgeNode, EdgeNodeEvent from backend.models.identity import ( + LocalAuthSession, + LocalCredential, ServiceIdentity, Team, TeamMembership, @@ -89,6 +91,8 @@ "EdgeNode", "EdgeNodeEvent", "User", + "LocalCredential", + "LocalAuthSession", "Workspace", "WorkspaceMembership", "WorkspaceRole", diff --git a/backend/models/identity.py b/backend/models/identity.py index 317a1f1a..37f0ccc1 100644 --- a/backend/models/identity.py +++ b/backend/models/identity.py @@ -1,7 +1,7 @@ from datetime import datetime from enum import StrEnum -from sqlalchemy import Boolean, Enum, ForeignKey, String, UniqueConstraint +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from backend.models.base import TimestampMixin @@ -23,6 +23,44 @@ class User(TimestampMixin): disabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) +class LocalCredential(TimestampMixin): + """The one local appliance owner credential. + + ``singleton_key`` is deliberately unique so concurrent first-run requests + cannot create two owners, even when they use different usernames. + """ + + __tablename__ = "local_credentials" + + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False + ) + singleton_key: Mapped[str] = mapped_column( + String(16), unique=True, default="owner", nullable=False + ) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + failed_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class LocalAuthSession(TimestampMixin): + """Opaque, revocable browser session for the local appliance owner.""" + + __tablename__ = "local_auth_sessions" + + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + class Workspace(TimestampMixin): __tablename__ = "workspaces" diff --git a/backend/schemas/local_auth.py b/backend/schemas/local_auth.py new file mode 100644 index 00000000..ea043987 --- /dev/null +++ b/backend/schemas/local_auth.py @@ -0,0 +1,86 @@ +"""Schemas for the single-owner appliance login flow.""" + +from __future__ import annotations + +import re +import unicodedata + +from pydantic import BaseModel, Field, SecretStr, field_validator + +_CROCKFORD_CODE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{10}$") + + +def normalize_username(value: str) -> str: + normalized = unicodedata.normalize("NFKC", value).strip().casefold() + if not normalized or any(not (char.isalnum() or char in "._-@") for char in normalized): + raise ValueError("username may only contain letters, numbers, '.', '_', '-' or '@'") + return normalized + + +class LocalAuthStatus(BaseModel): + initialized: bool + claim_available: bool + oidc_enabled: bool + local_login_enabled: bool + recovery_enabled: bool + + +class LocalOwnerSetup(BaseModel): + claim_code: str + username: str = Field(min_length=3, max_length=64) + display_name: str | None = Field(default=None, max_length=255) + password: SecretStr = Field(min_length=10, max_length=128) + remember_device: bool = False + + @field_validator("claim_code", mode="before") + @classmethod + def validate_claim_code(cls, value: object) -> str: + if not isinstance(value, str): + raise ValueError("claim_code must be a string") + normalized = value.strip().upper() + if not _CROCKFORD_CODE.fullmatch(normalized): + raise ValueError("claim_code must be 10 Crockford Base32 characters") + return normalized + + @field_validator("username", mode="before") + @classmethod + def validate_username(cls, value: object) -> str: + if not isinstance(value, str): + raise ValueError("username must be a string") + return normalize_username(value) + + @field_validator("display_name", mode="before") + @classmethod + def normalize_display_name(cls, value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("display_name must be a string") + return value.strip() or None + + +class LocalLogin(BaseModel): + username: str = Field(min_length=3, max_length=64) + password: SecretStr = Field(min_length=1, max_length=128) + remember_device: bool = False + + @field_validator("username", mode="before") + @classmethod + def validate_username(cls, value: object) -> str: + if not isinstance(value, str): + raise ValueError("username must be a string") + return normalize_username(value) + + +class AuthIdentity(BaseModel): + subject: str + email: str | None = None + name: str | None = None + username: str | None = None + picture: str | None = None + is_platform_admin: bool + auth_method: str + + +class LogoutResult(BaseModel): + signed_out: bool = True diff --git a/backend/security/fleet_auth.py b/backend/security/fleet_auth.py index 94642385..24f75cc1 100644 --- a/backend/security/fleet_auth.py +++ b/backend/security/fleet_auth.py @@ -67,12 +67,28 @@ from collections.abc import Sequence from urllib.parse import parse_qs +from fastapi import HTTPException from starlette.datastructures import Headers +from starlette.requests import Request from starlette.responses import JSONResponse from starlette.types import ASGIApp, Receive, Scope, Send from starlette.websockets import WebSocketClose -from backend.config import get_settings +from backend.config import Settings, get_settings +from backend.security.identity import ( + REQUEST_IDENTITY_STATE_KEY, + IdentitySettings, + OIDCVerifier, + RequestIdentity, +) +from backend.security.local_auth import ( + CSRF_HEADER_NAME, + LOCAL_AUTH_STATE_KEY, + SESSION_COOKIE_NAME, + authenticate_local_session, + is_public_local_auth_path, + local_session_write_has_csrf, +) #: Path prefixes guarded by :class:`FleetAuthMiddleware`. PROTECTED_PREFIXES = ("/api", "/mcp") @@ -149,6 +165,8 @@ class FleetAuthMiddleware: def __init__(self, app: ASGIApp) -> None: self.app = app + self._oidc_signature: tuple[str, str, str] | None = None + self._oidc_verifier: OIDCVerifier | None = None async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] not in ("http", "websocket") or not scope["path"].startswith( @@ -160,14 +178,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # Read per request: get_settings() is lru_cached (cheap), but # api/v1/system.py may cache_clear() it at runtime after a config # patch, so don't freeze the token at middleware construction time. - token = get_settings().api_auth_token - if not token: - # Dev posture: no token configured -> API open. Only reachable on - # a localhost bind thanks to enforce_bind_guard at startup. - await self.app(scope, receive, send) - return + settings = get_settings() + token = settings.api_auth_token if scope["type"] == "websocket": + if not token: + # Preserve the existing localhost-only development posture. + await self.app(scope, receive, send) + return headers = Headers(scope=scope) credential = _bearer_credential(headers) or _query_token(scope.get("query_string", b"")) if credential and _token_matches(credential, token): @@ -179,14 +197,107 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: return headers = Headers(scope=scope) + + # Only the three bootstrap/login discovery endpoints are anonymous. + # Exact path matching keeps this exemption from widening to the rest + # of /auth or any similarly prefixed route. + if is_public_local_auth_path(scope["path"]): + await self.app(scope, receive, send) + return + credential = headers.get("x-api-token", "") or _bearer_credential(headers) if credential and _token_matches(credential, token): await self.app(scope, receive, send) return + bearer = _bearer_credential(headers) + if ( + scope["path"].startswith("/api") + and settings.bootstrap_admin_token + and bearer + and _token_matches(bearer, settings.bootstrap_admin_token) + ): + # Emergency recovery is a complete bearer credential: operators + # do not also need to recover/remember the Fleet token. + scope.setdefault("state", {})[REQUEST_IDENTITY_STATE_KEY] = RequestIdentity( + subject="bootstrap-admin", + name="Bootstrap Admin", + is_platform_admin=True, + auth_method="bootstrap", + ) + await self.app(scope, receive, send) + return + + if scope["path"].startswith("/api"): + if bearer and settings.oidc_issuer and settings.oidc_audience: + try: + oidc_identity = await self._oidc_verifier_for(settings).verify(bearer) + except HTTPException: + oidc_identity = None + if oidc_identity is not None: + scope.setdefault("state", {})[REQUEST_IDENTITY_STATE_KEY] = oidc_identity + await self.app(scope, receive, send) + return + + session_token = Request(scope).cookies.get(SESSION_COOKIE_NAME, "") + if session_token: + try: + local_identity = await authenticate_local_session(session_token) + except Exception: + response = JSONResponse( + status_code=503, + content={"success": False, "error": "Local authentication unavailable"}, + ) + await response(scope, receive, send) + return + if local_identity is not None: + if not local_session_write_has_csrf( + scope.get("method", "GET"), headers.get(CSRF_HEADER_NAME, "") + ): + response = JSONResponse( + status_code=403, + content={"success": False, "error": "CSRF header required"}, + ) + await response(scope, receive, send) + return + scope.setdefault("state", {})[LOCAL_AUTH_STATE_KEY] = local_identity + scope["state"][REQUEST_IDENTITY_STATE_KEY] = RequestIdentity( + subject=local_identity.subject, + email=local_identity.email, + name=local_identity.name, + username=local_identity.username, + is_platform_admin=True, + auth_method="local", + ) + await self.app(scope, receive, send) + return + + if not token: + # Dev posture: no token configured -> API open. Only reachable on + # a localhost bind thanks to enforce_bind_guard at startup. + await self.app(scope, receive, send) + return + response = JSONResponse( status_code=401, content={"success": False, "error": "Invalid or missing API token"}, headers={"WWW-Authenticate": "Bearer"}, ) await response(scope, receive, send) + + def _oidc_verifier_for(self, settings: Settings) -> OIDCVerifier: + signature = ( + settings.oidc_issuer.rstrip("/"), + settings.oidc_audience, + settings.oidc_jwks_url, + ) + if self._oidc_verifier is None or self._oidc_signature != signature: + self._oidc_signature = signature + self._oidc_verifier = OIDCVerifier( + IdentitySettings( + issuer=signature[0], + audience=signature[1], + jwks_url=signature[2], + ) + ) + return self._oidc_verifier diff --git a/backend/security/identity.py b/backend/security/identity.py index 09e79fbd..1544faed 100644 --- a/backend/security/identity.py +++ b/backend/security/identity.py @@ -1,4 +1,4 @@ -"""OIDC request identity verification and emergency bootstrap authentication.""" +"""OIDC, local-session, and emergency bootstrap request identities.""" from __future__ import annotations @@ -12,6 +12,9 @@ from jose import JWTError, jwt from backend.config import get_settings +from backend.security.local_auth import LOCAL_AUTH_STATE_KEY, LocalSessionIdentity + +REQUEST_IDENTITY_STATE_KEY = "opencli_request_identity" @dataclass(frozen=True) @@ -129,6 +132,21 @@ def identity_dependency( oidc = verifier or OIDCVerifier(resolved) async def get_request_identity(request: Request) -> RequestIdentity: + verified_identity = request.scope.get("state", {}).get(REQUEST_IDENTITY_STATE_KEY) + if isinstance(verified_identity, RequestIdentity): + return verified_identity + + local_identity = request.scope.get("state", {}).get(LOCAL_AUTH_STATE_KEY) + if isinstance(local_identity, LocalSessionIdentity): + return RequestIdentity( + subject=local_identity.subject, + email=local_identity.email, + name=local_identity.name, + username=local_identity.username, + is_platform_admin=True, + auth_method="local", + ) + scheme, _, token = request.headers.get("authorization", "").partition(" ") if scheme.lower() != "bearer" or not token: raise HTTPException( diff --git a/backend/security/local_auth.py b/backend/security/local_auth.py new file mode 100644 index 00000000..6f20ece6 --- /dev/null +++ b/backend/security/local_auth.py @@ -0,0 +1,366 @@ +"""Single-owner appliance authentication and opaque browser sessions.""" + +from __future__ import annotations + +import hashlib +import re +import secrets +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +import bcrypt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.concurrency import run_in_threadpool + +from backend.config import Settings +from backend.database import AsyncSessionLocal +from backend.models.identity import ( + LocalAuthSession, + LocalCredential, + User, + Workspace, + WorkspaceMembership, + WorkspaceRole, +) +from backend.schemas.local_auth import normalize_username + +SESSION_COOKIE_NAME = "opencli_session" +LOCAL_AUTH_STATE_KEY = "opencli_local_identity" +CSRF_HEADER_NAME = "X-OpenCLI-CSRF" +CSRF_HEADER_VALUE = "1" +LOCAL_OWNER_SUBJECT = "local:owner" + +# These are the only API routes which may cross the Fleet middleware without +# an already-authenticated browser session. Logout is intentionally excluded: +# it crosses with a valid session and the normal write-CSRF requirement. +PUBLIC_LOCAL_AUTH_PATHS = frozenset( + { + "/api/v1/auth/status", + "/api/v1/auth/setup", + "/api/v1/auth/login", + } +) + +_SAFE_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) +_CROCKFORD_CODE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{10}$") +_PASSWORD_HASH_PREFIX = "$bcrypt-sha256$v=1$" +_PASSWORD_BCRYPT_ROUNDS = 12 +_DUMMY_PASSWORD_HASH: str | None = None + +# Middleware cannot use FastAPI's request-scoped dependency session, so it +# opens a short read-only session through this replaceable factory. Keeping the +# seam explicit also lets isolated integration tests bind it to their engine. +local_auth_session_factory = AsyncSessionLocal + + +class LocalOwnerAlreadyInitialized(Exception): + pass + + +class DeviceClaimUnavailable(Exception): + pass + + +class InvalidDeviceClaim(Exception): + pass + + +class InvalidLocalCredentials(Exception): + pass + + +@dataclass(frozen=True) +class LocalSessionIdentity: + user_id: str + subject: str + email: str | None + name: str | None + username: str + + +@dataclass(frozen=True) +class LocalSessionGrant: + token: str + expires_at: datetime + identity: LocalSessionIdentity + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def is_public_local_auth_path(path: str) -> bool: + normalized = path.rstrip("/") or "/" + return normalized in PUBLIC_LOCAL_AUTH_PATHS + + +def local_session_write_has_csrf(method: str, header_value: str) -> bool: + return method.upper() in _SAFE_HTTP_METHODS or secrets.compare_digest( + header_value.encode("utf-8"), CSRF_HEADER_VALUE.encode("utf-8") + ) + + +def hash_session_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +async def hash_password(password: str) -> str: + """Hash an arbitrary-length UTF-8 password without bcrypt's 72-byte truncation. + + The SHA-256 digest is fixed-width and bcrypt still supplies the per-password + salt and work factor. This deliberately avoids passlib's legacy bcrypt + backend probe, which is incompatible with bcrypt 5.x. + """ + + digest = hashlib.sha256(password.encode("utf-8")).digest() + encoded = await run_in_threadpool( + bcrypt.hashpw, + digest, + bcrypt.gensalt(rounds=_PASSWORD_BCRYPT_ROUNDS), + ) + return f"{_PASSWORD_HASH_PREFIX}{encoded.decode('ascii').removeprefix('$')}" + + +async def verify_password(password: str, password_hash: str) -> bool: + if not password_hash.startswith(_PASSWORD_HASH_PREFIX): + return False + encoded = f"${password_hash.removeprefix(_PASSWORD_HASH_PREFIX)}".encode("ascii") + digest = hashlib.sha256(password.encode("utf-8")).digest() + try: + return await run_in_threadpool(bcrypt.checkpw, digest, encoded) + except (UnicodeEncodeError, TypeError, ValueError): + return False + + +async def local_auth_initialized(db: AsyncSession) -> bool: + credential_id = await db.scalar(select(LocalCredential.id).limit(1)) + return credential_id is not None + + +def device_claim_available(settings: Settings) -> bool: + """Return whether this deployment can complete one-time owner setup.""" + return bool(_CROCKFORD_CODE.fullmatch(settings.device_claim_code.strip().upper())) + + +async def claim_local_owner( + db: AsyncSession, + *, + claim_code: str, + username: str, + display_name: str | None, + password: str, + settings: Settings, +) -> LocalSessionIdentity: + """Consume the deployment claim by creating the sole local owner.""" + if await local_auth_initialized(db): + raise LocalOwnerAlreadyInitialized + + configured_claim = settings.device_claim_code.strip().upper() + if not device_claim_available(settings): + raise DeviceClaimUnavailable + candidate = claim_code.strip().upper() + if not _CROCKFORD_CODE.fullmatch(candidate) or not secrets.compare_digest( + candidate.encode("ascii"), configured_claim.encode("ascii") + ): + raise InvalidDeviceClaim + + normalized_username = normalize_username(username) + password_hash = await hash_password(password) + + user = await db.scalar(select(User).where(User.subject == LOCAL_OWNER_SUBJECT)) + if user is None: + user = User( + subject=LOCAL_OWNER_SUBJECT, + display_name=display_name or normalized_username, + disabled=False, + ) + db.add(user) + await db.flush() + else: + user.disabled = False + if display_name is not None or not user.display_name: + user.display_name = display_name or normalized_username + + db.add( + LocalCredential( + user_id=user.id, + singleton_key="owner", + username=normalized_username, + password_hash=password_hash, + ) + ) + + workspaces = list((await db.scalars(select(Workspace))).all()) + if not workspaces: + workspace = Workspace(name="我的空间", slug="my-space", active=True) + db.add(workspace) + await db.flush() + workspaces = [workspace] + + existing_memberships = { + membership.workspace_id: membership + for membership in ( + await db.scalars( + select(WorkspaceMembership).where(WorkspaceMembership.user_id == user.id) + ) + ).all() + } + for workspace in workspaces: + membership = existing_memberships.get(workspace.id) + if membership is None: + db.add( + WorkspaceMembership( + workspace_id=workspace.id, + user_id=user.id, + role=WorkspaceRole.ADMIN, + ) + ) + else: + membership.role = WorkspaceRole.ADMIN + + await db.flush() + return _identity_from_user(user, normalized_username) + + +async def authenticate_local_owner( + db: AsyncSession, + *, + username: str, + password: str, + settings: Settings, +) -> LocalSessionIdentity: + """Verify the owner password with bounded lockout and uniform failure.""" + global _DUMMY_PASSWORD_HASH + + normalized_username = normalize_username(username) + result = ( + await db.execute( + select(LocalCredential, User) + .join(User, User.id == LocalCredential.user_id) + .where(LocalCredential.username == normalized_username) + ) + ).one_or_none() + + if result is None: + if _DUMMY_PASSWORD_HASH is None: + _DUMMY_PASSWORD_HASH = await hash_password("opencli-dummy-owner-password") + await verify_password(password, _DUMMY_PASSWORD_HASH) + raise InvalidLocalCredentials + + credential, user = result + now = utcnow() + locked_until = _as_utc(credential.locked_until) + password_valid = await verify_password(password, credential.password_hash) + + if locked_until is not None and locked_until > now: + raise InvalidLocalCredentials + + if locked_until is not None: + credential.locked_until = None + credential.failed_attempts = 0 + + if not password_valid or user.disabled: + max_failures = max(1, settings.local_login_max_failures) + credential.failed_attempts += 1 + if credential.failed_attempts >= max_failures: + credential.locked_until = now + timedelta( + seconds=max(1, settings.local_login_lock_seconds) + ) + await db.flush() + raise InvalidLocalCredentials + + credential.failed_attempts = 0 + credential.locked_until = None + credential.last_login_at = now + await db.flush() + return _identity_from_user(user, credential.username) + + +async def create_local_session( + db: AsyncSession, + *, + identity: LocalSessionIdentity, + remember_device: bool, + settings: Settings, +) -> LocalSessionGrant: + now = utcnow() + ttl_seconds = ( + settings.local_remember_session_ttl_seconds + if remember_device + else settings.local_session_ttl_seconds + ) + expires_at = now + timedelta(seconds=max(60, ttl_seconds)) + token = secrets.token_urlsafe(32) + db.add( + LocalAuthSession( + user_id=identity.user_id, + token_hash=hash_session_token(token), + expires_at=expires_at, + last_seen_at=now, + ) + ) + await db.flush() + return LocalSessionGrant(token=token, expires_at=expires_at, identity=identity) + + +async def get_local_session_identity( + db: AsyncSession, token: str +) -> LocalSessionIdentity | None: + if not token: + return None + result = ( + await db.execute( + select(LocalAuthSession, User, LocalCredential) + .join(User, User.id == LocalAuthSession.user_id) + .join(LocalCredential, LocalCredential.user_id == User.id) + .where(LocalAuthSession.token_hash == hash_session_token(token)) + .where(LocalAuthSession.revoked_at.is_(None)) + ) + ).one_or_none() + if result is None: + return None + session, user, credential = result + if user.disabled or _as_utc(session.expires_at) <= utcnow(): + return None + return _identity_from_user(user, credential.username) + + +async def authenticate_local_session(token: str) -> LocalSessionIdentity | None: + """Resolve a browser session from middleware's independent DB session.""" + async with local_auth_session_factory() as db: + return await get_local_session_identity(db, token) + + +async def revoke_local_session(db: AsyncSession, token: str) -> bool: + if not token: + return False + session = await db.scalar( + select(LocalAuthSession).where( + LocalAuthSession.token_hash == hash_session_token(token), + LocalAuthSession.revoked_at.is_(None), + ) + ) + if session is None: + return False + session.revoked_at = utcnow() + await db.flush() + return True + + +def _identity_from_user(user: User, username: str) -> LocalSessionIdentity: + return LocalSessionIdentity( + user_id=user.id, + subject=user.subject, + email=user.email, + name=user.display_name or username, + username=username, + ) + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) diff --git a/docker-compose.yml b/docker-compose.yml index 84ef3f63..be05b7d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -156,6 +156,8 @@ services: SECRET_KEY: ${SECRET_KEY:-change-me-in-production} API_AUTH_TOKEN: ${API_AUTH_TOKEN:?Set API_AUTH_TOKEN in .env or run scripts/install.sh} BOOTSTRAP_ADMIN_TOKEN: ${BOOTSTRAP_ADMIN_TOKEN:?Set BOOTSTRAP_ADMIN_TOKEN in .env or run scripts/install.sh} + DEVICE_CLAIM_CODE: ${DEVICE_CLAIM_CODE:-} + LOCAL_SESSION_COOKIE_SECURE: ${LOCAL_SESSION_COOKIE_SECURE:-false} OPENCLI_MCP_ALLOWED_HOSTS: ${OPENCLI_MCP_ALLOWED_HOSTS:-} OPENCLI_MCP_ALLOWED_ORIGINS: ${OPENCLI_MCP_ALLOWED_ORIGINS:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} diff --git a/docs/adr/0043-use-appliance-first-local-owner-authentication.md b/docs/adr/0043-use-appliance-first-local-owner-authentication.md new file mode 100644 index 00000000..6fbe821a --- /dev/null +++ b/docs/adr/0043-use-appliance-first-local-owner-authentication.md @@ -0,0 +1,55 @@ +# Use Appliance-First Local Owner Authentication + +Status: accepted + +OpenCLI's default deployment is a personally operated appliance running on a workstation, NAS, +or capable home router. The first human-facing security ceremony is therefore device claim, not +organization sign-in. A new installation presents a one-time claim code, creates one local owner, +establishes an HTTP-only server session, and then uses that local account for ordinary console +access. The internal Workspace and RBAC models remain authorization boundaries; they are not the +default login concept shown to a personal operator. + +OIDC remains supported as an optional advanced capability for deployments that deliberately enable +team or enterprise identity. It must not block first use, appear as an error when unconfigured, or +remove the last usable local owner. Passkeys, TOTP, trusted-device approval, and vendor-assisted +remote access are post-claim enhancements rather than prerequisites for an offline-capable local +installation. + +Human and machine credentials stay separate: + +- Local owner sessions authenticate browser HTTP requests and are stored only as opaque, + revocable server-side sessions with HTTP-only cookies. +- `API_AUTH_TOKEN` continues to authenticate Agents, MCP, CLI, WebSocket, and service-to-service + traffic. It is never requested by the ordinary browser login form or stored in browser storage. +- `BOOTSTRAP_ADMIN_TOKEN` is a break-glass migration and recovery credential. It is not a daily + login mechanism and is hidden from the normal login path. +- The one-time device claim code authorizes only the unclaimed-to-active transition. A database + uniqueness constraint and transaction ensure that concurrent callers cannot create two first + owners; after the first claim, the code has no effect. + +The persistent Setup Center decision remains intact. Device claim is a one-time ownership and +security boundary, while the Setup Center continues to report and repair models, Connections, +Plugins, delivery channels, and execution resources throughout the appliance lifetime. + +Consequences: + +- New installations open on "Set up this device" and create a local owner before entering Studio. +- Existing installations keep OIDC and recovery access during migration. After the operator adds a + fresh claim code to deployment configuration, they can create the local owner without + invalidating OIDC subjects, Workspace memberships, data volumes, or Fleet clients. +- Unsafe cookie-authenticated mutations require an explicit same-origin CSRF header in addition to + `SameSite` cookie policy. +- noVNC is not made public merely to improve LAN convenience; it requires an authenticated console + proxy before it can be safely exposed beyond loopback. +- The current Studio Workspace and governance Workspace models are not merged by this decision. + Appliance-first presentation may hide that distinction, but data ownership changes require a + separate migration. + +Rejected alternatives: + +- Automatically inserting the Bootstrap token into the page retains a permanent bearer secret and + only hides the underlying problem. +- Trusting all LAN clients without login is unsafe because the API can reach browser profiles, + platform cookies, and the Docker control plane. +- Making OIDC the default requires an external identity provider and contradicts offline-capable, + single-owner installation. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 052adfc4..f5f121c6 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,7 +1,11 @@ FROM node:22-alpine AS dependencies WORKDIR /app -RUN corepack enable && corepack prepare pnpm@11.10.0 --activate +ARG NPM_REGISTRY=https://registry.npmjs.org +ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY} +RUN corepack enable \ + && corepack prepare pnpm@11.10.0 --activate \ + && pnpm config set registry "${NPM_REGISTRY}" COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile diff --git a/frontend/app/(app)/studio/new/page.tsx b/frontend/app/(app)/studio/new/page.tsx index 66362f2f..7eeff968 100644 --- a/frontend/app/(app)/studio/new/page.tsx +++ b/frontend/app/(app)/studio/new/page.tsx @@ -30,6 +30,7 @@ import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { useBootstrapWorkspaceProject, useMyWorkspaces } from '@/lib/api/hooks' import { updateProjectWorkflowDraft } from '@/lib/api/endpoints' +import { getApiAuthHeaders } from '@/lib/api/auth-headers' import { analyzeGeneratedWorkflowReadiness, extractWorkflowSchedule, extractWorkflowSource, generateWorkflowLocally } from '@/lib/flow/local-generate' import type { GeneratedWorkflowSpec } from '@/lib/flow/types' import { generatedSpecToWorkflowProject } from '@/lib/workflow/generated-project' @@ -314,7 +315,7 @@ export default function NewAgentStudioPage() { try { const response = await fetch('/api/generate-workflow', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...getApiAuthHeaders() }, body: JSON.stringify({ prompt: requirements.join('\n补充要求:') }), }) const payload = await response.json() diff --git a/frontend/app/api/generate-workflow/route.ts b/frontend/app/api/generate-workflow/route.ts index 2e59f193..7417e8d3 100644 --- a/frontend/app/api/generate-workflow/route.ts +++ b/frontend/app/api/generate-workflow/route.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { analyzeGeneratedWorkflowReadiness } from "@/lib/flow/local-generate" import type { GeneratedWorkflowSpec } from "@/lib/flow/types" +import { requireAuthenticatedMutation } from "@/lib/api/server-auth" export const maxDuration = 30 @@ -118,6 +119,8 @@ const workflowSchema = z.object({ }) export async function POST(req: Request) { + const authError = await requireAuthenticatedMutation(req) + if (authError) return authError try { let body: { prompt?: unknown } try { diff --git a/frontend/app/api/render/route.ts b/frontend/app/api/render/route.ts index 0346bc51..995bc71c 100644 --- a/frontend/app/api/render/route.ts +++ b/frontend/app/api/render/route.ts @@ -1,6 +1,8 @@ // Server-Side Image Creation // Renders the workflow graph to a standalone SVG on the server (no headless browser needed). +import { requireAuthenticatedMutation } from "@/lib/api/server-auth" + interface RNode { id: string position: { x: number; y: number } @@ -40,6 +42,8 @@ function esc(s: string) { } export async function POST(req: Request) { + const authError = await requireAuthenticatedMutation(req) + if (authError) return authError try { const { nodes, edges } = (await req.json()) as { nodes: RNode[]; edges: REdge[] } if (!Array.isArray(nodes) || nodes.length === 0) { diff --git a/frontend/app/api/workflow/bbx-tool-nodes/route.ts b/frontend/app/api/workflow/bbx-tool-nodes/route.ts index 3e6f2f07..d09111cc 100644 --- a/frontend/app/api/workflow/bbx-tool-nodes/route.ts +++ b/frontend/app/api/workflow/bbx-tool-nodes/route.ts @@ -7,9 +7,7 @@ export async function GET(req: Request) { const url = new URL(req.url) const response = await fetch(`${BACKEND_URL}/api/v1/workflows/bbx-tool-nodes${url.search}`, { headers: { - ...(req.headers.get("authorization") - ? { Authorization: req.headers.get("authorization") as string } - : {}), + ...forwardedRequestAuthHeaders(req), }, cache: "no-store", }) @@ -29,3 +27,5 @@ export async function GET(req: Request) { ) } } + +import { forwardedRequestAuthHeaders } from "@/lib/workflow/request-auth" diff --git a/frontend/app/api/workflow/evidence-batch-proxy.ts b/frontend/app/api/workflow/evidence-batch-proxy.ts index 0639b842..e340e9a8 100644 --- a/frontend/app/api/workflow/evidence-batch-proxy.ts +++ b/frontend/app/api/workflow/evidence-batch-proxy.ts @@ -25,9 +25,7 @@ async function proxyWorkflowEvidenceRequest( const search = new URL(req.url).searchParams.toString() const response = await fetch(`${root}${search ? `?${search}` : ""}`, { headers: { - ...(req.headers.get("authorization") - ? { Authorization: req.headers.get("authorization") as string } - : {}), + ...forwardedRequestAuthHeaders(req), }, cache: "no-store", }) @@ -47,3 +45,5 @@ async function proxyWorkflowEvidenceRequest( ) } } + +import { forwardedRequestAuthHeaders } from "@/lib/workflow/request-auth" diff --git a/frontend/app/api/workflow/import/dify/route.ts b/frontend/app/api/workflow/import/dify/route.ts index 8aaff557..bcf4b336 100644 --- a/frontend/app/api/workflow/import/dify/route.ts +++ b/frontend/app/api/workflow/import/dify/route.ts @@ -22,9 +22,7 @@ export async function POST(req: Request) { method: "POST", headers: { "Content-Type": "application/json", - ...(req.headers.get("authorization") - ? { Authorization: req.headers.get("authorization") as string } - : {}), + ...forwardedRequestAuthHeaders(req), }, body: JSON.stringify({ source: readProperty(body, "source"), @@ -49,6 +47,8 @@ export async function POST(req: Request) { } } +import { forwardedRequestAuthHeaders } from "@/lib/workflow/request-auth" + function readProperty(value: unknown, key: string): unknown { return typeof value === "object" && value !== null && key in value ? (value as Record)[key] diff --git a/frontend/app/api/workflow/opentabs-tool-nodes/route.ts b/frontend/app/api/workflow/opentabs-tool-nodes/route.ts index 114bd2c7..c7431c50 100644 --- a/frontend/app/api/workflow/opentabs-tool-nodes/route.ts +++ b/frontend/app/api/workflow/opentabs-tool-nodes/route.ts @@ -7,9 +7,7 @@ export async function GET(req: Request) { const url = new URL(req.url) const response = await fetch(`${BACKEND_URL}/api/v1/workflows/opentabs-tool-nodes${url.search}`, { headers: { - ...(req.headers.get("authorization") - ? { Authorization: req.headers.get("authorization") as string } - : {}), + ...forwardedRequestAuthHeaders(req), }, cache: "no-store", }) @@ -29,3 +27,5 @@ export async function GET(req: Request) { ) } } + +import { forwardedRequestAuthHeaders } from "@/lib/workflow/request-auth" diff --git a/frontend/app/api/workflow/runs/[runId]/research-continuations/route.ts b/frontend/app/api/workflow/runs/[runId]/research-continuations/route.ts index 4fdc97cf..5ca1c7af 100644 --- a/frontend/app/api/workflow/runs/[runId]/research-continuations/route.ts +++ b/frontend/app/api/workflow/runs/[runId]/research-continuations/route.ts @@ -11,9 +11,7 @@ export async function POST(req: Request, context: { params: Promise<{ runId: str method: "POST", headers: { "Content-Type": "application/json", - ...(req.headers.get("authorization") - ? { Authorization: req.headers.get("authorization") as string } - : {}), + ...forwardedRequestAuthHeaders(req), }, body: await req.text(), cache: "no-store", @@ -34,3 +32,5 @@ export async function POST(req: Request, context: { params: Promise<{ runId: str ) } } + +import { forwardedRequestAuthHeaders } from "@/lib/workflow/request-auth" diff --git a/frontend/app/api/workflow/runs/[runId]/research-ledger/route.ts b/frontend/app/api/workflow/runs/[runId]/research-ledger/route.ts index 18ed6cf4..1070e49f 100644 --- a/frontend/app/api/workflow/runs/[runId]/research-ledger/route.ts +++ b/frontend/app/api/workflow/runs/[runId]/research-ledger/route.ts @@ -9,9 +9,7 @@ export async function GET(req: Request, context: { params: Promise<{ runId: stri `${BACKEND_URL}/api/v1/workflows/runs/${encodeURIComponent(runId)}/research-ledger`, { headers: { - ...(req.headers.get("authorization") - ? { Authorization: req.headers.get("authorization") as string } - : {}), + ...forwardedRequestAuthHeaders(req), }, cache: "no-store", }, @@ -31,3 +29,5 @@ export async function GET(req: Request, context: { params: Promise<{ runId: stri ) } } + +import { forwardedRequestAuthHeaders } from "@/lib/workflow/request-auth" diff --git a/frontend/app/globals.css b/frontend/app/globals.css index fd4b4a91..57027712 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -72,9 +72,9 @@ */ :root { color-scheme: light; - --font-ui: var(--font-noto-sans-sc), "Source Han Sans SC", "Noto Sans CJK SC", "Microsoft YaHei UI", ui-sans-serif, system-ui, sans-serif; - --font-code: var(--font-ibm-plex-mono), var(--font-noto-sans-sc), "Noto Sans Mono CJK SC", ui-monospace, SFMono-Regular, Consolas, monospace; - --font-data: var(--font-ibm-plex-mono), ui-monospace, SFMono-Regular, Consolas, monospace; + --font-ui: "Source Han Sans SC", "Noto Sans CJK SC", "Microsoft YaHei UI", "PingFang SC", ui-sans-serif, system-ui, sans-serif; + --font-code: "IBM Plex Mono", "Noto Sans Mono CJK SC", "Microsoft YaHei UI", ui-monospace, SFMono-Regular, Consolas, monospace; + --font-data: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace; --background: #ffffff; --foreground: #0a0a0a; --card: #f7f7f5; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 035324e7..1f021e46 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,25 +1,10 @@ import { Analytics } from '@vercel/analytics/next' import type { Metadata, Viewport } from 'next' -import { IBM_Plex_Mono, Noto_Sans_SC } from 'next/font/google' import { Providers } from '@/components/providers' import { Toaster } from '@/components/ui/sonner' import './globals.css' -const notoSansSC = Noto_Sans_SC({ - subsets: ['latin'], - variable: '--font-noto-sans-sc', - weight: 'variable', - display: 'swap', -}) - -const ibmPlexMono = IBM_Plex_Mono({ - subsets: ['latin'], - variable: '--font-ibm-plex-mono', - weight: ['400', '500', '600', '700'], - display: 'swap', -}) - export const metadata: Metadata = { title: 'OpenCLI Admin', description: '采集编排控制台 — 以节点工作流为核心的数据采集管理平台', @@ -40,7 +25,7 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - + {children} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 16447936..3db7e566 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -134,19 +134,30 @@ function LoginForm() { const searchParams = useSearchParams() const { status, + serverStatus, oidcEnabled, developmentLoginEnabled, + setupLocalAccount, + signInWithLocal, signInWithOidc, - signInWithBootstrap, enterDevelopmentMode, } = useAuth() - const [identityToken, setIdentityToken] = useState('') - const [fleetToken, setFleetToken] = useState('') - const [submitting, setSubmitting] = useState<'oidc' | 'bootstrap' | 'development' | null>(null) + const [claimCode, setClaimCode] = useState('') + const [username, setUsername] = useState('') + const [displayName, setDisplayName] = useState('') + const [password, setPassword] = useState('') + const [passwordConfirmation, setPasswordConfirmation] = useState('') + const [rememberDevice, setRememberDevice] = useState(true) + const [submitting, setSubmitting] = useState< + 'setup' | 'local' | 'oidc' | 'development' | null + >(null) const [reduceMotion, setReduceMotion] = useState(true) const [backdrop, setBackdrop] = useState('liquid') const [headlineWord, setHeadlineWord] = useState(0) const returnTo = sanitizeReturnTo(searchParams.get('returnTo')) + const requiresSetup = status === 'setup-required' || serverStatus?.initialized === false + const claimAvailable = serverStatus?.claim_available ?? true + const localLoginEnabled = serverStatus?.local_login_enabled ?? true useEffect(() => { const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') @@ -173,27 +184,72 @@ function LoginForm() { return () => window.clearInterval(interval) }, [reduceMotion]) - const optionalFleetToken = fleetToken.trim() || undefined - async function startOidcLogin() { setSubmitting('oidc') try { - await signInWithOidc(returnTo, optionalFleetToken) + await signInWithOidc(returnTo) } catch (error) { toast.error(error instanceof Error ? error.message : '无法启动 OIDC 登录') setSubmitting(null) } } - async function handleBootstrapLogin(event: React.FormEvent) { + async function handleSetup(event: React.FormEvent) { event.preventDefault() - setSubmitting('bootstrap') + if (!claimCode.trim()) { + toast.error('请输入设备认领码') + return + } + if (!username.trim()) { + toast.error('请输入管理员用户名') + return + } + if (!password) { + toast.error('请输入管理员密码') + return + } + if (password.length < 10) { + toast.error('管理员密码至少需要 10 个字符') + return + } + if (password !== passwordConfirmation) { + toast.error('两次输入的密码不一致') + return + } + + setSubmitting('setup') try { - await signInWithBootstrap(identityToken, optionalFleetToken) - toast.success('管理员身份验证成功') - router.replace(returnTo) + await setupLocalAccount({ + claim_code: claimCode.trim(), + username: username.trim(), + display_name: displayName.trim() || undefined, + password, + remember_device: rememberDevice, + }) + toast.success('本机管理员已创建') } catch (error) { - toast.error(error instanceof Error ? error.message : '身份验证失败') + toast.error(error instanceof Error ? error.message : '无法完成设备设置') + setSubmitting(null) + } + } + + async function handleLocalLogin(event: React.FormEvent) { + event.preventDefault() + if (!username.trim() || !password) { + toast.error('请输入管理员用户名和密码') + return + } + + setSubmitting('local') + try { + await signInWithLocal({ + username: username.trim(), + password, + remember_device: rememberDevice, + }) + toast.success('登录成功') + } catch (error) { + toast.error(error instanceof Error ? error.message : '用户名或密码不正确') setSubmitting(null) } } @@ -201,7 +257,7 @@ function LoginForm() { function handleDevelopmentLogin() { setSubmitting('development') try { - enterDevelopmentMode(optionalFleetToken) + enterDevelopmentMode() window.setTimeout(() => router.replace(returnTo), reduceMotion ? 0 : 285) } catch (error) { toast.error(error instanceof Error ? error.message : '无法进入本地开发模式') @@ -327,118 +383,283 @@ function LoginForm() { - 登录控制台 + {requiresSetup ? '设置此设备' : '登录控制台'} - 使用组织账号登录;Bootstrap Admin 仅用于首次部署和紧急恢复。 + {requiresSetup + ? '这是此设备的首次设置。创建本机管理员后即可直接使用。' + : '使用本机管理员账号登录。'} - {oidcEnabled ? ( - - ) : ( -
- 当前未配置组织登录。请配置 OIDC issuer、client ID 和授权端点。 + + + + ) : requiresSetup ? ( +
+
+

设备认领码尚未配置

+

+ 这是旧版本升级时的保护状态。请在部署主机的 .env 中设置 + 10 位 DEVICE_CLAIM_CODE,重启 API 后刷新本页;认领码只用于首次设置。 +

+
+ {oidcEnabled ? ( + + ) : null}
- )} - -
- - 紧急管理员访问 - -
+ ) : ( + <> + {localLoginEnabled ? ( +
+ + + 管理员用户名 + setUsername(event.target.value)} + autoComplete="username" + autoFocus + /> + + + 密码 + setPassword(event.target.value)} + autoComplete="current-password" + /> + + + + +
+ ) : null} -
- - - 管理员身份令牌 - setIdentityToken(event.target.value)} - autoComplete="off" - /> - 验证成功后仅保存在当前标签页会话中。 - - - Fleet API 令牌(可选) - setFleetToken(event.target.value)} - autoComplete="off" - /> - - 后端启用 Fleet Auth 时填写;留空沿用部署配置或浏览器中已有值。 - - - -
+ {oidcEnabled || developmentLoginEnabled ? ( +
+
+ + 其他登录方式 + +
+ {oidcEnabled ? ( + + ) : null} + {developmentLoginEnabled ? ( + + ) : null} +
+ ) : null} + + )} - - - {developmentLoginEnabled ? ( - - ) : null} - + {!requiresSetup && + status !== 'loading' && + serverStatus?.recovery_enabled ? ( + +
+ 紧急恢复 +

+ 恢复操作需要直接访问部署主机。请在主机侧生成一次性恢复入口;此登录页不会接收长期管理员令牌。 +

+
+
+ ) : null}

LOCAL-FIRST · AUDITABLE · NODE-NATIVE diff --git a/frontend/components/auth/auth-gate.tsx b/frontend/components/auth/auth-gate.tsx index 114440de..fecd4cb0 100644 --- a/frontend/components/auth/auth-gate.tsx +++ b/frontend/components/auth/auth-gate.tsx @@ -13,7 +13,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) { const router = useRouter() useEffect(() => { - if (status === 'anonymous') { + if (status === 'anonymous' || status === 'setup-required') { router.replace(`/login?returnTo=${encodeURIComponent(pathname)}`) } }, [pathname, router, status]) @@ -32,7 +32,13 @@ export function AuthGate({ children }: { children: React.ReactNode }) { palette={{ on: 'var(--color-primary)', off: 'var(--color-muted-foreground)' }} ariaLabel="正在加载" /> - {status === 'loading' ? '正在恢复会话…' : '正在前往登录…'} + + {status === 'loading' + ? '正在恢复会话…' + : status === 'setup-required' + ? '正在前往设置此设备…' + : '正在前往登录…'} +

) diff --git a/frontend/components/auth/auth-provider.tsx b/frontend/components/auth/auth-provider.tsx index d00ee1b9..163e6695 100644 --- a/frontend/components/auth/auth-provider.tsx +++ b/frontend/components/auth/auth-provider.tsx @@ -2,30 +2,43 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { getCurrentIdentity } from '@/lib/api/endpoints' import { AUTH_REQUIRED_EVENT } from '@/lib/api/auth-events' -import { setApiAuthToken } from '@/lib/api/auth-token' +import { clearLegacyApiAuthToken } from '@/lib/api/auth-token' +import { + getAuthStatus, + getCurrentIdentity, + loginLocalAuth, + logoutCurrentSession, + setupLocalAuth, +} from '@/lib/api/endpoints' import { getOidcManager, isOidcConfigured, oidcReturnTo, sanitizeReturnTo } from '@/lib/auth/oidc' import { clearIdentityToken, - getBootstrapIdentityToken, + clearLegacyBootstrapIdentityToken, hasDevelopmentSession, isDevelopmentLoginAllowed, - persistBootstrapIdentityToken, setDevelopmentSession, setRuntimeIdentityToken, } from '@/lib/auth/session' -import type { AuthIdentity, AuthStatus } from '@/lib/auth/types' +import type { + AuthIdentity, + AuthServerStatus, + AuthStatus, + LocalAuthLoginInput, + LocalAuthSetupInput, +} from '@/lib/auth/types' type AuthContextValue = { status: AuthStatus identity: AuthIdentity | null + serverStatus: AuthServerStatus | null oidcEnabled: boolean developmentLoginEnabled: boolean - signInWithOidc: (returnTo?: string, fleetToken?: string) => Promise + setupLocalAccount: (input: LocalAuthSetupInput) => Promise + signInWithLocal: (input: LocalAuthLoginInput) => Promise + signInWithOidc: (returnTo?: string) => Promise completeOidcSignIn: () => Promise - signInWithBootstrap: (identityToken: string, fleetToken?: string) => Promise - enterDevelopmentMode: (fleetToken?: string) => void + enterDevelopmentMode: () => void signOut: () => Promise } @@ -41,10 +54,21 @@ const DEVELOPMENT_IDENTITY: AuthIdentity = { auth_method: 'development', } +function legacyServerStatus(): AuthServerStatus { + return { + initialized: true, + claim_available: true, + oidc_enabled: isOidcConfigured(), + local_login_enabled: true, + recovery_enabled: true, + } +} + export function AuthProvider({ children }: { children: React.ReactNode }) { const [status, setStatus] = useState('loading') const [identity, setIdentity] = useState(null) - const oidcEnabled = isOidcConfigured() + const [serverStatus, setServerStatus] = useState(null) + const oidcEnabled = isOidcConfigured() && (serverStatus?.oidc_enabled ?? true) const developmentLoginEnabled = isDevelopmentLoginAllowed() const acceptIdentityToken = useCallback(async (token: string) => { @@ -61,40 +85,74 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, []) + const acceptCookieIdentity = useCallback((nextIdentity: AuthIdentity) => { + clearIdentityToken() + setDevelopmentSession(false) + setIdentity(nextIdentity) + setStatus('authenticated') + return nextIdentity + }, []) + const becomeAnonymous = useCallback(() => { clearIdentityToken() setDevelopmentSession(false) setIdentity(null) - setStatus('anonymous') - }, []) + setStatus(serverStatus?.initialized === false ? 'setup-required' : 'anonymous') + }, [serverStatus?.initialized]) useEffect(() => { let active = true async function restoreSession() { + clearLegacyBootstrapIdentityToken() + clearLegacyApiAuthToken() + + let nextServerStatus: AuthServerStatus try { - const oidcUser = await getOidcManager()?.getUser() - if (oidcUser && !oidcUser.expired) { - if (!oidcUser.id_token) throw new Error('OIDC 未返回身份令牌') - await acceptIdentityToken(oidcUser.id_token) - return - } + nextServerStatus = await getAuthStatus() + } catch { + // Keep the previous OIDC/development posture usable during a rolling + // upgrade where the new status endpoint is not available yet. + nextServerStatus = legacyServerStatus() + } + if (!active) return + setServerStatus(nextServerStatus) - const bootstrapToken = getBootstrapIdentityToken() - if (bootstrapToken) { - await acceptIdentityToken(bootstrapToken) - return - } + if (!nextServerStatus.initialized) { + setIdentity(null) + setStatus('setup-required') + return + } - if (developmentLoginEnabled && hasDevelopmentSession()) { - if (!active) return - setIdentity(DEVELOPMENT_IDENTITY) - setStatus('authenticated') - return + if (nextServerStatus.oidc_enabled) { + try { + const oidcUser = await getOidcManager()?.getUser() + if (oidcUser && !oidcUser.expired) { + if (!oidcUser.id_token) throw new Error('OIDC 未返回身份令牌') + await acceptIdentityToken(oidcUser.id_token) + return + } + } catch { + clearIdentityToken() } + } + + try { + const nextIdentity = await getCurrentIdentity() + if (!active) return + acceptCookieIdentity(nextIdentity) + return } catch { - clearIdentityToken() + // No valid local cookie is the ordinary signed-out state. } + + if (developmentLoginEnabled && hasDevelopmentSession()) { + if (!active) return + setIdentity(DEVELOPMENT_IDENTITY) + setStatus('authenticated') + return + } + if (active) { setIdentity(null) setStatus('anonymous') @@ -105,7 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return () => { active = false } - }, [acceptIdentityToken, developmentLoginEnabled]) + }, [acceptCookieIdentity, acceptIdentityToken, developmentLoginEnabled]) useEffect(() => { const manager = getOidcManager() @@ -135,10 +193,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return () => window.removeEventListener(AUTH_REQUIRED_EVENT, onAuthRequired) }, [becomeAnonymous, developmentLoginEnabled]) - const signInWithOidc = useCallback(async (returnTo = '/studio', fleetToken?: string) => { + const setupLocalAccount = useCallback( + async (input: LocalAuthSetupInput) => { + const nextIdentity = await setupLocalAuth(input) + await getOidcManager()?.removeUser() + setServerStatus((current) => ({ + ...(current ?? legacyServerStatus()), + initialized: true, + claim_available: false, + local_login_enabled: true, + })) + return acceptCookieIdentity(nextIdentity) + }, + [acceptCookieIdentity], + ) + + const signInWithLocal = useCallback( + async (input: LocalAuthLoginInput) => { + const nextIdentity = await loginLocalAuth(input) + await getOidcManager()?.removeUser() + return acceptCookieIdentity(nextIdentity) + }, + [acceptCookieIdentity], + ) + + const signInWithOidc = useCallback(async (returnTo = '/studio') => { const manager = getOidcManager() if (!manager) throw new Error('OIDC 登录尚未配置') - if (fleetToken !== undefined) setApiAuthToken(fleetToken) await manager.signinRedirect({ state: { returnTo: sanitizeReturnTo(returnTo) } }) }, []) @@ -151,32 +232,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return oidcReturnTo(user) }, [acceptIdentityToken]) - const signInWithBootstrap = useCallback( - async (identityToken: string, fleetToken?: string) => { - const trimmed = identityToken.trim() - if (!trimmed) throw new Error('请输入管理员身份令牌') - if (fleetToken !== undefined) setApiAuthToken(fleetToken) - await acceptIdentityToken(trimmed) - persistBootstrapIdentityToken(trimmed) - }, - [acceptIdentityToken], - ) - - const enterDevelopmentMode = useCallback( - (fleetToken?: string) => { - if (!developmentLoginEnabled) throw new Error('本地开发模式不可用') - if (fleetToken !== undefined) setApiAuthToken(fleetToken) - clearIdentityToken() - setDevelopmentSession(true) - setIdentity(DEVELOPMENT_IDENTITY) - setStatus('authenticated') - }, - [developmentLoginEnabled], - ) + const enterDevelopmentMode = useCallback(() => { + if (!developmentLoginEnabled) throw new Error('本地开发模式不可用') + clearIdentityToken() + setDevelopmentSession(true) + setIdentity(DEVELOPMENT_IDENTITY) + setStatus('authenticated') + }, [developmentLoginEnabled]) const signOut = useCallback(async () => { const manager = getOidcManager() const oidcUser = await manager?.getUser() + try { + await logoutCurrentSession() + } catch { + // A missing/expired cookie is already signed out from the local service. + } becomeAnonymous() if (!manager || !oidcUser) return try { @@ -190,11 +261,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { () => ({ status, identity, + serverStatus, oidcEnabled, developmentLoginEnabled, + setupLocalAccount, + signInWithLocal, signInWithOidc, completeOidcSignIn, - signInWithBootstrap, enterDevelopmentMode, signOut, }), @@ -204,7 +277,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { enterDevelopmentMode, identity, oidcEnabled, - signInWithBootstrap, + serverStatus, + setupLocalAccount, + signInWithLocal, signInWithOidc, signOut, status, diff --git a/frontend/components/flow/command-palette.tsx b/frontend/components/flow/command-palette.tsx index e01b8727..acf975f2 100644 --- a/frontend/components/flow/command-palette.tsx +++ b/frontend/components/flow/command-palette.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react" import { NODE_PALETTE } from "@/lib/flow/palette" +import { getApiAuthHeaders } from "@/lib/api/auth-headers" import { portTypesCompatible } from "@/lib/flow/graph" import { getIcon } from "@/lib/flow/icons" import { generateWorkflowLocally } from "@/lib/flow/local-generate" @@ -623,7 +624,7 @@ export function CommandPalette({ try { const response = await fetch("/api/generate-workflow", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...getApiAuthHeaders() }, body: JSON.stringify({ prompt: text }), }) const data = await response.json() diff --git a/frontend/components/flow/command-strip.tsx b/frontend/components/flow/command-strip.tsx index 7feed88f..f1ad0024 100644 --- a/frontend/components/flow/command-strip.tsx +++ b/frontend/components/flow/command-strip.tsx @@ -28,6 +28,7 @@ import { MoreHorizontal, } from "lucide-react" import { Button } from "@/components/ui/button" +import { getApiAuthHeaders } from "@/lib/api/auth-headers" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { DropdownMenu, @@ -226,7 +227,7 @@ export function CommandStrip({ try { const res = await fetch("/api/render", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...getApiAuthHeaders() }, body: JSON.stringify({ nodes, edges }), }) if (!res.ok) throw new Error("failed") diff --git a/frontend/components/flow/run-trace-panel.tsx b/frontend/components/flow/run-trace-panel.tsx index 5622af2b..feb9aee4 100644 --- a/frontend/components/flow/run-trace-panel.tsx +++ b/frontend/components/flow/run-trace-panel.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react" import { Activity, Boxes, FileInput, Loader2, Play, RotateCcw } from "lucide-react" -import { getApiAuthToken } from "@/lib/api/auth-token" import { useFlowStore } from "@/lib/flow/store" import { fetchWorkflowCapabilities } from "@/lib/workflow/backend-capabilities" import { compileWorkflowProject, type WorkflowCompileResponse } from "@/lib/workflow/backend-compile" @@ -141,13 +140,11 @@ export function RunTracePanel({ runRequestId = 0 }: { runRequestId?: number }) { const runBackendWorkflow = async (sourceOutputs?: Record>>) => { setRunState((current) => ({ status: "running", projection: current.projection, events: current.events, error: null })) try { - const token = getApiAuthToken() - const authorization = token ? `Bearer ${token}` : null - const started = await startWorkflowRun(workflowProject, { authorization, sourceOutputs }) + const started = await startWorkflowRun(workflowProject, { sourceOutputs }) applyWorkflowRunProjection(started) setRunState({ status: "running", projection: started, events: [], error: null }) - const replay = await replayWorkflowRunEventStream(started.runId, { authorization }) + const replay = await replayWorkflowRunEventStream(started.runId) for (const event of replay.events) { applyWorkflowNodeRunEvent(event) } @@ -155,8 +152,8 @@ export function RunTracePanel({ runRequestId = 0 }: { runRequestId?: number }) { applyWorkflowRunProjection(finalProjection) setRunState({ status: "ready", projection: finalProjection, events: replay.events, error: null }) await Promise.all([ - loadEvidenceBatchResults(finalProjection.runId, authorization), - loadResearchLedger(finalProjection.runId, authorization), + loadEvidenceBatchResults(finalProjection.runId), + loadResearchLedger(finalProjection.runId), ]) } catch (error) { setRunState((current) => ({ @@ -194,12 +191,12 @@ export function RunTracePanel({ runRequestId = 0 }: { runRequestId?: number }) { if (runRequestId > 0) runButtonRef.current?.click() }, [runRequestId]) - const loadEvidenceBatchResults = async (runId: string, authorization: string | null) => { + const loadEvidenceBatchResults = async (runId: string) => { setEvidenceState((current) => ({ ...current, status: "loading", error: null, detail: null, selectedBatchId: null })) try { const [batchList, projection] = await Promise.all([ - fetchWorkflowEvidenceBatches(runId, { authorization }), - fetchWorkflowEvidenceBatchProjection(runId, { authorization }), + fetchWorkflowEvidenceBatches(runId), + fetchWorkflowEvidenceBatchProjection(runId), ]) applyWorkflowEvidenceBatchProjection(projection, batchList.batches) setEvidenceState({ @@ -219,9 +216,9 @@ export function RunTracePanel({ runRequestId = 0 }: { runRequestId?: number }) { } } - const loadResearchLedger = async (runId: string, authorization: string | null) => { + const loadResearchLedger = async (runId: string) => { try { - const ledger = await fetchWorkflowResearchLedger(runId, { authorization }) + const ledger = await fetchWorkflowResearchLedger(runId) setResearchLedger( ledger.entries.some((entry) => entry.revisionId || entry.decision) ? ledger : null, ) @@ -234,10 +231,7 @@ export function RunTracePanel({ runRequestId = 0 }: { runRequestId?: number }) { if (!projection) return setEvidenceState((current) => ({ ...current, status: "loading", selectedBatchId: batchId, detail: null, error: null })) try { - const token = getApiAuthToken() - const detail = await fetchWorkflowEvidenceBatchDetail(projection.runId, batchId, { - authorization: token ? `Bearer ${token}` : null, - }) + const detail = await fetchWorkflowEvidenceBatchDetail(projection.runId, batchId) setEvidenceState((current) => ({ ...current, status: "ready", detail, error: null })) } catch (error) { setEvidenceState((current) => ({ @@ -267,8 +261,6 @@ export function RunTracePanel({ runRequestId = 0 }: { runRequestId?: number }) { throw new Error("sourceOutputs 必须是非空的 { nodeId: object[] } JSON") } const sourceOutputs = parsed as Record>> - const token = getApiAuthToken() - const authorization = token ? `Bearer ${token}` : null const idempotencyKey = continuationKey || crypto.randomUUID() setContinuationKey(idempotencyKey) const continued = await continueWorkflowResearch( @@ -279,7 +271,6 @@ export function RunTracePanel({ runRequestId = 0 }: { runRequestId?: number }) { idempotencyKey, sourceOutputs, }, - { authorization }, ) applyWorkflowRunProjection(continued.projection) setRunState((current) => ({ @@ -288,7 +279,7 @@ export function RunTracePanel({ runRequestId = 0 }: { runRequestId?: number }) { events: current.events, error: null, })) - const replay = await replayWorkflowRunEventStream(continued.childRunId, { authorization }) + const replay = await replayWorkflowRunEventStream(continued.childRunId) for (const event of replay.events) applyWorkflowNodeRunEvent(event) const finalProjection = replay.projection ?? continued.projection applyWorkflowRunProjection(finalProjection) @@ -299,8 +290,8 @@ export function RunTracePanel({ runRequestId = 0 }: { runRequestId?: number }) { error: null, }) await Promise.all([ - loadEvidenceBatchResults(finalProjection.runId, authorization), - loadResearchLedger(finalProjection.runId, authorization), + loadEvidenceBatchResults(finalProjection.runId), + loadResearchLedger(finalProjection.runId), ]) setContinuationInput("{}") setContinuationKey("") @@ -314,22 +305,19 @@ export function RunTracePanel({ runRequestId = 0 }: { runRequestId?: number }) { const runBackendPreview = async () => { setBackendState((current) => ({ status: "running", compile: current.compile, trace: current.trace, native: current.native, error: null })) try { - const token = getApiAuthToken() - const authorization = token ? `Bearer ${token}` : null const nativePackageNodeId = findNativeIntelligenceWorkflowPackageNodeId(workflowProject) const [compile, nativeDependencies] = await Promise.all([ - compileWorkflowProject(workflowProject, { authorization }), + compileWorkflowProject(workflowProject), nativePackageNodeId ? Promise.all([ - fetchWorkflowCapabilities({ authorization }), - fetchWorkflowToolCapabilities({ authorization }), + fetchWorkflowCapabilities(), + fetchWorkflowToolCapabilities(), ]) : Promise.resolve(null), ]) const openCLIPackageNodeId = findOpenCLIHDAWorkflowPackageNodeId(workflowProject) const trace = compile.valid && openCLIPackageNodeId ? await traceOpenCLIHDAWorkflow(workflowProject, { - authorization, packageNodeId: openCLIPackageNodeId, }) : null diff --git a/frontend/e2e/login.spec.mjs b/frontend/e2e/login.spec.mjs index f44344b7..53af7911 100644 --- a/frontend/e2e/login.spec.mjs +++ b/frontend/e2e/login.spec.mjs @@ -1,7 +1,119 @@ import { expect, test } from '@playwright/test' -test('login page renders its administrator credentials form', async ({ page }) => { - await page.goto('/login') - await expect(page.getByText('登录控制台')).toBeVisible() - await expect(page.getByLabel('管理员身份令牌')).toBeVisible() +const identity = { + subject: 'local:owner', + email: null, + name: '家庭管理员', + username: 'owner', + picture: null, + is_platform_admin: true, + auth_method: 'local', +} + +const response = (data) => ({ success: true, data }) + +test('uninitialized device creates its local owner with a one-time claim code', async ({ page }) => { + let setupRequest + let csrfHeader + + await page.route('**/api/v1/auth/status', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify( + response({ + initialized: false, + oidc_enabled: false, + local_login_enabled: true, + recovery_enabled: false, + }), + ), + }), + ) + await page.route('**/api/v1/auth/setup', async (route) => { + setupRequest = route.request().postDataJSON() + csrfHeader = route.request().headers()['x-opencli-csrf'] + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(response(identity)), + }) + }) + + await page.goto('/login?returnTo=/login') + + await expect(page.getByRole('heading', { name: '设置此设备' })).toBeVisible() + await expect(page.getByLabel('设备认领码')).toBeVisible() + await expect(page.getByPlaceholder('BOOTSTRAP_ADMIN_TOKEN')).toHaveCount(0) + await expect(page.getByPlaceholder('API_AUTH_TOKEN')).toHaveCount(0) + + await page.getByLabel('设备认领码').fill('claim-once') + await page.getByLabel('管理员用户名').fill('owner') + await page.getByLabel('显示名称(可选)').fill('家庭管理员') + await page.getByLabel('管理员密码').fill('owner-password') + await page.getByLabel('确认密码').fill('owner-password') + await expect(page.getByRole('checkbox', { name: '记住此设备' })).toBeChecked() + await page.getByRole('button', { name: '完成设置并进入控制台' }).click() + + await expect.poll(() => setupRequest).toEqual({ + claim_code: 'claim-once', + username: 'owner', + display_name: '家庭管理员', + password: 'owner-password', + remember_device: true, + }) + expect(csrfHeader).toBe('1') +}) + +test('initialized device defaults to local account login without operator tokens', async ({ page }) => { + let loginRequest + let csrfHeader + + await page.route('**/api/v1/auth/status', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify( + response({ + initialized: true, + oidc_enabled: false, + local_login_enabled: true, + recovery_enabled: true, + }), + ), + }), + ) + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ detail: 'Not authenticated' }), + }), + ) + await page.route('**/api/v1/auth/login', async (route) => { + loginRequest = route.request().postDataJSON() + csrfHeader = route.request().headers()['x-opencli-csrf'] + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(response(identity)), + }) + }) + + await page.goto('/login?returnTo=/login') + + await expect(page.getByRole('heading', { name: '登录控制台' })).toBeVisible() + await expect(page.getByText('使用本机管理员账号登录。')).toBeVisible() + await expect(page.getByText('当前未配置组织登录')).toHaveCount(0) + await expect(page.getByText('Fleet API 令牌(可选)')).toHaveCount(0) + await expect(page.getByText('管理员身份令牌')).toHaveCount(0) + + await page.getByLabel('管理员用户名').fill('owner') + await page.getByLabel('密码').fill('owner-password') + await page.getByRole('checkbox', { name: '记住此设备' }).uncheck() + await page.getByRole('button', { name: '登录', exact: true }).click() + + await expect.poll(() => loginRequest).toEqual({ + username: 'owner', + password: 'owner-password', + remember_device: false, + }) + expect(csrfHeader).toBe('1') + await expect(page.getByText('紧急恢复')).toBeVisible() }) diff --git a/frontend/features/image-studio/platform-canvas-host-bridge.ts b/frontend/features/image-studio/platform-canvas-host-bridge.ts index afd72758..84f0b194 100644 --- a/frontend/features/image-studio/platform-canvas-host-bridge.ts +++ b/frontend/features/image-studio/platform-canvas-host-bridge.ts @@ -1,5 +1,5 @@ import { apiClient } from '@/lib/api/client' -import { getApiAuthToken } from '@/lib/api/auth-token' +import { getApiAuthHeaders } from '@/lib/api/auth-headers' import { EMPTY_CANVAS_RECIPE, @@ -30,8 +30,7 @@ function segment(value: string): string { } function platformHeaders(accept: string): HeadersInit { - const token = getApiAuthToken() - return token ? { Accept: accept, Authorization: `Bearer ${token}` } : { Accept: accept } + return { Accept: accept, ...getApiAuthHeaders() } } export function createPlatformCanvasHostBridge(scope: ImageStudioScope): CanvasHostBridge { diff --git a/frontend/lib/api/auth-headers.ts b/frontend/lib/api/auth-headers.ts index 11752ff6..b64c4955 100644 --- a/frontend/lib/api/auth-headers.ts +++ b/frontend/lib/api/auth-headers.ts @@ -4,17 +4,16 @@ import { isDevelopmentLoginAllowed, } from '@/lib/auth/session' -import { getApiAuthToken } from './auth-token' - export type ApiAuthHeaders = { Authorization?: string 'X-API-Token'?: string 'X-OpenCLI-Development-Identity'?: string + 'X-OpenCLI-CSRF'?: string + Cookie?: string } export function getApiAuthHeaders(authorizationOverride?: string | null): ApiAuthHeaders { const identityToken = getIdentityAccessToken() - const fleetToken = getApiAuthToken() const developmentIdentity = isDevelopmentLoginAllowed() && hasDevelopmentSession() ? { 'X-OpenCLI-Development-Identity': 'local-development' as const } @@ -23,20 +22,19 @@ export function getApiAuthHeaders(authorizationOverride?: string | null): ApiAut if (authorizationOverride) { return { Authorization: authorizationOverride, - ...(identityToken && fleetToken ? { 'X-API-Token': fleetToken } : {}), + 'X-OpenCLI-CSRF': '1', ...developmentIdentity, } } if (identityToken) { return { Authorization: `Bearer ${identityToken}`, - ...(fleetToken ? { 'X-API-Token': fleetToken } : {}), + 'X-OpenCLI-CSRF': '1', ...developmentIdentity, } } return { - ...(fleetToken ? { Authorization: `Bearer ${fleetToken}` } : {}), + 'X-OpenCLI-CSRF': '1', ...developmentIdentity, } } - diff --git a/frontend/lib/api/auth-token.ts b/frontend/lib/api/auth-token.ts index 57ea609f..3a628800 100644 --- a/frontend/lib/api/auth-token.ts +++ b/frontend/lib/api/auth-token.ts @@ -1,54 +1,30 @@ -// Fleet auth token resolution (ADR-0005, closeout issue 04). -// -// The backend guards every /api route with a static fleet token once -// API_AUTH_TOKEN is configured. When an OIDC identity is also present this -// token travels in X-API-Token, leaving Authorization for the user identity. -// -// Token source, in priority order: -// 1. localStorage 'apiAuthToken' — runtime override, wins so the operator -// can set the token once per browser without rebuilding the bundle. -// 2. NEXT_PUBLIC_API_AUTH_TOKEN — baked in at build time. -// Empty result = no header attached (dev posture: tokenless localhost API). +// Browser-held Fleet credentials were used by the legacy operator login. +// Human sessions now use an HttpOnly cookie, so this module exists only to +// remove the old value for users upgrading in place. Machine-side Fleet +// authentication is forwarded by server routes and never sourced here. export const API_AUTH_TOKEN_KEY = 'apiAuthToken' -/** - * Pure resolution logic (node --test friendly): the stored runtime override - * wins over the build-time token; blank/whitespace values count as unset. - */ -export function resolveApiAuthToken( - buildToken: string | null | undefined, - storedToken: string | null | undefined, -): string { - const stored = typeof storedToken === 'string' ? storedToken.trim() : '' - if (stored) return stored - return typeof buildToken === 'string' ? buildToken.trim() : '' -} +let legacyTokenCleared = false -function safeGetItem(key: string): string | null { +export function clearLegacyApiAuthToken(): void { + if (legacyTokenCleared) return try { - if (typeof localStorage === 'undefined') return null - return localStorage.getItem(key) + if (typeof localStorage !== 'undefined') localStorage.removeItem(API_AUTH_TOKEN_KEY) + if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(API_AUTH_TOKEN_KEY) + legacyTokenCleared = true } catch { - return null + // Storage can be unavailable in private browsing or hardened browsers. } } -/** Current effective token for this browser session ('' = none). */ +/** Browser API calls authenticate with OIDC or the HttpOnly local cookie. */ export function getApiAuthToken(): string { - // Next.js inlines NEXT_PUBLIC_* at build time; safe to read on the client. - const buildToken = process.env.NEXT_PUBLIC_API_AUTH_TOKEN - return resolveApiAuthToken(buildToken, safeGetItem(API_AUTH_TOKEN_KEY)) + clearLegacyApiAuthToken() + return '' } -/** Persist a runtime token override for this browser ('' clears it). */ -export function setApiAuthToken(token: string): void { - try { - if (typeof localStorage === 'undefined') return - const trimmed = token.trim() - if (trimmed) localStorage.setItem(API_AUTH_TOKEN_KEY, trimmed) - else localStorage.removeItem(API_AUTH_TOKEN_KEY) - } catch { - /* ignore */ - } +/** @deprecated Runtime Fleet credentials are no longer accepted in browsers. */ +export function setApiAuthToken(_token: string): void { + clearLegacyApiAuthToken() } diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index d1df00a0..0bc0044b 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -5,23 +5,34 @@ import { notifyAuthRequired } from './auth-events' export const apiClient = axios.create({ baseURL: '/api/v1', + withCredentials: true, headers: { 'Content-Type': 'application/json' }, }) export const rootClient = axios.create({ + withCredentials: true, headers: { 'Content-Type': 'application/json' }, }) -// Attach user identity and fleet transport credentials centrally. With both -// configured, Authorization carries OIDC/bootstrap identity and X-API-Token -// carries the deployment's fleet credential (ADR-0005). +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) + +// Attach OIDC identity centrally. Local human sessions are carried only by +// the browser-managed HttpOnly cookie; browsers never attach Fleet tokens. const attachAuthHeaders = (config: InternalAxiosRequestConfig) => { const headers = getApiAuthHeaders() if (headers.Authorization && !config.headers.Authorization) { config.headers.Authorization = headers.Authorization } - if (headers['X-API-Token'] && !config.headers['X-API-Token']) { - config.headers['X-API-Token'] = headers['X-API-Token'] + if ( + headers['X-OpenCLI-Development-Identity'] && + !config.headers['X-OpenCLI-Development-Identity'] + ) { + config.headers['X-OpenCLI-Development-Identity'] = + headers['X-OpenCLI-Development-Identity'] + } + const method = config.method?.toUpperCase() ?? 'GET' + if (!SAFE_METHODS.has(method) && !config.headers['X-OpenCLI-CSRF']) { + config.headers['X-OpenCLI-CSRF'] = '1' } return config } @@ -39,7 +50,9 @@ rootClient.interceptors.request.use(attachAuthHeaders) // instead, additive to every existing caller that only reads `.message`. const normalizeApiError = (err: unknown) => { if (axios.isAxiosError(err)) { - if (err.response?.status === 401) notifyAuthRequired() + const authenticationAttempt = + err.config?.url === '/auth/login' || err.config?.url === '/auth/setup' + if (err.response?.status === 401 && !authenticationAttempt) notifyAuthRequired() const detail = err.response?.data?.detail const detailIsList = Array.isArray(detail) const message = diff --git a/frontend/lib/api/endpoints.ts b/frontend/lib/api/endpoints.ts index fe0b8dd0..b9919ead 100644 --- a/frontend/lib/api/endpoints.ts +++ b/frontend/lib/api/endpoints.ts @@ -1,5 +1,11 @@ import { apiClient, rootClient } from './client' -import type { AuthIdentity } from '@/lib/auth/types' +import type { + AuthIdentity, + AuthServerStatus, + AuthSignOutResult, + LocalAuthLoginInput, + LocalAuthSetupInput, +} from '@/lib/auth/types' import type { AIAgent, AdvisoryReport, @@ -92,6 +98,18 @@ export const resetWorkspaceSettings = () => export const getCurrentIdentity = () => apiClient.get>('/auth/me').then((r) => r.data.data) +export const getAuthStatus = () => + apiClient.get>('/auth/status').then((r) => r.data.data) + +export const setupLocalAuth = (data: LocalAuthSetupInput) => + apiClient.post>('/auth/setup', data).then((r) => r.data.data) + +export const loginLocalAuth = (data: LocalAuthLoginInput) => + apiClient.post>('/auth/login', data).then((r) => r.data.data) + +export const logoutCurrentSession = () => + apiClient.post>('/auth/logout').then((r) => r.data.data) + export const listMyWorkspaces = () => apiClient.get>('/workspaces').then((r) => r.data.data) diff --git a/frontend/lib/api/server-auth.ts b/frontend/lib/api/server-auth.ts new file mode 100644 index 00000000..b41fa0ff --- /dev/null +++ b/frontend/lib/api/server-auth.ts @@ -0,0 +1,43 @@ +import { forwardedRequestAuthHeaders } from '@/lib/workflow/request-auth' + +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://127.0.0.1:8031' + +/** Protect Next-owned mutation routes with the same identity boundary as the API. */ +export async function requireAuthenticatedMutation(request: Request): Promise { + const csrfValid = request.headers.get('x-opencli-csrf') === '1' + const trustedDevelopmentIdentity = + process.env.NODE_ENV !== 'production' && + request.headers.get('x-opencli-development-identity') === 'local-development' + if (trustedDevelopmentIdentity) { + return csrfValid ? null : csrfError() + } + + let identityResponse: Response + try { + identityResponse = await fetch(`${BACKEND_URL}/api/v1/auth/me`, { + headers: forwardedRequestAuthHeaders(request), + cache: 'no-store', + }) + } catch { + return Response.json( + { success: false, error: 'AUTH_SERVICE_UNAVAILABLE' }, + { status: 503 }, + ) + } + + if (!identityResponse.ok) { + return Response.json( + { success: false, error: 'AUTHENTICATION_REQUIRED' }, + { status: identityResponse.status === 401 ? 401 : 503 }, + ) + } + if (!csrfValid) return csrfError() + return null +} + +function csrfError(): Response { + return Response.json( + { success: false, error: 'CSRF_HEADER_REQUIRED' }, + { status: 403 }, + ) +} diff --git a/frontend/lib/auth/session.ts b/frontend/lib/auth/session.ts index d0cd8b5e..cbf6765b 100644 --- a/frontend/lib/auth/session.ts +++ b/frontend/lib/auth/session.ts @@ -1,4 +1,4 @@ -const BOOTSTRAP_TOKEN_KEY = 'opencli.bootstrapIdentityToken' +const LEGACY_BOOTSTRAP_TOKEN_KEY = 'opencli.bootstrapIdentityToken' const DEVELOPMENT_SESSION_KEY = 'opencli.developmentSession' let runtimeIdentityToken = '' @@ -23,26 +23,20 @@ function safeSessionSet(key: string, value: string): void { } export function getIdentityAccessToken(): string { - return runtimeIdentityToken || safeSessionGet(BOOTSTRAP_TOKEN_KEY) + return runtimeIdentityToken } export function setRuntimeIdentityToken(token: string): void { runtimeIdentityToken = token.trim() } -export function getBootstrapIdentityToken(): string { - return safeSessionGet(BOOTSTRAP_TOKEN_KEY) -} - -export function persistBootstrapIdentityToken(token: string): void { - const trimmed = token.trim() - runtimeIdentityToken = trimmed - safeSessionSet(BOOTSTRAP_TOKEN_KEY, trimmed) +export function clearLegacyBootstrapIdentityToken(): void { + safeSessionSet(LEGACY_BOOTSTRAP_TOKEN_KEY, '') } export function clearIdentityToken(): void { runtimeIdentityToken = '' - safeSessionSet(BOOTSTRAP_TOKEN_KEY, '') + clearLegacyBootstrapIdentityToken() } export function hasDevelopmentSession(): boolean { @@ -60,4 +54,3 @@ export function isDevelopmentLoginAllowed(): boolean { process.env.NEXT_PUBLIC_ALLOW_UNAUTHENTICATED_DEV !== 'false' ) } - diff --git a/frontend/lib/auth/types.ts b/frontend/lib/auth/types.ts index da65ef3f..3a4297e9 100644 --- a/frontend/lib/auth/types.ts +++ b/frontend/lib/auth/types.ts @@ -5,7 +5,33 @@ export type AuthIdentity = { username: string | null picture: string | null is_platform_admin: boolean - auth_method: 'oidc' | 'bootstrap' | 'development' | string + auth_method: 'local' | 'oidc' | 'bootstrap' | 'development' | string } -export type AuthStatus = 'loading' | 'authenticated' | 'anonymous' +export type AuthServerStatus = { + initialized: boolean + claim_available?: boolean + oidc_enabled: boolean + local_login_enabled: boolean + recovery_enabled: boolean +} + +export type LocalAuthSetupInput = { + claim_code: string + username: string + display_name?: string + password: string + remember_device: boolean +} + +export type LocalAuthLoginInput = { + username: string + password: string + remember_device: boolean +} + +export type AuthSignOutResult = { + signed_out: true +} + +export type AuthStatus = 'loading' | 'setup-required' | 'authenticated' | 'anonymous' diff --git a/frontend/lib/plugins/backend-node-capabilities.ts b/frontend/lib/plugins/backend-node-capabilities.ts index 695aef97..ba0929f9 100644 --- a/frontend/lib/plugins/backend-node-capabilities.ts +++ b/frontend/lib/plugins/backend-node-capabilities.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query" -import { getApiAuthToken } from "@/lib/api/auth-token" +import { getApiAuthHeaders } from "@/lib/api/auth-headers" import type { WorkflowCapability, WorkflowNodeKind, @@ -154,6 +154,5 @@ function readApiError(payload: ApiResponse | null, fallback: string): stri } function apiAuthHeaders(): HeadersInit { - const token = getApiAuthToken() - return token ? { Authorization: `Bearer ${token}` } : {} + return getApiAuthHeaders() } diff --git a/frontend/lib/plugins/backend-plugin-catalog.ts b/frontend/lib/plugins/backend-plugin-catalog.ts index 1ca3fc44..3999b4a1 100644 --- a/frontend/lib/plugins/backend-plugin-catalog.ts +++ b/frontend/lib/plugins/backend-plugin-catalog.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query" -import { getApiAuthToken } from "@/lib/api/auth-token" +import { getApiAuthHeaders } from "@/lib/api/auth-headers" export type BackendPluginBlocker = { code: string @@ -124,6 +124,5 @@ function readApiError(payload: ApiResponse | null, fallback: string): stri } function apiAuthHeaders(): HeadersInit { - const token = getApiAuthToken() - return token ? { Authorization: `Bearer ${token}` } : {} + return getApiAuthHeaders() } diff --git a/frontend/lib/workflow/backend-bbx-tool-nodes.ts b/frontend/lib/workflow/backend-bbx-tool-nodes.ts index 7b854e1a..195eb521 100644 --- a/frontend/lib/workflow/backend-bbx-tool-nodes.ts +++ b/frontend/lib/workflow/backend-bbx-tool-nodes.ts @@ -1,4 +1,5 @@ import type { WorkflowNodeCatalogItem } from "./node-catalog" +import { workflowRequestAuthHeaders } from "./request-auth" type ApiResponse = { success?: boolean @@ -59,7 +60,7 @@ export async function fetchWorkflowBbxToolNodes( const query = params.toString() const response = await fetch(`/api/workflow/bbx-tool-nodes${query ? `?${query}` : ""}`, { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", signal: options.signal, diff --git a/frontend/lib/workflow/backend-dify-import.ts b/frontend/lib/workflow/backend-dify-import.ts index 119c65fe..e84d6196 100644 --- a/frontend/lib/workflow/backend-dify-import.ts +++ b/frontend/lib/workflow/backend-dify-import.ts @@ -7,6 +7,7 @@ import type { DifyInspectionSummary, DifyTranslationReport, } from "./dify-translator" +import { workflowRequestAuthHeaders } from "./request-auth" type ApiResponse = { success?: boolean @@ -44,7 +45,7 @@ export async function importDifyWorkflow( method: "POST", headers: { "Content-Type": "application/json", - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, body: JSON.stringify({ source, diff --git a/frontend/lib/workflow/backend-opentabs-tool-nodes.ts b/frontend/lib/workflow/backend-opentabs-tool-nodes.ts index 388684bd..a2dbcfbb 100644 --- a/frontend/lib/workflow/backend-opentabs-tool-nodes.ts +++ b/frontend/lib/workflow/backend-opentabs-tool-nodes.ts @@ -1,4 +1,5 @@ import type { WorkflowNodeCatalogItem } from "./node-catalog" +import { workflowRequestAuthHeaders } from "./request-auth" type ApiResponse = { success?: boolean @@ -60,7 +61,7 @@ export async function fetchWorkflowOpenTabsToolNodes( const query = params.toString() const response = await fetch(`/api/workflow/opentabs-tool-nodes${query ? `?${query}` : ""}`, { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", signal: options.signal, diff --git a/frontend/lib/workflow/backend-runs.ts b/frontend/lib/workflow/backend-runs.ts index 8756a29b..a5711265 100644 --- a/frontend/lib/workflow/backend-runs.ts +++ b/frontend/lib/workflow/backend-runs.ts @@ -1,4 +1,5 @@ import type { WorkflowProject } from "./schema" +import { workflowRequestAuthHeaders } from "./request-auth" type ApiResponse = { success?: boolean @@ -292,7 +293,7 @@ export async function startWorkflowRun( method: "POST", headers: { "Content-Type": "application/json", - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, body: JSON.stringify({ project, @@ -312,7 +313,7 @@ export async function fetchWorkflowRunProjection( ): Promise { const response = await fetch(workflowRunEndpoint(runId), { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", }) @@ -325,7 +326,7 @@ export async function fetchWorkflowRunCheckpoint( ): Promise { const response = await fetch(`${workflowRunEndpoint(runId)}/checkpoint`, { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", }) @@ -350,7 +351,7 @@ export async function queryWorkflowRunTrace( const suffix = search.size > 0 ? `?${search.toString()}` : "" const response = await fetch(`${workflowRunEndpoint(runId)}/trace${suffix}`, { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", }) @@ -375,7 +376,7 @@ export async function fetchWorkflowRunEvents( const suffix = search.size > 0 ? `?${search.toString()}` : "" const response = await fetch(`${workflowRunEndpoint(runId)}/events${suffix}`, { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", }) @@ -391,7 +392,7 @@ export async function continueWorkflowRunWithSourceOutputs( method: "POST", headers: { "Content-Type": "application/json", - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, body: JSON.stringify({ sourceOutputs }), }) @@ -404,7 +405,7 @@ export async function fetchWorkflowResearchLedger( ): Promise { const response = await fetch(`${workflowRunEndpoint(runId)}/research-ledger`, { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", }) @@ -425,7 +426,7 @@ export async function continueWorkflowResearch( method: "POST", headers: { "Content-Type": "application/json", - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, body: JSON.stringify(input), }) @@ -443,7 +444,7 @@ export async function replayWorkflowRunEventStream( } const response = await fetch(`${workflowRunEndpoint(runId)}/events/stream`, { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", }) @@ -477,7 +478,7 @@ export async function fetchWorkflowEvidenceBatches( const suffix = search.size > 0 ? `?${search.toString()}` : "" const response = await fetch(`${workflowEvidenceBatchEndpoint(runId)}${suffix}`, { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", }) @@ -491,7 +492,7 @@ export async function fetchWorkflowEvidenceBatchDetail( ): Promise { const response = await fetch(workflowEvidenceBatchEndpoint(runId, batchId), { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", }) @@ -514,7 +515,7 @@ export async function fetchWorkflowEvidenceBatchProjection( const suffix = search.size > 0 ? `?${search.toString()}` : "" const response = await fetch(`${workflowEvidenceBatchEndpoint(runId)}/projection${suffix}`, { headers: { - ...(options.authorization ? { Authorization: options.authorization } : {}), + ...workflowRequestAuthHeaders(options.authorization), }, cache: "no-store", }) diff --git a/frontend/lib/workflow/request-auth.ts b/frontend/lib/workflow/request-auth.ts index 27e9822b..bfe51a4f 100644 --- a/frontend/lib/workflow/request-auth.ts +++ b/frontend/lib/workflow/request-auth.ts @@ -8,12 +8,15 @@ export function forwardedRequestAuthHeaders(request: Request): ApiAuthHeaders { const authorization = request.headers.get('authorization') const fleetToken = request.headers.get('x-api-token') const developmentIdentity = request.headers.get('x-opencli-development-identity') + const csrf = request.headers.get('x-opencli-csrf') + const cookie = request.headers.get('cookie') return { ...(authorization ? { Authorization: authorization } : {}), ...(fleetToken ? { 'X-API-Token': fleetToken } : {}), + ...(csrf ? { 'X-OpenCLI-CSRF': csrf } : {}), + ...(cookie ? { Cookie: cookie } : {}), ...(developmentIdentity ? { 'X-OpenCLI-Development-Identity': developmentIdentity } : {}), } } - diff --git a/frontend/scripts/check-login-theme-regressions.mjs b/frontend/scripts/check-login-theme-regressions.mjs index 40eddf7a..314f2703 100644 --- a/frontend/scripts/check-login-theme-regressions.mjs +++ b/frontend/scripts/check-login-theme-regressions.mjs @@ -18,11 +18,50 @@ test('login preserves the current auth paths and reduced-motion fallback', async const login = await read('app/login/page.tsx') assert.match(login, /signInWithOidc/) - assert.match(login, /signInWithBootstrap/) + assert.match(login, /setupLocalAccount/) + assert.match(login, /signInWithLocal/) assert.match(login, /enterDevelopmentMode/) + assert.doesNotMatch(login, /signInWithBootstrap/) assert.match(login, /prefers-reduced-motion: reduce/) }) +test('local owner setup replaces browser-held operator credentials', async () => { + const [login, provider, session, authToken, headers, client, endpoints] = await Promise.all([ + read('app/login/page.tsx'), + read('components/auth/auth-provider.tsx'), + read('lib/auth/session.ts'), + read('lib/api/auth-token.ts'), + read('lib/api/auth-headers.ts'), + read('lib/api/client.ts'), + read('lib/api/endpoints.ts'), + ]) + + assert.match(login, /设置此设备/) + assert.match(login, /设备认领码/) + assert.match(login, /设备认领码尚未配置/) + assert.match(login, /DEVICE_CLAIM_CODE/) + assert.match(login, /管理员密码至少需要 10 个字符/) + assert.match(login, /minLength=\{10\}/) + assert.match(login, /使用本机管理员账号登录/) + assert.doesNotMatch(login, /BOOTSTRAP_ADMIN_TOKEN|API_AUTH_TOKEN|Fleet API 令牌/) + assert.match(provider, /getAuthStatus/) + assert.match(provider, /setupLocalAuth/) + assert.match(provider, /loginLocalAuth/) + assert.match(provider, /logoutCurrentSession/) + assert.doesNotMatch(provider, /persistBootstrapIdentityToken|setApiAuthToken/) + assert.match(session, /return runtimeIdentityToken/) + assert.doesNotMatch(session, /sessionStorage\.setItem\(.*bootstrap/i) + assert.match(authToken, /localStorage\.removeItem\(API_AUTH_TOKEN_KEY\)/) + assert.match(authToken, /return ''/) + assert.doesNotMatch(headers, /getApiAuthToken/) + assert.doesNotMatch(client, /headers\['X-API-Token'\]/) + assert.match(client, /X-OpenCLI-CSRF/) + assert.match(endpoints, /get>\('\/auth\/status'\)/) + assert.match(endpoints, /post>\('\/auth\/setup'/) + assert.match(endpoints, /post>\('\/auth\/login'/) + assert.match(endpoints, /post>\('\/auth\/logout'\)/) +}) + test('auth defaults return to the project list instead of a contextless workflow', async () => { const [provider, oidc] = await Promise.all([ read('components/auth/auth-provider.tsx'), @@ -58,3 +97,62 @@ test('OIDC keeps PKCE in the browser while proxying CORS-blocked token and JWKS assert.match(header, /identity\?\.picture/) assert.match(header, /identity\?\.username/) }) + +test('local owner credentials survive browser and workflow proxy boundaries', async () => { + const proxyPaths = [ + 'app/api/workflow/opentabs-tool-nodes/route.ts', + 'app/api/workflow/bbx-tool-nodes/route.ts', + 'app/api/workflow/evidence-batch-proxy.ts', + 'app/api/workflow/import/dify/route.ts', + 'app/api/workflow/runs/[runId]/research-continuations/route.ts', + 'app/api/workflow/runs/[runId]/research-ledger/route.ts', + ] + const [headers, requestAuth, pluginCatalog, nodeCapabilities, imageBridge, ...proxies] = + await Promise.all([ + read('lib/api/auth-headers.ts'), + read('lib/workflow/request-auth.ts'), + read('lib/plugins/backend-plugin-catalog.ts'), + read('lib/plugins/backend-node-capabilities.ts'), + read('features/image-studio/platform-canvas-host-bridge.ts'), + ...proxyPaths.map(read), + ]) + + assert.match(headers, /'X-OpenCLI-CSRF': '1'/) + assert.match(requestAuth, /request\.headers\.get\('cookie'\)/) + assert.match(requestAuth, /request\.headers\.get\('x-opencli-csrf'\)/) + assert.match(requestAuth, /Cookie: cookie/) + assert.match(requestAuth, /'X-OpenCLI-CSRF': csrf/) + + for (const proxy of proxies) { + assert.match(proxy, /forwardedRequestAuthHeaders\(req\)/) + assert.doesNotMatch(proxy, /req\.headers\.get\(["']authorization["']\)/) + } + + assert.match(pluginCatalog, /getApiAuthHeaders\(\)/) + assert.match(nodeCapabilities, /getApiAuthHeaders\(\)/) + assert.match(imageBridge, /getApiAuthHeaders\(\)/) + assert.doesNotMatch(`${pluginCatalog}\n${nodeCapabilities}\n${imageBridge}`, /getApiAuthToken/) +}) + +test('Next-owned mutations authenticate server-side and require CSRF', async () => { + const [guard, generateRoute, renderRoute, studioNew, palette, strip] = await Promise.all([ + read('lib/api/server-auth.ts'), + read('app/api/generate-workflow/route.ts'), + read('app/api/render/route.ts'), + read('app/(app)/studio/new/page.tsx'), + read('components/flow/command-palette.tsx'), + read('components/flow/command-strip.tsx'), + ]) + + assert.match(guard, /\/api\/v1\/auth\/me/) + assert.match(guard, /forwardedRequestAuthHeaders\(request\)/) + assert.match(guard, /request\.headers\.get\('x-opencli-csrf'\) === '1'/) + assert.match(guard, /process\.env\.NODE_ENV !== 'production'/) + assert.match(guard, /'local-development'/) + for (const route of [generateRoute, renderRoute]) { + assert.match(route, /requireAuthenticatedMutation\(req\)/) + } + for (const caller of [studioNew, palette, strip]) { + assert.match(caller, /\.\.\.getApiAuthHeaders\(\)/) + } +}) diff --git a/pyproject.toml b/pyproject.toml index dc8d1a38..8c051386 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "openai>=1.58.0", # Security "python-jose[cryptography]>=3.3.0", - "passlib[bcrypt]>=1.7.0", + "bcrypt>=5.0.0,<6", # Email "aiosmtplib>=3.0.0", # Utilities diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f5e1f38c..3fd3bb72 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,6 +1,6 @@ param( [string]$Version = $(if ($env:OPENCLI_ADMIN_VERSION) { $env:OPENCLI_ADMIN_VERSION } else { "0.4.0" }), - [string]$Repository = $(if ($env:OPENCLI_ADMIN_REPOSITORY) { $env:OPENCLI_ADMIN_REPOSITORY } else { "2233admin/opencli-admin" }), + [string]$Repository = $(if ($env:OPENCLI_ADMIN_REPOSITORY) { $env:OPENCLI_ADMIN_REPOSITORY } else { "2233admin/opencli-Razormind" }), [string]$InstallDir = $(if ($env:OPENCLI_ADMIN_DIR) { $env:OPENCLI_ADMIN_DIR } else { Join-Path (Get-Location) "opencli-admin" }) ) @@ -33,7 +33,8 @@ $expanded = Join-Path $tempRoot "expanded" New-Item -ItemType Directory -Path $expanded | Out-Null try { - Invoke-WebRequest "https://github.com/$Repository/archive/refs/tags/v$Version.zip" -OutFile $archive -UseBasicParsing + & curl.exe -fsSL --retry 3 --connect-timeout 20 -o $archive "https://codeload.github.com/$Repository/zip/refs/tags/v$Version" + Assert-NativeSuccess "Failed to download the OpenCLI Admin release archive." Expand-Archive -LiteralPath $archive -DestinationPath $expanded $sourceRoot = Get-ChildItem -LiteralPath $expanded -Directory | Select-Object -First 1 if (-not $sourceRoot) { @@ -68,6 +69,40 @@ function New-FernetKey { return [Convert]::ToBase64String((New-RandomBytes 32)).Replace("+", "-").Replace("/", "_") } +function New-DeviceClaimCode([int]$Length = 10) { + $alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + return -join ((New-RandomBytes $Length) | ForEach-Object { $alphabet[$_ -band 31] }) +} + +function Get-LanIPv4Address { + try { + $candidates = foreach ($config in Get-NetIPConfiguration -ErrorAction Stop) { + if ($config.NetAdapter.Status -ne "Up" -or -not $config.IPv4DefaultGateway) { + continue + } + foreach ($address in @($config.IPv4Address)) { + if (-not $address.IPAddress) { + continue + } + $bytes = ([Net.IPAddress]::Parse($address.IPAddress)).GetAddressBytes() + $private = + $bytes[0] -eq 10 -or + ($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or + ($bytes[0] -eq 192 -and $bytes[1] -eq 168) + if ($private) { + [pscustomobject]@{ + Address = $address.IPAddress + Metric = $config.NetIPv4Interface.InterfaceMetric + } + } + } + } + return ($candidates | Sort-Object Metric | Select-Object -First 1).Address + } catch { + return $null + } +} + function Set-EnvValue([string]$Key, [string]$Value) { $content = [IO.File]::ReadAllText($envPath) $content = [Text.RegularExpressions.Regex]::Replace( @@ -80,8 +115,10 @@ function Set-EnvValue([string]$Key, [string]$Value) { $apiToken = New-HexSecret 32 $bootstrapToken = New-HexSecret 32 +$deviceClaimCode = New-DeviceClaimCode Set-EnvValue "API_AUTH_TOKEN" $apiToken Set-EnvValue "BOOTSTRAP_ADMIN_TOKEN" $bootstrapToken +Set-EnvValue "DEVICE_CLAIM_CODE" $deviceClaimCode Set-EnvValue "SECRET_KEY" (New-HexSecret 32) Set-EnvValue "CREDENTIAL_ENCRYPTION_KEY" (New-FernetKey) @@ -113,8 +150,13 @@ try { } Write-Host "" -Write-Host "OpenCLI Admin $Version is ready." -Write-Host "URL: http://localhost:$frontendPort" -Write-Host "BOOTSTRAP_ADMIN_TOKEN: $bootstrapToken" -Write-Host "API_AUTH_TOKEN: $apiToken" -Write-Host "Use BOOTSTRAP_ADMIN_TOKEN in the first login field and API_AUTH_TOKEN in the optional fleet field. Both are stored in $envPath" +Write-Host "opencli-Razormind $Version is ready." +Write-Host "Console URL: http://localhost:$frontendPort" +$lanAddress = Get-LanIPv4Address +if ($lanAddress) { + Write-Host "LAN URL: http://${lanAddress}:$frontendPort" +} +Write-Host "Device claim code: $deviceClaimCode" +Write-Host "Open the console and use this one-time code to claim the device and create the local administrator." +Write-Host "BOOTSTRAP_ADMIN_TOKEN and API_AUTH_TOKEN were generated and stored only in $envPath for emergency recovery and machine access." +Write-Host "Keep $envPath private." diff --git a/scripts/install.sh b/scripts/install.sh index a79c9a36..5fa9df7e 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu VERSION="${OPENCLI_ADMIN_VERSION:-0.4.0}" -REPOSITORY="${OPENCLI_ADMIN_REPOSITORY:-2233admin/opencli-admin}" +REPOSITORY="${OPENCLI_ADMIN_REPOSITORY:-2233admin/opencli-Razormind}" INSTALL_DIR="${OPENCLI_ADMIN_DIR:-$PWD/opencli-admin}" for command_name in docker curl tar; do @@ -49,6 +49,43 @@ random_fernet() { fi } +random_crockford() { + length="${1:-10}" + alphabet='0123456789ABCDEFGHJKMNPQRSTVWXYZ' + if command -v openssl >/dev/null 2>&1; then + openssl rand "$length" | od -An -tu1 | awk -v alphabet="$alphabet" -v needed="$length" ' + { + for (i = 1; i <= NF && count < needed; i++) { + printf "%s", substr(alphabet, ($i % 32) + 1, 1) + count++ + } + } + END { if (count != needed) exit 1 } + ' + else + docker run --rm python:3.13-alpine python -c \ + "import secrets; alphabet='0123456789ABCDEFGHJKMNPQRSTVWXYZ'; print(''.join(secrets.choice(alphabet) for _ in range($length)))" + fi +} + +detect_lan_ipv4() { + candidate='' + if command -v ip >/dev/null 2>&1; then + candidate="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "src") { print $(i + 1); exit } }')" + elif command -v route >/dev/null 2>&1 && command -v ipconfig >/dev/null 2>&1; then + interface="$(route -n get default 2>/dev/null | awk '/interface:/ { print $2; exit }')" + if [ -n "$interface" ]; then + candidate="$(ipconfig getifaddr "$interface" 2>/dev/null || true)" + fi + elif command -v hostname >/dev/null 2>&1; then + candidate="$(hostname -I 2>/dev/null | awk '{ print $1 }')" + fi + + case "$candidate" in + 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) printf '%s' "$candidate" ;; + esac +} + replace_env() { key="$1" value="$2" @@ -62,13 +99,19 @@ replace_env() { api_token="$(random_hex 32)" bootstrap_token="$(random_hex 32)" +device_claim_code="$(random_crockford 10)" credential_encryption_key="$(random_fernet)" +if [ "${#device_claim_code}" -ne 10 ]; then + echo "Failed to generate DEVICE_CLAIM_CODE" >&2 + exit 1 +fi if [ -z "$credential_encryption_key" ]; then echo "Failed to generate CREDENTIAL_ENCRYPTION_KEY" >&2 exit 1 fi replace_env API_AUTH_TOKEN "$api_token" replace_env BOOTSTRAP_ADMIN_TOKEN "$bootstrap_token" +replace_env DEVICE_CLAIM_CODE "$device_claim_code" replace_env SECRET_KEY "$(random_hex 32)" replace_env CREDENTIAL_ENCRYPTION_KEY "$credential_encryption_key" chmod 600 "$INSTALL_DIR/.env" @@ -90,7 +133,12 @@ until curl -fsS "http://localhost:${FRONTEND_PORT:-3010}/login" >/dev/null 2>&1; done printf '\nOpenCLI Admin %s is ready.\n' "$VERSION" -printf 'URL: http://localhost:%s\n' "${FRONTEND_PORT:-3010}" -printf 'BOOTSTRAP_ADMIN_TOKEN: %s\n' "$bootstrap_token" -printf 'API_AUTH_TOKEN: %s\n' "$api_token" -printf 'Use BOOTSTRAP_ADMIN_TOKEN in the first login field and API_AUTH_TOKEN in the optional fleet field. Both are stored in %s/.env\n' "$INSTALL_DIR" +printf 'Console URL: http://localhost:%s\n' "${FRONTEND_PORT:-3010}" +lan_address="$(detect_lan_ipv4)" +if [ -n "$lan_address" ]; then + printf 'LAN URL: http://%s:%s\n' "$lan_address" "${FRONTEND_PORT:-3010}" +fi +printf 'Device claim code: %s\n' "$device_claim_code" +printf 'Open the console and use this one-time code to claim the device and create the local administrator.\n' +printf 'BOOTSTRAP_ADMIN_TOKEN and API_AUTH_TOKEN were generated and stored only in %s/.env for emergency recovery and machine access.\n' "$INSTALL_DIR" +printf 'Keep %s/.env private.\n' "$INSTALL_DIR" diff --git a/tests/integration/auth/test_local_auth_api.py b/tests/integration/auth/test_local_auth_api.py new file mode 100644 index 00000000..ecef9ac2 --- /dev/null +++ b/tests/integration/auth/test_local_auth_api.py @@ -0,0 +1,271 @@ +import pytest +import pytest_asyncio +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.config import get_settings +from backend.security import local_auth +from backend.security.identity import OIDCVerifier, RequestIdentity + +FLEET_TOKEN = "fleet-test-token" +BOOTSTRAP_TOKEN = "bootstrap-test-token" +CLAIM_CODE = "01ARZ3NDEK" + + +@pytest_asyncio.fixture +async def appliance_auth(monkeypatch, db_engine): + settings = get_settings() + monkeypatch.setattr(settings, "api_auth_token", FLEET_TOKEN) + monkeypatch.setattr(settings, "bootstrap_admin_token", BOOTSTRAP_TOKEN) + monkeypatch.setattr(settings, "device_claim_code", CLAIM_CODE) + monkeypatch.setattr(settings, "oidc_issuer", "") + monkeypatch.setattr(settings, "oidc_audience", "") + monkeypatch.setattr(settings, "oidc_jwks_url", "") + monkeypatch.setattr(settings, "local_session_cookie_secure", False) + session_factory = async_sessionmaker( + db_engine, + class_=AsyncSession, + expire_on_commit=False, + ) + monkeypatch.setattr(local_auth, "local_auth_session_factory", session_factory) + yield settings + + +def _setup_body(**overrides): + body = { + "claim_code": CLAIM_CODE, + "username": "owner", + "display_name": "设备管理员", + "password": "correct horse battery staple", + "remember_device": False, + } + body.update(overrides) + return body + + +@pytest.mark.asyncio +async def test_status_exposes_missing_claim_for_upgrade_recovery( + client, + appliance_auth, + monkeypatch, +): + monkeypatch.setattr(appliance_auth, "device_claim_code", "") + status_response = await client.get("/api/v1/auth/status") + assert status_response.status_code == 200 + assert status_response.json()["data"]["initialized"] is False + assert status_response.json()["data"]["claim_available"] is False + + +@pytest.mark.asyncio +async def test_status_setup_session_csrf_logout_flow( + client, + db_session, + appliance_auth, +): + status_response = await client.get("/api/v1/auth/status") + assert status_response.status_code == 200 + assert status_response.json()["data"] == { + "initialized": False, + "claim_available": True, + "oidc_enabled": False, + "local_login_enabled": False, + "recovery_enabled": True, + } + + setup_response = await client.post("/api/v1/auth/setup", json=_setup_body()) + assert setup_response.status_code == 201 + assert setup_response.json()["data"] == { + "subject": "local:owner", + "email": None, + "name": "设备管理员", + "username": "owner", + "picture": None, + "is_platform_admin": True, + "auth_method": "local", + } + cookie = setup_response.headers["set-cookie"].lower() + assert "httponly" in cookie + assert "samesite=lax" in cookie + assert "; secure" not in cookie + assert "max-age" not in cookie + assert "opencli_session" not in setup_response.text + issued_session_token = setup_response.cookies.get(local_auth.SESSION_COOKIE_NAME) + assert issued_session_token + await db_session.commit() + + me_response = await client.get("/api/v1/auth/me") + assert me_response.status_code == 200 + assert me_response.json()["data"]["auth_method"] == "local" + + second_setup = await client.post( + "/api/v1/auth/setup", + json=_setup_body(username="second"), + ) + assert second_setup.status_code == 409 + + no_csrf = await client.post("/api/v1/auth/logout") + assert no_csrf.status_code == 403 + assert no_csrf.json() == {"success": False, "error": "CSRF header required"} + + logout = await client.post( + "/api/v1/auth/logout", + headers={"X-OpenCLI-CSRF": "1"}, + ) + assert logout.status_code == 200 + assert logout.json()["data"] == {"signed_out": True} + await db_session.commit() + + after_logout = await client.get("/api/v1/auth/me") + assert after_logout.status_code == 401 + replay_after_logout = await client.get( + "/api/v1/auth/me", + headers={ + "Cookie": f"{local_auth.SESSION_COOKIE_NAME}={issued_session_token}", + }, + ) + assert replay_after_logout.status_code == 401 + + +@pytest.mark.asyncio +async def test_secure_cookie_override_applies_to_set_and_delete_behind_proxy( + client, + db_session, + appliance_auth, + monkeypatch, +): + monkeypatch.setattr(appliance_auth, "local_session_cookie_secure", True) + + setup_response = await client.post("/api/v1/auth/setup", json=_setup_body()) + assert setup_response.status_code == 201 + set_cookie = setup_response.headers["set-cookie"].lower() + assert "; secure" in set_cookie + session_token = setup_response.cookies.get(local_auth.SESSION_COOKIE_NAME) + assert session_token + await db_session.commit() + + # The test backend is HTTP, matching TLS termination at a reverse proxy. + # Send the secure cookie explicitly because an HTTPX jar correctly refuses + # to attach Secure cookies to the internal HTTP request on its own. + logout_response = await client.post( + "/api/v1/auth/logout", + headers={ + "Cookie": f"{local_auth.SESSION_COOKIE_NAME}={session_token}", + "X-OpenCLI-CSRF": "1", + }, + ) + assert logout_response.status_code == 200 + delete_cookie = logout_response.headers["set-cookie"].lower() + assert "; secure" in delete_cookie + assert "max-age=0" in delete_cookie + + +@pytest.mark.asyncio +async def test_login_has_uniform_failure_and_local_cookie_never_authenticates_mcp( + client, + db_session, + appliance_auth, +): + setup_response = await client.post("/api/v1/auth/setup", json=_setup_body()) + assert setup_response.status_code == 201 + await db_session.commit() + client.cookies.clear() + + wrong_password = await client.post( + "/api/v1/auth/login", + json={ + "username": "owner", + "password": "wrong password", + "remember_device": True, + }, + ) + missing_user = await client.post( + "/api/v1/auth/login", + json={ + "username": "nobody", + "password": "wrong password", + "remember_device": True, + }, + ) + assert wrong_password.status_code == missing_user.status_code == 401 + assert wrong_password.json() == missing_user.json() == { + "detail": "Invalid username or password" + } + + login = await client.post( + "/api/v1/auth/login", + json={ + "username": "owner", + "password": "correct horse battery staple", + "remember_device": True, + }, + ) + assert login.status_code == 200 + assert "max-age=" in login.headers["set-cookie"].lower() + immediate_me = await client.get("/api/v1/auth/me") + assert immediate_me.status_code == 200 + assert immediate_me.json()["data"]["auth_method"] == "local" + await db_session.commit() + + mcp = await client.post( + "/mcp", + headers={"Content-Type": "application/json", "X-OpenCLI-CSRF": "1"}, + json={"jsonrpc": "2.0", "id": 1, "method": "server/discover"}, + ) + assert mcp.status_code == 401 + + +@pytest.mark.asyncio +async def test_bootstrap_bearer_crosses_fleet_barrier_without_fleet_header( + client, + appliance_auth, +): + response = await client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {BOOTSTRAP_TOKEN}"}, + ) + assert response.status_code == 200 + assert response.json()["data"]["auth_method"] == "bootstrap" + assert response.json()["data"]["is_platform_admin"] is True + + +@pytest.mark.asyncio +async def test_verified_oidc_bearer_is_human_http_auth_and_is_written_to_scope( + client, + appliance_auth, + monkeypatch, +): + settings = appliance_auth + monkeypatch.setattr(settings, "oidc_issuer", "https://id.example") + monkeypatch.setattr(settings, "oidc_audience", "opencli") + + async def fake_verify(self, token): + if token != "valid-oidc-token": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid bearer token") + return RequestIdentity( + subject="oidc-user", + email="owner@example.com", + name="OIDC User", + username="oidc-user", + ) + + monkeypatch.setattr(OIDCVerifier, "verify", fake_verify) + + config_response = await client.get( + "/api/v1/system/config", + headers={"Authorization": "Bearer valid-oidc-token"}, + ) + assert config_response.status_code == 200 + + me_response = await client.get( + "/api/v1/auth/me", + headers={"Authorization": "Bearer valid-oidc-token"}, + ) + assert me_response.status_code == 200 + assert me_response.json()["data"]["subject"] == "oidc-user" + assert me_response.json()["data"]["auth_method"] == "oidc" + + invalid_response = await client.get( + "/api/v1/system/config", + headers={"Authorization": "Bearer invalid-oidc-token"}, + ) + assert invalid_response.status_code == 401 diff --git a/tests/unit/security/test_local_auth.py b/tests/unit/security/test_local_auth.py new file mode 100644 index 00000000..942ac120 --- /dev/null +++ b/tests/unit/security/test_local_auth.py @@ -0,0 +1,133 @@ +from datetime import timedelta + +import pytest +from sqlalchemy import select + +from backend.config import Settings +from backend.models import ( + LocalAuthSession, + LocalCredential, + Workspace, + WorkspaceMembership, + WorkspaceRole, +) +from backend.security.local_auth import ( + InvalidLocalCredentials, + LocalOwnerAlreadyInitialized, + authenticate_local_owner, + claim_local_owner, + create_local_session, + get_local_session_identity, + revoke_local_session, + utcnow, +) + +CLAIM_CODE = "01ARZ3NDEK" + + +def _settings(**overrides) -> Settings: + return Settings( + _env_file=None, + device_claim_code=CLAIM_CODE, + local_login_max_failures=2, + local_login_lock_seconds=300, + **overrides, + ) + + +def test_local_session_cookie_secure_defaults_off_and_accepts_proxy_override(): + assert _settings().local_session_cookie_secure is False + assert _settings(local_session_cookie_secure=True).local_session_cookie_secure is True + + +@pytest.mark.asyncio +async def test_claim_creates_one_owner_default_workspace_and_opaque_session(db_session): + settings = _settings() + identity = await claim_local_owner( + db_session, + claim_code=CLAIM_CODE.lower(), + username=" Owner ", + display_name="设备管理员", + password="correct horse battery staple", + settings=settings, + ) + grant = await create_local_session( + db_session, + identity=identity, + remember_device=False, + settings=settings, + ) + + credential = await db_session.scalar(select(LocalCredential)) + workspace = await db_session.scalar(select(Workspace)) + membership = await db_session.scalar(select(WorkspaceMembership)) + stored_session = await db_session.scalar(select(LocalAuthSession)) + + assert identity.subject == "local:owner" + assert credential is not None + assert credential.username == "owner" + assert credential.password_hash.startswith("$bcrypt-sha256$") + assert workspace is not None and (workspace.name, workspace.slug) == ("我的空间", "my-space") + assert membership is not None and membership.role == WorkspaceRole.ADMIN + assert stored_session is not None + assert grant.token != stored_session.token_hash + assert len(stored_session.token_hash) == 64 + assert (await get_local_session_identity(db_session, grant.token)).username == "owner" + + assert await revoke_local_session(db_session, grant.token) is True + assert await get_local_session_identity(db_session, grant.token) is None + + with pytest.raises(LocalOwnerAlreadyInitialized): + await claim_local_owner( + db_session, + claim_code=CLAIM_CODE, + username="second", + display_name=None, + password="another long password", + settings=settings, + ) + + +@pytest.mark.asyncio +async def test_password_failures_lock_owner_and_expired_session_is_rejected(db_session): + settings = _settings() + identity = await claim_local_owner( + db_session, + claim_code=CLAIM_CODE, + username="owner", + display_name=None, + password="correct horse battery staple", + settings=settings, + ) + + for _ in range(2): + with pytest.raises(InvalidLocalCredentials): + await authenticate_local_owner( + db_session, + username="owner", + password="wrong password", + settings=settings, + ) + + credential = await db_session.scalar(select(LocalCredential)) + assert credential is not None + assert credential.failed_attempts == 2 + assert credential.locked_until is not None + with pytest.raises(InvalidLocalCredentials): + await authenticate_local_owner( + db_session, + username="owner", + password="correct horse battery staple", + settings=settings, + ) + + grant = await create_local_session( + db_session, + identity=identity, + remember_device=False, + settings=settings, + ) + stored_session = await db_session.scalar(select(LocalAuthSession)) + stored_session.expires_at = utcnow() - timedelta(seconds=1) + await db_session.flush() + assert await get_local_session_identity(db_session, grant.token) is None diff --git a/tests/unit/test_public_release_contract.py b/tests/unit/test_public_release_contract.py index 75c47743..6ff3e378 100644 --- a/tests/unit/test_public_release_contract.py +++ b/tests/unit/test_public_release_contract.py @@ -22,6 +22,8 @@ def test_public_release_has_a_runnable_frontend_and_safe_compose_defaults() -> N assert "${INVOKEAI_ATTESTED_IMAGE:?" not in compose assert "${API_AUTH_TOKEN:?" in compose assert "${BOOTSTRAP_ADMIN_TOKEN:?" in compose + assert "DEVICE_CLAIM_CODE: ${DEVICE_CLAIM_CODE:-}" in compose + assert "LOCAL_SESSION_COOKIE_SECURE: ${LOCAL_SESSION_COOKIE_SECURE:-false}" in compose assert 'output: "standalone"' in frontend_config assert (ROOT / "frontend" / "Dockerfile").is_file() @@ -37,8 +39,22 @@ def test_public_release_has_one_ci_frontend_job_and_installers() -> None: assert (ROOT / "scripts" / "install.ps1").is_file() assert (ROOT / ".env.docker.example").is_file() assert "BOOTSTRAP_ADMIN_TOKEN" in source(".env.docker.example") + assert "DEVICE_CLAIM_CODE" in source(".env.docker.example") + assert "LOCAL_SESSION_COOKIE_SECURE=false" in source(".env.docker.example") assert "BOOTSTRAP_ADMIN_TOKEN" in unix_installer assert "BOOTSTRAP_ADMIN_TOKEN" in windows_installer + assert "2233admin/opencli-Razormind" in unix_installer + assert "2233admin/opencli-Razormind" in windows_installer + assert "0123456789ABCDEFGHJKMNPQRSTVWXYZ" in unix_installer + assert "0123456789ABCDEFGHJKMNPQRSTVWXYZ" in windows_installer + assert "replace_env DEVICE_CLAIM_CODE" in unix_installer + assert 'Set-EnvValue "DEVICE_CLAIM_CODE"' in windows_installer + assert "Device claim code:" in unix_installer + assert "Device claim code:" in windows_installer + assert "printf 'BOOTSTRAP_ADMIN_TOKEN: %s" not in unix_installer + assert "printf 'API_AUTH_TOKEN: %s" not in unix_installer + assert 'Write-Host "BOOTSTRAP_ADMIN_TOKEN:' not in windows_installer + assert 'Write-Host "API_AUTH_TOKEN:' not in windows_installer assert 'os.environ.get("NOVNC_BASE_PORT", 6080)' in source( "backend/api/v1/browsers.py" ) @@ -49,3 +65,38 @@ def test_public_release_has_one_ci_frontend_job_and_installers() -> None: assert 'if [ -z "$credential_encryption_key" ]; then' in unix_installer assert "packages: write" in release_workflow assert "id-token: write" not in release_workflow + + +def test_nas_reference_has_current_version_and_visible_secret_sentinels() -> None: + nas_env = source(".env.nas.example") + values = dict( + line.split("=", 1) + for line in nas_env.splitlines() + if line and not line.startswith("#") and "=" in line + ) + + assert values["IMAGE_TAG"] == "0.4.0" + assert values["LOCAL_SESSION_COOKIE_SECURE"] == "false" + for key in ( + "API_AUTH_TOKEN", + "BOOTSTRAP_ADMIN_TOKEN", + "DEVICE_CLAIM_CODE", + "SECRET_KEY", + "CREDENTIAL_ENCRYPTION_KEY", + "POSTGRES_PASSWORD", + ): + assert values[key] + assert len(values["DEVICE_CLAIM_CODE"]) == 10 + assert set(values["DEVICE_CLAIM_CODE"]) <= set("0123456789ABCDEFGHJKMNPQRSTVWXYZ") + assert "opencli_secret" not in values["POSTGRES_PASSWORD"] + assert "change-me-in-production" not in nas_env + assert "不是家庭设备默认栈" in nas_env + + +def test_readme_leads_with_local_device_claim_and_keeps_oidc_optional() -> None: + readme = source("README.md") + + assert "一次性设备认领码" in readme + assert "创建本地管理员" in readme + assert "OIDC 是可选的组织登录方式" in readme + assert "不会把值打印到终端" in readme diff --git a/uv.lock b/uv.lock index 0e9a93cb..adde4b68 100644 --- a/uv.lock +++ b/uv.lock @@ -1968,7 +1968,7 @@ dependencies = [ { name = "lxml" }, { name = "mcp" }, { name = "openai" }, - { name = "passlib", extra = ["bcrypt"] }, + { name = "bcrypt" }, { name = "playwright" }, { name = "pycookiecloud" }, { name = "pydantic" }, @@ -2024,7 +2024,7 @@ requires-dist = [ { name = "mcp", specifier = "==2.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, { name = "openai", specifier = ">=1.58.0" }, - { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.0" }, + { name = "bcrypt", specifier = ">=5.0.0,<6" }, { name = "playwright", specifier = ">=1.40.0" }, { name = "pycookiecloud", specifier = ">=0.4.0" }, { name = "pydantic", specifier = ">=2.10.0" }, @@ -2069,20 +2069,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, -] - -[package.optional-dependencies] -bcrypt = [ - { name = "bcrypt" }, -] - [[package]] name = "patchright" version = "1.61.2"