登录鉴权:新增用户名口令门 + 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:
1 parent
5aa1fdca04
commit
c484481964
10 files changed
+531
-56
No files matched your search
@@ -17,24 +17,70 @@ export async function listAllUsers(): Promise<UserListItem[]> {
|
|||||||
return res.json() as 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(
|
export async function loginByUsername(
|
||||||
username: string,
|
username: string,
|
||||||
|
password?: string | null,
|
||||||
): Promise<LoginByUsernameResponse> {
|
): 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(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/v1/auth/login/username`,
|
`${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username }),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response
|
const error = await response.json().catch(() => null);
|
||||||
.json()
|
throw new Error(extractErrorMessage(error));
|
||||||
.catch(() => ({ detail: "Login failed." }));
|
|
||||||
throw new Error(error.detail ?? "Login failed.");
|
|
||||||
}
|
}
|
||||||
return response.json() as Promise<LoginByUsernameResponse>;
|
return response.json() as Promise<LoginByUsernameResponse>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { loginByUsername } from "@/core/auth/api";
|
import { loginByToken, loginByUsername } from "@/core/auth/api";
|
||||||
import { setStoredAuth } from "@/core/auth";
|
import { setStoredAuth } from "@/core/auth";
|
||||||
import {
|
import {
|
||||||
persistExternalLoginParams,
|
persistExternalLoginParams,
|
||||||
@@ -40,6 +40,10 @@ export default function LoginPage() {
|
|||||||
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
|
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
|
||||||
const yUserId = searchParams.get("yUserId")?.trim() ?? "";
|
const yUserId = searchParams.get("yUserId")?.trim() ?? "";
|
||||||
const toolToken = searchParams.get("token")?.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(() => ({
|
const externalParams = useMemo(() => ({
|
||||||
theme: searchParams.get("theme") ?? "",
|
theme: searchParams.get("theme") ?? "",
|
||||||
ticket: searchParams.get("ticket") ?? "",
|
ticket: searchParams.get("ticket") ?? "",
|
||||||
@@ -71,13 +75,19 @@ export default function LoginPage() {
|
|||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
try {
|
try {
|
||||||
if (yUserId) {
|
let auth;
|
||||||
await loginByYUserId(yUserId, toolToken || null);
|
if (authToken) {
|
||||||
} else if (toolToken) {
|
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
|
||||||
persistToolsToken(toolToken);
|
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) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,7 +110,7 @@ export default function LoginPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken]);
|
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken, authToken, gatePassword]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background px-6">
|
<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)
|
- `summarization` - Context summarization (enabled, trigger conditions, keep policy)
|
||||||
- `subagents.enabled` - Master switch for subagent delegation
|
- `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)
|
- `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`**:
|
**`extensions_config.json`**:
|
||||||
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description)
|
- `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/local",
|
||||||
"/api/v1/auth/login/username",
|
"/api/v1/auth/login/username",
|
||||||
|
"/api/v1/auth/login/token",
|
||||||
"/api/v1/auth/register",
|
"/api/v1/auth/register",
|
||||||
"/api/v1/auth/logout",
|
"/api/v1/auth/logout",
|
||||||
"/api/v1/auth/setup-status",
|
"/api/v1/auth/setup-status",
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import time
|
|||||||
from ipaddress import ip_address, ip_network
|
from ipaddress import ip_address, ip_network
|
||||||
from typing import Literal
|
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 fastapi.security import OAuth2PasswordRequestForm
|
||||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
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.agents.base import AgentStore
|
||||||
from deerflow.persistence.skills.base import SkillStore
|
from deerflow.persistence.skills.base import SkillStore
|
||||||
|
|
||||||
@@ -174,12 +176,12 @@ def _normalize_username_local_part(username: str) -> str | None:
|
|||||||
"""
|
"""
|
||||||
raw = username.strip()
|
raw = username.strip()
|
||||||
if not raw:
|
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:
|
if "@" in raw:
|
||||||
return None
|
return None
|
||||||
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
|
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
|
||||||
if not normalized:
|
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
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
@@ -200,6 +202,118 @@ def _username_to_email(username: str) -> str:
|
|||||||
return f"{local}@{domain}"
|
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:
|
def _set_session_cookie(response: Response, token: str, request: Request) -> None:
|
||||||
"""Set the access_token HttpOnly cookie on the response."""
|
"""Set the access_token HttpOnly cookie on the response."""
|
||||||
config = get_auth_config()
|
config = get_auth_config()
|
||||||
@@ -301,7 +415,7 @@ def _check_rate_limit(ip: str) -> None:
|
|||||||
if time.time() < lock_until:
|
if time.time() < lock_until:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
detail="Too many login attempts. Try again later.",
|
detail="登录尝试次数过多,请稍后再试",
|
||||||
)
|
)
|
||||||
del _login_attempts[ip]
|
del _login_attempts[ip]
|
||||||
|
|
||||||
@@ -357,7 +471,7 @@ async def login_local(
|
|||||||
_record_login_failure(client_ip)
|
_record_login_failure(client_ip)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
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)
|
_record_login_success(client_ip)
|
||||||
@@ -371,53 +485,39 @@ async def login_local(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login/username", response_model=UsernameLoginResponse)
|
@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.
|
"""Passwordless username login with auto-registration.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- If mapped account exists, sign in directly (no password required).
|
- If mapped account exists, sign in directly (no password required).
|
||||||
- If account does not exist, create a regular user and sign in.
|
- 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
|
Returns the JWT access token in the response body and also sets it as
|
||||||
a session cookie for browser callers.
|
a session cookie for browser callers.
|
||||||
"""
|
"""
|
||||||
provider = get_local_provider()
|
username_login_config = get_app_config().auth_login.username_login
|
||||||
email = _username_to_email(body.username)
|
if username_login_config.require_password:
|
||||||
local_part = _normalize_username_local_part(body.username)
|
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, created = await _get_or_create_user_by_username(body.username)
|
||||||
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)
|
|
||||||
|
|
||||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||||
_set_session_cookie(response, token, request)
|
_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)
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def register(request: Request, response: Response, body: RegisterRequest):
|
async def register(request: Request, response: Response, body: RegisterRequest):
|
||||||
"""Register a new user account (always 'user' role).
|
"""Register a new user account (always 'user' role).
|
||||||
|
|||||||
@@ -187,6 +187,26 @@ skills:
|
|||||||
agents_api:
|
agents_api:
|
||||||
enabled: true
|
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:
|
database:
|
||||||
backend: sqlite
|
backend: sqlite
|
||||||
sqlite_dir: .deer-flow/data
|
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.extensions_config import ExtensionsConfig
|
||||||
from deerflow.config.gateway_config import GatewayConfig
|
from deerflow.config.gateway_config import GatewayConfig
|
||||||
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
|
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.memory_config import MemoryConfig, load_memory_config_from_dict
|
||||||
from deerflow.config.model_config import ModelConfig
|
from deerflow.config.model_config import ModelConfig
|
||||||
from deerflow.config.run_events_config import RunEventsConfig
|
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")
|
token_usage: TokenUsageConfig = Field(default_factory=TokenUsageConfig, description="Token usage tracking configuration")
|
||||||
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
|
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
|
||||||
vision: VisionConfig = Field(default_factory=VisionConfig, description="Image recognition (识图) configuration")
|
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")
|
sandbox: SandboxConfig = Field(description="Sandbox configuration")
|
||||||
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
|
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
|
||||||
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
|
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)
|
||||||
@@ -143,6 +143,25 @@ skills:
|
|||||||
enabled: false
|
enabled: false
|
||||||
agents_api:
|
agents_api:
|
||||||
enabled: true
|
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:
|
database:
|
||||||
backend: mysql
|
backend: mysql
|
||||||
mysql_url: $MYSQL_DATABASE_URL
|
mysql_url: $MYSQL_DATABASE_URL
|
||||||
|
|||||||
Reference in new issue
Block a user