Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
44d7304f8e
14 files changed
+436
-49
No files matched your search
@@ -816,7 +816,7 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_
|
||||
- `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.
|
||||
- `username_login.{require_password, password}` - **Shared** 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. **Per-account override (账号级登录口令)**: independent of this shared gate, an admin can assign **one specific account** an individual **plaintext** password via the 白名单管理 page (`users.login_password` column, migration `20260625_04`, nullable). When the target account carries a `login_password`, `/login/username` **requires** `?password=` to match it **exactly** and that personal password **takes precedence over** the shared gate (the shared secret will not unlock that account); accounts with no personal password keep the shared-gate / passwordless behavior. The check happens before auto-register via the lookup-only helper `_lookup_user_by_username` (so a brand-new username is unaffected). Surfaced/managed admin-only: `GET /api/v1/auth/users` returns `UserListItem.login_password` (plaintext — the list is already admin-gated), and `PUT /api/v1/auth/users/{user_id}/login-password` `{password: str|null}` sets (non-empty) / clears (null·empty) it without touching `token_version`. Plaintext storage is intentional (the password travels in the login URL query anyway, so it stays re-copyable). The user logs in via the existing `#/login/<username>?password=xxxx`. Tests: `tests/test_login_personal_password.py`.
|
||||
- `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path, mock_tokens}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with the caller-supplied token placed directly in the `Authorization: Bearer <token>` header (no cookie; the static `service_authorization` field is deprecated/unused, kept only for backward compat), 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). **`mock_tokens` (offline test mapping `{token: username}`)**: when an incoming token matches a key, `_fetch_username_from_token` returns the mapped username and **skips the `token_info_url` call entirely** — for environments where the upstream `getTokenInfo` host is unreachable (e.g. external networks). Empty in prod (no effect); seeded `{"123ewq": "admin"}` in this deployment so `/login/任意?authToken=123ewq` signs in as admin. **`UsernameLoginResponse` now also returns `username`** (the raw resolved login name, pre email-normalization) on both `/login/token` and `/login/username`, so the frontend can append a faithful `?username=` on jump links (按钮管理 追加登录态) instead of the lossy email local-part. 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`.
|
||||
|
||||
@@ -31,6 +31,7 @@ class User(BaseModel):
|
||||
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
|
||||
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
|
||||
approved: bool = Field(default=True, description="登录白名单放行标记(黑名单模式);仅当系统设置 login_whitelist.enabled 打开时拦截被取消放行的用户。新账号默认放行(True),管理员可「取消放行」(False)封禁个别用户。")
|
||||
login_password: str | None = Field(default=None, description="管理员在白名单管理页给该账号设置的「登录口令」(明文,仅 admin 可见)。非空 → 该账号用户名直登必须带匹配的 ?password=;为空 → 走原有共享口令门/免口令逻辑。")
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
|
||||
@@ -47,6 +47,7 @@ class SQLiteUserRepository(UserRepository):
|
||||
needs_setup=row.needs_setup,
|
||||
token_version=row.token_version,
|
||||
approved=row.approved,
|
||||
login_password=row.login_password,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -62,6 +63,7 @@ class SQLiteUserRepository(UserRepository):
|
||||
needs_setup=user.needs_setup,
|
||||
token_version=user.token_version,
|
||||
approved=user.approved,
|
||||
login_password=user.login_password,
|
||||
)
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────
|
||||
@@ -157,6 +159,7 @@ class SQLiteUserRepository(UserRepository):
|
||||
row.needs_setup = user.needs_setup
|
||||
row.token_version = user.token_version
|
||||
row.approved = user.approved
|
||||
row.login_password = user.login_password
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.gateway.auth import (
|
||||
UserResponse,
|
||||
create_access_token,
|
||||
)
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.csrf_middleware import is_secure_request
|
||||
@@ -279,6 +280,25 @@ async def _get_or_create_user_by_username(username: str):
|
||||
return user, created
|
||||
|
||||
|
||||
async def _lookup_user_by_username(username: str) -> User | None:
|
||||
"""Resolve a username to an **existing** local user, without creating one.
|
||||
|
||||
Mirrors the lookup half of ``_get_or_create_user_by_username`` (canonical
|
||||
``<username>@<domain>`` email + legacy local-part fallback) so the
|
||||
per-account 登录口令 gate can read the target account's ``login_password``
|
||||
before deciding whether to admit the login. Returns ``None`` for a username
|
||||
that has never registered (the personal-password gate then simply does not
|
||||
apply, and ``_get_or_create_user_by_username`` later auto-registers it).
|
||||
"""
|
||||
provider = get_local_provider()
|
||||
email = _username_to_email(username)
|
||||
local_part = _normalize_username_local_part(username)
|
||||
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)
|
||||
return user
|
||||
|
||||
|
||||
def enforce_user_approved(user) -> None:
|
||||
"""登录白名单拦截(**黑名单模式**):开关打开且用户被「取消放行」时拒绝登录。
|
||||
|
||||
@@ -566,16 +586,40 @@ async def login_by_username(
|
||||
- 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.
|
||||
Password gates (checked in this order):
|
||||
|
||||
1. **Per-account 登录口令** — if an admin assigned this specific account a
|
||||
personal password (``users.login_password``, set on the 白名单管理 page),
|
||||
the caller must pass ``?password=`` exactly matching it. This takes
|
||||
precedence over the shared gate and is the「临时给某账号开通+设新口令」flow.
|
||||
2. **Shared gate** — otherwise, when
|
||||
``auth_login.username_login.require_password`` is enabled, callers must
|
||||
pass ``?password=`` matching the configured shared secret (default
|
||||
``123ewq`` when unset). A single shared password; 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.
|
||||
"""
|
||||
username_login_config = get_app_config().auth_login.username_login
|
||||
if username_login_config.require_password:
|
||||
|
||||
# Per-account password takes precedence: look the account up (no create) and,
|
||||
# if it carries a personal 登录口令, require an exact match — ignoring the
|
||||
# shared gate entirely for that account.
|
||||
existing = await _lookup_user_by_username(body.username)
|
||||
personal_password = (getattr(existing, "login_password", None) or "").strip() if existing else ""
|
||||
|
||||
if personal_password:
|
||||
client_ip = _get_client_ip(request)
|
||||
_check_rate_limit(client_ip)
|
||||
if (password or "") != personal_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)
|
||||
elif username_login_config.require_password:
|
||||
client_ip = _get_client_ip(request)
|
||||
_check_rate_limit(client_ip)
|
||||
if password != username_login_config.effective_password:
|
||||
@@ -830,6 +874,7 @@ class UserListItem(BaseModel):
|
||||
agent_count: int
|
||||
qa_count: int = 0 # 问答次数: number of runs (one run = one question)
|
||||
approved: bool = True # 登录白名单放行标记(白名单管理页据此放行/取消)
|
||||
login_password: str | None = None # 账号级登录口令(明文,管理员可见/可复制登录链接)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserListItem])
|
||||
@@ -863,6 +908,7 @@ async def list_all_users(
|
||||
agent_count=await agent_store.count_owned(uid),
|
||||
qa_count=qa_counts.get(uid, 0),
|
||||
approved=getattr(u, "approved", True),
|
||||
login_password=getattr(u, "login_password", None),
|
||||
)
|
||||
)
|
||||
return result
|
||||
@@ -897,6 +943,48 @@ async def set_user_approval(user_id: str, body: UserApprovalRequest, request: Re
|
||||
agent_count=0,
|
||||
qa_count=0,
|
||||
approved=saved.approved,
|
||||
login_password=saved.login_password,
|
||||
)
|
||||
|
||||
|
||||
class UserLoginPasswordRequest(BaseModel):
|
||||
password: str | None = Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
description="设为非空 → 该账号用户名直登需带此明文口令;传 null/空字符串 → 清除该口令(回到共享口令门/免口令)。",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/login-password", response_model=UserListItem)
|
||||
async def set_user_login_password(user_id: str, body: UserLoginPasswordRequest, request: Request) -> UserListItem:
|
||||
"""设置 / 清除某个账号的「登录口令」(明文)。Admin only。
|
||||
|
||||
设置后,该账号的用户名直登(``/login/username``)必须带匹配的 ``?password=``;
|
||||
管理员据返回的明文口令拼出登录链接 ``…/login/<用户名>?password=xxxx`` 发给用户。
|
||||
不动 ``token_version``,现有会话不受影响(仅影响下次登录)。
|
||||
"""
|
||||
admin = await get_current_user_from_request(request)
|
||||
if admin.system_role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
provider = get_local_provider()
|
||||
target = await provider.get_user(user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 空字符串 / null / 纯空白 → 清除(None);否则存去掉首尾空白后的明文。
|
||||
pwd = (body.password or "").strip()
|
||||
target.login_password = pwd or None
|
||||
saved = await provider.update_user(target)
|
||||
return UserListItem(
|
||||
id=str(saved.id),
|
||||
email=saved.email,
|
||||
system_role=saved.system_role,
|
||||
skill_count=0,
|
||||
agent_count=0,
|
||||
qa_count=0,
|
||||
approved=saved.approved,
|
||||
login_password=saved.login_password,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
"""Add users.login_password (per-account 登录口令, 明文).
|
||||
|
||||
Backs the 白名单管理「设置登录口令」feature: an admin assigns an individual
|
||||
plaintext password to a specific account, and that account's username login
|
||||
(``POST /api/v1/auth/login/username``) then requires a matching ``?password=``
|
||||
(it takes precedence over the global shared-password gate). The column is
|
||||
nullable — NULL/empty means "no personal password" and the account keeps the
|
||||
existing shared-gate / passwordless behavior.
|
||||
|
||||
Stored in plaintext on purpose: the password rides in the login URL's query
|
||||
string anyway, and keeping it readable lets the admin re-copy the full login
|
||||
link ``…/login/<username>?password=xxxx`` at any time. The ``/users`` list that
|
||||
exposes it is admin-only.
|
||||
|
||||
Revision ID: 20260625_04
|
||||
Revises: 20260625_03
|
||||
Create Date: 2026-06-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260625_04"
|
||||
down_revision = "20260625_03"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# A brand-new DB gets the column from create_all; only patch existing tables.
|
||||
if _has_table("users") and not _has_column("users", "login_password"):
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("login_password", sa.String(length=255), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("users", "login_password"):
|
||||
op.drop_column("users", "login_password")
|
||||
@@ -56,6 +56,13 @@ class UserRow(Base):
|
||||
# 时把已存在行置为已放行。
|
||||
approved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1"))
|
||||
|
||||
# 管理员在「白名单管理」页给个别账号设置的「登录口令」(**明文**)。非空 → 该账号的
|
||||
# 用户名直登(``POST /api/v1/auth/login/username``)必须带 ``?password=`` 且与此值
|
||||
# 完全一致才放行(优先于全局共享口令门);为空/NULL → 走原有逻辑(全局共享口令门
|
||||
# 或免口令)。明文存储是有意为之:口令本就以 URL query 明文传输,存明文才能让管理员
|
||||
# 随时复制完整登录链接 ``…/login/<用户名>?password=xxxx``。仅 admin 可读写。
|
||||
login_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# 岗位 (position) the user is assigned to (1:1). NULL = no explicit
|
||||
# position → the user inherits the ``is_default`` position's grants. Drives
|
||||
# the subscription sync engine (see ``deerflow.services.position_sync``).
|
||||
|
||||
@@ -130,6 +130,13 @@ CALL _add_column_if_missing('task_buttons', 'login_params_json', 'TEXT NULL DEFA
|
||||
-- ORM: UserRow.approved = Boolean, NOT NULL, default True, server_default '1'。
|
||||
CALL _add_column_if_missing('users', 'approved', 'TINYINT(1) NOT NULL DEFAULT 1');
|
||||
|
||||
-- ---- users.login_password: 账号级「登录口令」(明文),对应 migration 20260625_04 ----
|
||||
-- 管理员在白名单管理页给个别账号设置的明文登录口令。非空 → 该账号用户名直登
|
||||
-- (/api/v1/auth/login/username) 必须带匹配的 ?password=(优先于全局共享口令门);
|
||||
-- NULL/空 → 走原有共享口令门/免口令逻辑。明文存储以便随时复制完整登录链接。
|
||||
-- ORM: UserRow.login_password = String(255), nullable。
|
||||
CALL _add_column_if_missing('users', 'login_password', 'VARCHAR(255) NULL DEFAULT NULL');
|
||||
|
||||
-- ---- 清理临时存储过程 --------------------------------------------------------
|
||||
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||
|
||||
|
||||
Reference in new issue
Block a user