diff --git a/frontend-web/src/core/auth/api.ts b/frontend-web/src/core/auth/api.ts index 94a64bdb..f919a3dc 100644 --- a/frontend-web/src/core/auth/api.ts +++ b/frontend-web/src/core/auth/api.ts @@ -17,24 +17,70 @@ export async function listAllUsers(): Promise { return res.json() as Promise; } +// Normalize a FastAPI error body into a human-readable string. `detail` may be +// a plain string, an object like AuthErrorResponse ({ code, message }), or an +// array of validation errors — anything but a string would otherwise render as +// the dreaded "[object Object]". +function extractErrorMessage(body: unknown, fallback = "Login failed."): string { + const detail = (body as { detail?: unknown } | null)?.detail ?? body; + if (typeof detail === "string") return detail; + if (Array.isArray(detail)) { + const msg = detail + .map((d) => (d && typeof d === "object" ? (d as { msg?: string }).msg : String(d))) + .filter(Boolean) + .join("; "); + return msg || fallback; + } + if (detail && typeof detail === "object") { + const obj = detail as { message?: string; msg?: string }; + return obj.message ?? obj.msg ?? fallback; + } + return fallback; +} + export async function loginByUsername( username: string, + password?: string | null, ): Promise { + // When the username-login password gate is enabled server-side, the shared + // secret must be passed as ?password=. Omitted when no password is provided + // so the passwordless flow keeps working unchanged. + const base = `${getBackendBaseURL()}/api/v1/auth/login/username`; + const url = + password != null && password !== "" + ? `${base}?password=${encodeURIComponent(password)}` + : base; + const response = await apiFetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username }), + }); + if (!response.ok) { + const error = await response.json().catch(() => null); + throw new Error(extractErrorMessage(error)); + } + return response.json() as Promise; +} + +export async function loginByToken( + token: string, +): Promise { + // Token-exchange login: the gateway resolves the upstream token to a username + // and signs the matching local account in. const response = await apiFetch( - `${getBackendBaseURL()}/api/v1/auth/login/username`, + `${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ username }), }, ); if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: "Login failed." })); - throw new Error(error.detail ?? "Login failed."); + const error = await response.json().catch(() => null); + throw new Error(extractErrorMessage(error)); } return response.json() as Promise; } diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 8e7bca92..d1ef498c 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -4,7 +4,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { useTheme } from "@/contexts/ThemeContext"; -import { loginByUsername } from "@/core/auth/api"; +import { loginByToken, loginByUsername } from "@/core/auth/api"; import { setStoredAuth } from "@/core/auth"; import { persistExternalLoginParams, @@ -40,6 +40,10 @@ export default function LoginPage() { // 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage const yUserId = searchParams.get("yUserId")?.trim() ?? ""; const toolToken = searchParams.get("token")?.trim() ?? ""; + // 用户名直登口令门:开启后端开关时,口令从 ?password= 透传 + const gatePassword = searchParams.get("password") ?? ""; + // token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开) + const authToken = searchParams.get("authToken")?.trim() ?? ""; const externalParams = useMemo(() => ({ theme: searchParams.get("theme") ?? "", ticket: searchParams.get("ticket") ?? "", @@ -71,13 +75,19 @@ export default function LoginPage() { setStatus("loading"); setErrorMessage(""); try { - if (yUserId) { - await loginByYUserId(yUserId, toolToken || null); - } else if (toolToken) { - persistToolsToken(toolToken); - } + let auth; + if (authToken) { + // token 换登录:后端用 authToken 调上游接口换出用户名再登录 + auth = await loginByToken(authToken); + } else { + if (yUserId) { + await loginByYUserId(yUserId, toolToken || null); + } else if (toolToken) { + persistToolsToken(toolToken); + } - const auth = await loginByUsername(resolvedUsername); + auth = await loginByUsername(resolvedUsername, gatePassword); + } if (cancelled) { return; } @@ -100,7 +110,7 @@ export default function LoginPage() { return () => { cancelled = true; }; - }, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken]); + }, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken, authToken, gatePassword]); return (
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 2ec69e96..ee3bc593 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -497,6 +497,11 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_ - `summarization` - Context summarization (enabled, trigger conditions, keep policy) - `subagents.enabled` - Master switch for subagent delegation - `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens) +- `auth_login` - Custom login modes layered on the base auth (`deerflow/config/login_config.py`): + - `username_login.{require_password, password}` - Password gate for `POST /api/v1/auth/login/username`. When `require_password=true`, the (otherwise passwordless) username login additionally requires `?password=`; empty `password` falls back to `123ewq`. Gate only — it does not change which account logs in. + - `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with `Authorization: ` and the supplied token as the `access_token` cookie, reads `data.username` from a `{"state":"200",...}` body, then signs the matching local user in (auto-registering if absent). `token_info_url` is HTTPS — `verify_ssl: false` skips cert verification for self-signed/internal UAT hosts, or set `ca_cert_path` to a PEM bundle (takes precedence, keeps verification on). See [docs/AUTH_LOGIN.md](docs/AUTH_LOGIN.md) for the full request/response contract. + +Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`. **`extensions_config.json`**: - `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description) diff --git a/offline-backend-20260512/backend/app/gateway/auth_middleware.py b/offline-backend-20260512/backend/app/gateway/auth_middleware.py index 6cd01c49..e507e557 100644 --- a/offline-backend-20260512/backend/app/gateway/auth_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/auth_middleware.py @@ -41,6 +41,7 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset( { "/api/v1/auth/login/local", "/api/v1/auth/login/username", + "/api/v1/auth/login/token", "/api/v1/auth/register", "/api/v1/auth/logout", "/api/v1/auth/setup-status", diff --git a/offline-backend-20260512/backend/app/gateway/routers/auth.py b/offline-backend-20260512/backend/app/gateway/routers/auth.py index 9ca07663..e126be2a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/auth.py +++ b/offline-backend-20260512/backend/app/gateway/routers/auth.py @@ -6,10 +6,12 @@ import time from ipaddress import ip_address, ip_network from typing import Literal -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, EmailStr, Field, field_validator +from deerflow.config import get_app_config from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.skills.base import SkillStore @@ -174,12 +176,12 @@ def _normalize_username_local_part(username: str) -> str | None: """ raw = username.strip() if not raw: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username cannot be empty") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不能为空") if "@" in raw: return None normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-") if not normalized: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username is invalid") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不合法") return normalized @@ -200,6 +202,118 @@ def _username_to_email(username: str) -> str: return f"{local}@{domain}" +async def _get_or_create_user_by_username(username: str): + """Resolve a username to a local user, auto-registering when absent. + + Shared by ``/login/username`` and ``/login/token`` — both turn an + externally-supplied username into a local account and sign it in. Returns + ``(user, created)`` where ``created`` is True only when this call inserted + the row. Mirrors the legacy domain-agnostic fallback and the canonical + ``admin`` promotion so both entry points behave identically. + """ + provider = get_local_provider() + email = _username_to_email(username) + local_part = _normalize_username_local_part(username) + + created = False + user = await provider.get_user_by_email(email) + # Domain-agnostic fallback: if no row exists at the canonical + # ``@``, look for an existing user with + # the same local part under any domain. This is the safety net that keeps + # usernames globally unique when the default email domain changes (e.g. + # ``local.deerflow`` → ``cm.com``) — without it, switching the default + # would let the same username log into two different accounts. + if user is None and local_part is not None: + legacy = await provider.find_user_by_email_local_part(local_part) + if legacy is not None: + user = legacy + logger.info("Matched username %r to legacy account %s", username, legacy.email) + if user is None: + try: + role = "admin" if username == "admin" else "user" + user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False) + created = True + logger.info("Auto-registered passwordless user: %s", email) + except ValueError: + # Concurrent first login may create the same user in parallel. + user = await provider.get_user_by_email(email) + if user is None and local_part is not None: + user = await provider.find_user_by_email_local_part(local_part) + if user is None: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="创建或加载用户失败") + elif username == "admin" and user.system_role != "admin": + # Historical accounts created before the username-based role assignment + # may still have system_role="user" — promote the canonical admin user + # so /login/admin behaves consistently. + user.system_role = "admin" + user = await provider.update_user(user) + logger.info("Promoted historical 'admin' account to system_role=admin: %s", email) + + return user, created + + +async def _fetch_username_from_token(token: str) -> str: + """Resolve an upstream access token to a username via the configured endpoint. + + Calls ``auth_login.token_login.token_info_url`` exactly like the documented + curl: a POST carrying ``Authorization: `` plus the + token as the ``access_token`` cookie. Expects ``{"state": "200", "data": + {"username": ...}}`` and returns ``data.username``. Any upstream failure, + non-200 ``state``, or missing username raises HTTP 401. + """ + config = get_app_config().auth_login.token_login + if not config.token_info_url: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="token 登录未配置(token_info_url 为空)") + + # TLS verification for the (HTTPS) upstream. A custom CA bundle wins; else + # the boolean toggle. UAT/internal hosts often need verify_ssl=false because + # they serve self-signed or private-CA certs — without this the call dies + # with an SSLError and every token login silently 401s. + verify: bool | str = config.ca_cert_path if config.ca_cert_path else config.verify_ssl + + try: + async with httpx.AsyncClient(timeout=config.timeout_seconds, verify=verify) as client: + upstream = await client.post( + config.token_info_url, + headers={ + "Authorization": config.service_authorization, + "Content-Type": "application/x-www-form-urlencoded", + }, + cookies={"access_token": token}, + ) + except httpx.HTTPError as exc: + # Surface the concrete failure (SSL handshake, DNS, timeout, ...) in logs + # so HTTPS/cert misconfiguration is diagnosable; client still sees 401. + logger.warning( + "Token login: upstream call to %s failed (verify=%r): %s: %s", + config.token_info_url, + verify, + type(exc).__name__, + exc, + ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 校验失败").model_dump()) + + if upstream.status_code != 200: + logger.warning("Token login: upstream returned HTTP %s", upstream.status_code) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 无效").model_dump()) + + try: + payload = upstream.json() + except ValueError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 接口返回格式异常").model_dump()) + + # Upstream signals success with the string state "200" (see API docs). + if str(payload.get("state")) != "200": + logger.warning("Token login: upstream state=%r msg=%r", payload.get("state"), payload.get("msg")) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 无效或已过期").model_dump()) + + username = (payload.get("data") or {}).get("username") + if not username or not str(username).strip(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="未能从 token 解析出用户名").model_dump()) + + return str(username).strip() + + def _set_session_cookie(response: Response, token: str, request: Request) -> None: """Set the access_token HttpOnly cookie on the response.""" config = get_auth_config() @@ -301,7 +415,7 @@ def _check_rate_limit(ip: str) -> None: if time.time() < lock_until: raise HTTPException( status_code=429, - detail="Too many login attempts. Try again later.", + detail="登录尝试次数过多,请稍后再试", ) del _login_attempts[ip] @@ -357,7 +471,7 @@ async def login_local( _record_login_failure(client_ip) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Incorrect email or password").model_dump(), + detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="邮箱或密码错误").model_dump(), ) _record_login_success(client_ip) @@ -371,53 +485,39 @@ async def login_local( @router.post("/login/username", response_model=UsernameLoginResponse) -async def login_by_username(request: Request, response: Response, body: UsernameLoginRequest): +async def login_by_username( + request: Request, + response: Response, + body: UsernameLoginRequest, + password: str | None = Query(default=None, description="Shared gate secret; required only when auth_login.username_login.require_password is on."), +): """Passwordless username login with auto-registration. Rules: - If mapped account exists, sign in directly (no password required). - If account does not exist, create a regular user and sign in. + Password gate: when ``auth_login.username_login.require_password`` is + enabled, callers must additionally pass ``?password=`` matching the + configured shared secret (default ``123ewq`` when unset). The gate is a + single shared password — it does not change which account is logged in. + Returns the JWT access token in the response body and also sets it as a session cookie for browser callers. """ - provider = get_local_provider() - email = _username_to_email(body.username) - local_part = _normalize_username_local_part(body.username) + username_login_config = get_app_config().auth_login.username_login + if username_login_config.require_password: + client_ip = _get_client_ip(request) + _check_rate_limit(client_ip) + if password != username_login_config.effective_password: + _record_login_failure(client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="口令错误").model_dump(), + ) + _record_login_success(client_ip) - created = False - user = await provider.get_user_by_email(email) - # Domain-agnostic fallback: if no row exists at the canonical - # ``@``, look for an existing user with - # the same local part under any domain. This is the safety net that keeps - # usernames globally unique when the default email domain changes (e.g. - # ``local.deerflow`` → ``cm.com``) — without it, switching the default - # would let the same username log into two different accounts. - if user is None and local_part is not None: - legacy = await provider.find_user_by_email_local_part(local_part) - if legacy is not None: - user = legacy - logger.info("Matched username %r to legacy account %s", body.username, legacy.email) - if user is None: - try: - role = "admin" if body.username == "admin" else "user" - user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False) - created = True - logger.info("Auto-registered passwordless user: %s", email) - except ValueError: - # Concurrent first login may create the same user in parallel. - user = await provider.get_user_by_email(email) - if user is None and local_part is not None: - user = await provider.find_user_by_email_local_part(local_part) - if user is None: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create or load user") - elif body.username == "admin" and user.system_role != "admin": - # Historical accounts created before the username-based role assignment - # may still have system_role="user" — promote the canonical admin user - # so /login/admin behaves consistently. - user.system_role = "admin" - user = await provider.update_user(user) - logger.info("Promoted historical 'admin' account to system_role=admin: %s", email) + user, created = await _get_or_create_user_by_username(body.username) token = create_access_token(str(user.id), token_version=user.token_version) _set_session_cookie(response, token, request) @@ -434,6 +534,56 @@ async def login_by_username(request: Request, response: Response, body: Username ) +@router.post("/login/token", response_model=UsernameLoginResponse) +async def login_by_token( + request: Request, + response: Response, + token: str = Query(..., min_length=1, description="Upstream access token to exchange for a username."), +): + """Token-exchange login. + + Verifies an upstream access token by calling the configured + ``token_info_url`` (see ``auth_login.token_login``), reads the resolved + username, then signs the matching local account in — auto-registering it + when the username is not yet in the database. Gated by + ``auth_login.token_login.enabled``. + + Returns the JWT access token in the response body and also sets it as a + session cookie for browser callers. + """ + config = get_app_config().auth_login.token_login + if not config.enabled: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="token 登录未开启") + + client_ip = _get_client_ip(request) + _check_rate_limit(client_ip) + + try: + username = await _fetch_username_from_token(token) + except HTTPException as exc: + if exc.status_code == status.HTTP_401_UNAUTHORIZED: + _record_login_failure(client_ip) + raise + + _record_login_success(client_ip) + + user, created = await _get_or_create_user_by_username(username) + + access_token = create_access_token(str(user.id), token_version=user.token_version) + _set_session_cookie(response, access_token, request) + + return UsernameLoginResponse( + access_token=access_token, + token_type="bearer", + expires_in=get_auth_config().token_expiry_days * 24 * 3600, + user_id=str(user.id), + email=user.email, + system_role=user.system_role, + needs_setup=user.needs_setup, + created=created, + ) + + @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def register(request: Request, response: Response, body: RegisterRequest): """Register a new user account (always 'user' role). diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 587e91fb..dc4d3021 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -187,6 +187,26 @@ skills: agents_api: enabled: true +# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。 +auth_login: + # 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令, + # 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。 + username_login: + require_password: false + password: "" + # token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url + # (带 Authorization 头 + access_token cookie)换出用户名,再登录;用户名不在 + # 库里时自动注册。 + token_login: + enabled: false + token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo" + service_authorization: "Bearer admin" + timeout_seconds: 10 + # 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致 + # 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。 + verify_ssl: false + ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验 + database: backend: sqlite sqlite_dir: .deer-flow/data diff --git a/offline-backend-20260512/backend/docs/AUTH_LOGIN.md b/offline-backend-20260512/backend/docs/AUTH_LOGIN.md new file mode 100644 index 00000000..d0e74dc5 --- /dev/null +++ b/offline-backend-20260512/backend/docs/AUTH_LOGIN.md @@ -0,0 +1,142 @@ +# 登录鉴权扩展接口文档(auth_login) + +在原有登录之上新增两种自定义登录模式,均由 `config.yaml` 的 `auth_login` 段控制。 + +## 配置 + +```yaml +auth_login: + # 功能一:用户名直登口令门 + username_login: + require_password: false # true 时 /login/username 必须带 ?password= + password: "" # 共享口令;留空默认 123ewq + # 功能二:token 换登录 + token_login: + enabled: false # 总开关 + token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo" + service_authorization: "Bearer admin" # 调上游接口的鉴权头 + timeout_seconds: 10 + verify_ssl: false # 上游 HTTPS 证书校验;UAT/内网自签证书需置 false,生产置 true + ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并强制校验 +``` + +> **HTTPS 注意**:`token_info_url` 是 HTTPS。若上游用自签名 / 私有 CA 证书(UAT、内网常见),`verify_ssl: true` 时 httpx 的 TLS 握手会失败,导致 token 登录**恒定 401**。此时应: +> - 临时/内网:`verify_ssl: false`(跳过证书校验); +> - 正规做法:把私有 CA 证书导出为 PEM,配 `ca_cert_path: /path/to/ca.pem`(优先于 `verify_ssl`,仍做校验); +> - 生产对外域名(有效证书):保持 `verify_ssl: true`。 +> +> 握手失败时后端日志会打印具体异常类型(如 `SSLError`)与当前 `verify` 取值,便于排查。 + +配置模型:`packages/harness/deerflow/config/login_config.py`。修改 `config.yaml` 后无需重启(`get_app_config()` 按 mtime 自动重载)。 + +--- + +## 功能一:用户名直登 + 口令门 + +在原 `POST /api/v1/auth/login/username` 上叠加一个共享口令校验。`require_password=false`(默认)时行为完全不变(免密直登 + 自动注册)。 + +### 请求 + +``` +POST /api/v1/auth/login/username[?password=<共享口令>] +Content-Type: application/json + +{ "username": "alice" } +``` + +- `?password=`:仅当 `require_password=true` 时需要。值需等于 `username_login.password`;该项留空时默认口令为 `123ewq`。 +- 口令门是**单一共享口令**,只决定"放不放行",不影响登录成的是哪个账号。 +- 校验失败按 IP 计入登录限速(5 次/5 分钟锁定),与本地密码登录共用同一限速器。 + +### 响应 + +成功 `200`: + +```json +{ + "access_token": "", + "token_type": "bearer", + "expires_in": 604800, + "user_id": "", + "email": "alice@cm.com", + "system_role": "user", + "needs_setup": false, + "created": false +} +``` + +同时种下 `access_token` HttpOnly Cookie。 + +失败: + +| 状态码 | 场景 | +|--------|------| +| `401` | `require_password=true` 但口令缺失或不匹配 | +| `429` | 触发登录限速 | + +### 前端入口 + +访问 `/login/<用户名>?password=<口令>`。[LoginPage.tsx](../../../frontend-web/src/pages/LoginPage.tsx) 从 `?password=` 读取并透传给 [`loginByUsername`](../../../frontend-web/src/core/auth/api.ts)。 + +--- + +## 功能二:token 换登录 + +前端用 URL 把上游 token 传进来,后端调上游接口换出用户名再登录。 + +### 请求 + +``` +POST /api/v1/auth/login/token?token=<上游access_token> +``` + +后端据此向 `token_info_url` 发起(与下方 curl 对齐): + +```bash +curl 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo' \ + -X POST \ + -H 'authorization: Bearer admin' \ + -H 'content-type: application/x-www-form-urlencoded' \ + -H 'cookie: access_token=<上游access_token>' +``` + +上游返回示例: + +```json +{ "state": "200", "msg": "操作成功!", "data": { "username": "b1admin", "...": "..." } } +``` + +后端取 `data.username` → 经 `_username_to_email` 映射 → 库内查找(含跨域名 local-part 兜底)→ **查不到则自动注册** → 发本系统 JWT 并种 Cookie。 + +### 响应 + +成功 `200`:结构同功能一的 `UsernameLoginResponse`(首次自动注册时 `created=true`)。 + +失败: + +| 状态码 | 场景 | +|--------|------| +| `403` | `token_login.enabled=false` | +| `401` | 上游调用失败 / HTTP 非 200 / `state != "200"` / 未取到 username | +| `429` | 触发登录限速 | +| `500` | `token_info_url` 未配置 | + +### 前端入口 + +访问 `/login?authToken=<上游token>`(用 `authToken` 而非 `token`,以避开既有的 `tools_token` 参数 `?token=`)。LoginPage 命中 `authToken` 时走 [`loginByToken`](../../../frontend-web/src/core/auth/api.ts),不再调用户名直登。 + +--- + +## 安全说明 + +- 两个端点都在 `auth_middleware._PUBLIC_EXACT_PATHS` 白名单内(免会话鉴权,自身完成鉴权)。 +- 口令门是**最低限度**的共享密钥防护,适合内网/受控环境;非每用户独立密码。 +- token 换登录信任上游 `getTokenInfo` 的鉴权结论,务必保证 `service_authorization` 与 `token_info_url` 配置正确且走内网/HTTPS。 + +## 相关代码 + +- 后端端点 / 助手:`app/gateway/routers/auth.py`(`login_by_username`、`login_by_token`、`_get_or_create_user_by_username`、`_fetch_username_from_token`) +- 白名单:`app/gateway/auth_middleware.py` +- 配置模型:`packages/harness/deerflow/config/login_config.py`(挂载于 `AppConfig.auth_login`) +- 前端:`frontend-web/src/core/auth/api.ts`、`frontend-web/src/pages/LoginPage.tsx` +- 测试:`tests/test_login_config.py`、`tests/test_auth_login_modes.py` diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py index 240b9601..b47f5301 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py @@ -15,6 +15,7 @@ from deerflow.config.database_config import DatabaseConfig from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.gateway_config import GatewayConfig from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict +from deerflow.config.login_config import AuthLoginConfig from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict from deerflow.config.model_config import ModelConfig from deerflow.config.run_events_config import RunEventsConfig @@ -106,6 +107,7 @@ class AppConfig(BaseModel): token_usage: TokenUsageConfig = Field(default_factory=TokenUsageConfig, description="Token usage tracking configuration") models: list[ModelConfig] = Field(default_factory=list, description="Available models") vision: VisionConfig = Field(default_factory=VisionConfig, description="Image recognition (识图) configuration") + auth_login: AuthLoginConfig = Field(default_factory=AuthLoginConfig, description="Custom login modes (username password gate, token exchange)") sandbox: SandboxConfig = Field(description="Sandbox configuration") tools: list[ToolConfig] = Field(default_factory=list, description="Available tools") tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/login_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/login_config.py new file mode 100644 index 00000000..ad6366b9 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/login_config.py @@ -0,0 +1,80 @@ +from pydantic import BaseModel, Field + +# Fallback shared password for the username-login gate. Used when +# ``UsernameLoginConfig.password`` is left empty but ``require_password`` is on. +DEFAULT_USERNAME_LOGIN_PASSWORD = "123ewq" + + +class UsernameLoginConfig(BaseModel): + """Gate for the passwordless ``POST /api/v1/auth/login/username`` flow. + + By default this flow logs a user in (auto-registering if needed) from a + username alone — convenient, but it lets anyone sign in as any username. + Turning ``require_password`` on forces callers to additionally pass a + shared secret as ``?password=...``; mismatches are rejected with 401. + """ + + require_password: bool = Field( + default=False, + description="When true, login/username requires a matching ?password= query value.", + ) + password: str = Field( + default="", + description=f"Shared secret for the username-login gate. Empty falls back to '{DEFAULT_USERNAME_LOGIN_PASSWORD}'.", + ) + + @property + def effective_password(self) -> str: + """The password actually checked at login time (config value or default).""" + return self.password or DEFAULT_USERNAME_LOGIN_PASSWORD + + +class TokenLoginConfig(BaseModel): + """Token-exchange login. + + The frontend passes an upstream access token via the URL. The gateway calls + ``token_info_url`` with ``Authorization: `` and the + token as the ``access_token`` cookie, reads ``data.username`` from the + response, then signs the matching local user in (auto-registering if the + username is not yet in the database). + """ + + enabled: bool = Field( + default=False, + description="Master switch for the POST /api/v1/auth/login/token endpoint.", + ) + token_info_url: str = Field( + default="", + description="Upstream endpoint that resolves an access token to a username.", + ) + service_authorization: str = Field( + default="Bearer admin", + description="Authorization header value used when calling token_info_url.", + ) + timeout_seconds: float = Field( + default=10.0, + gt=0, + description="HTTP timeout (seconds) for the token_info_url call.", + ) + verify_ssl: bool = Field( + default=True, + description=( + "Whether to verify the upstream TLS certificate. Set false for " + "internal/UAT hosts with self-signed or private-CA certs. Leave " + "true in production." + ), + ) + ca_cert_path: str = Field( + default="", + description=( + "Optional path to a CA bundle (PEM) used to verify the upstream " + "certificate. Takes precedence over verify_ssl when set." + ), + ) + + +class AuthLoginConfig(BaseModel): + """Container for the custom login modes layered on top of the base auth.""" + + username_login: UsernameLoginConfig = Field(default_factory=UsernameLoginConfig) + token_login: TokenLoginConfig = Field(default_factory=TokenLoginConfig) diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index bfe303a9..fd737e7e 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -143,6 +143,25 @@ skills: enabled: false agents_api: enabled: true +# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。 +auth_login: + # 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令, + # 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。 + username_login: + require_password: false + password: "" + # token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url + # (带 Authorization 头 + access_token cookie)换出用户名,再登录;用户名不在 + # 库里时自动注册。 + token_login: + enabled: true + token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo" + service_authorization: "Bearer admin" + timeout_seconds: 10 + # 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致 + # 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。 + verify_ssl: false + ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验 database: backend: mysql mysql_url: $MYSQL_DATABASE_URL