登录鉴权:新增用户名口令门 + 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[]>;
|
||||
}
|
||||
|
||||
// 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>;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user