This commit is contained in:
董宏凯 committed 2026-06-27 16:00:47 +08:00
commit 44d7304f8e
14 files changed
+436 -49

No files matched your search

+1 -1
View File
@@ -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,
)
@@ -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;