登录鉴权:新增用户名口令门 + token 换登录两种模式

- config 新增 auth_login 段(login_config.py):
  - username_login.require_password 开启后,用户名直登必须带 ?password=,口令留空默认 123ewq
  - token_login 用上游 token 调 getTokenInfo 换用户名再登录,用户名不在库自动注册;含 verify_ssl/ca_cert_path 处理 HTTPS 自签证书
- auth.py:口令门、/login/token 端点、共享 _get_or_create_user_by_username 与 _fetch_username_from_token;登录类报错文案改中文
- auth_middleware:/login/token 加入免鉴权白名单
- 前端 api.ts/LoginPage:透传 ?password=、?authToken=;修复错误体为对象时显示 [object Object] 的问题
- 文档:backend/CLAUDE.md + docs/AUTH_LOGIN.md 接口说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-02 17:39:56 +08:00
1 parent 5aa1fdca04
commit c484481964
10 files changed
+531 -56

No files matched your search

+52 -6
View File
@@ -17,24 +17,70 @@ export async function listAllUsers(): Promise<UserListItem[]> {
return res.json() as Promise<UserListItem[]>;
}
// 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<LoginByUsernameResponse> {
// 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<LoginByUsernameResponse>;
}
export async function loginByToken(
token: string,
): Promise<LoginByUsernameResponse> {
// 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<LoginByUsernameResponse>;
}
+18 -8
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
@@ -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=<shared-secret>`; 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: <service_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)
@@ -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",
@@ -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
# ``<username>@<current-default-domain>``, 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: <service_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
# ``<username>@<current-default-domain>``, 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).
@@ -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
@@ -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": "<JWT>",
"token_type": "bearer",
"expires_in": 604800,
"user_id": "<uuid>",
"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`
@@ -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")
@@ -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: <service_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)
+19
View File
@@ -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