diff --git a/backend/alembic/versions/075_local_password_hashes.py b/backend/alembic/versions/075_local_password_hashes.py new file mode 100644 index 000000000..2b4dd4748 --- /dev/null +++ b/backend/alembic/versions/075_local_password_hashes.py @@ -0,0 +1,64 @@ +"""Protect dormant local credentials with salted Argon2id. + +Irreversible data migration: never downgrade credentials back to MD5. Stop old +writers before upgrading. Each CAS update commits independently so interruption +is resumable; historical initialization scripts remain unchanged. +""" + +import sqlalchemy as sa + +from alembic import op +from common.core.passwords import is_legacy_password, wrap_legacy_password + +revision = "d91543ca7b21" +down_revision = "ed42b7f169a3" +branch_labels = None +depends_on = None +BATCH_SIZE = 100 + + +def upgrade(): + # No external services, configuration, CRUD or plaintext passwords are used. + # Release row locks after each update rather than holding them during hashing. + with op.get_context().autocommit_block(): + bind = op.get_bind() + schema = None + if bind.dialect.name == "postgresql": + schema = bind.scalar(sa.text("SELECT current_schema()")) + if not schema or not sa.inspect(bind).has_table("sys_user", schema=schema): + raise RuntimeError( + "Local credentials must exist in the current migration schema" + ) + users = sa.table( + "sys_user", + sa.column("id", sa.BigInteger), + sa.column("password", sa.String(255)), + schema=schema, + ) + last_id = None + while True: + query = ( + sa.select(users.c.id, users.c.password) + .order_by(users.c.id) + .limit(BATCH_SIZE) + ) + if last_id is not None: + query = query.where(users.c.id > last_id) + rows = bind.execute(query).all() + if not rows: + break + for uid, stored in rows: + if is_legacy_password(stored): + replacement = wrap_legacy_password(stored) + bind.execute( + users.update() + .where(users.c.id == uid, users.c.password == stored) + .values(password=replacement) + ) + last_id = rows[-1][0] + + +def downgrade(): + # Schema is unchanged. Keeping strong credentials is intentional; old + # applications are incompatible and require a coordinated restore/reset. + pass diff --git a/backend/apps/mcp/mcp.py b/backend/apps/mcp/mcp.py index c2dbdbc2e..a96859c5a 100644 --- a/backend/apps/mcp/mcp.py +++ b/backend/apps/mcp/mcp.py @@ -5,6 +5,7 @@ from typing import Optional import jwt +from starlette.concurrency import run_in_threadpool from fastapi import HTTPException, status, APIRouter # from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError @@ -16,7 +17,7 @@ ChatFinishStep, McpDs, ChatToken, WsMcp, Chat from apps.datasource.crud.datasource import get_datasource_list from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace -from apps.system.crud.user import authenticate, user_ws_options +from apps.system.crud.user import authenticate, upgrade_password, user_ws_options from apps.system.crud.user import get_db_user from apps.system.models.system_model import UserWsModel from apps.system.models.user import UserModel @@ -40,12 +41,13 @@ @router.post("/access_token", operation_id="access_token") async def access_token(session: SessionDep, chat: ChatToken): - user: BaseUserDTO = authenticate(session=session, account=chat.username, password=chat.password) - if not user: + user: BaseUserDTO = await run_in_threadpool(authenticate, session=session, account=chat.username, password=chat.password) + if not user or user.status != 1 or user.origin not in (None, 0): raise HTTPException(status_code=400, detail="Incorrect account or password") if not user.oid or user.oid == 0: raise HTTPException(status_code=400, detail="No associated workspace, Please contact the administrator") + await run_in_threadpool(upgrade_password, session=session, user=user, password=chat.password) access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) user_dict = user.to_dict() t = Token(access_token=create_access_token( @@ -101,12 +103,13 @@ async def mcp_start(session: SessionDep, trans: Trans, chat: ChatStart): res_token = chat.token user = get_user(session, chat.token) else: - user = authenticate(session=session, account=chat.username, password=chat.password) - if not user: + user = await run_in_threadpool(authenticate, session=session, account=chat.username, password=chat.password) + if not user or user.status != 1 or user.origin not in (None, 0): raise HTTPException(status_code=400, detail="Incorrect account or password") if not user.oid or user.oid == 0: raise HTTPException(status_code=400, detail="No associated workspace, Please contact the administrator") + await run_in_threadpool(upgrade_password, session=session, user=user, password=chat.password) access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) user_dict = user.to_dict() t = Token(access_token=create_access_token( diff --git a/backend/apps/system/api/login.py b/backend/apps/system/api/login.py index 6a979a23c..ae142add6 100644 --- a/backend/apps/system/api/login.py +++ b/backend/apps/system/api/login.py @@ -1,12 +1,13 @@ from typing import Annotated +from starlette.concurrency import run_in_threadpool from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.security import OAuth2PasswordRequestForm from apps.system.schemas.logout_schema import LogoutSchema from apps.system.schemas.system_schema import BaseUserDTO, LoginPwdEditor from common.core.deps import SessionDep, Trans from common.utils.crypto import sqlbot_decrypt -from ..crud.user import authenticate, check_pwd_format, clean_user_cache, get_db_user -from common.core.security import create_access_token, default_pwd, md5pwd, verify_md5pwd +from ..crud.user import authenticate, upgrade_password, check_pwd_format, clean_user_cache, get_db_user +from common.core.security import create_access_token, default_pwd, get_password_hash, verify_password from datetime import timedelta from common.core.config import settings from common.core.schemas import Token @@ -32,7 +33,7 @@ async def local_login( ) -> Token: origin_account = await sqlbot_decrypt(form_data.username) origin_pwd = await sqlbot_decrypt(form_data.password) - user: BaseUserDTO = authenticate(session=session, account=origin_account, password=origin_pwd) + user: BaseUserDTO = await run_in_threadpool(authenticate, session=session, account=origin_account, password=origin_pwd) if not user: raise HTTPException(status_code=400, detail=trans('i18n_login.account_pwd_error')) if not user.oid or user.oid == 0: @@ -44,9 +45,10 @@ async def local_login( if ( user.id != 1 and await initial_pwd_disabled(session) - and verify_md5pwd(default_pwd(), user.password) + and await run_in_threadpool(verify_password, default_pwd(), user.password) ): return Token(access_token='', need_change_pwd=True) + await run_in_threadpool(upgrade_password, session=session, user=user, password=origin_pwd) access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) user_dict = user.to_dict() return Token( @@ -65,7 +67,7 @@ async def login_change_pwd(session: SessionDep, trans: Trans, editor: LoginPwdEd origin_account = await sqlbot_decrypt(editor.account) origin_pwd = await sqlbot_decrypt(editor.pwd) new_pwd = await sqlbot_decrypt(editor.new_pwd) - user: BaseUserDTO = authenticate(session=session, account=origin_account, password=origin_pwd) + user: BaseUserDTO = await run_in_threadpool(authenticate, session=session, account=origin_account, password=origin_pwd) if not user: raise HTTPException(status_code=400, detail=trans('i18n_login.account_pwd_error')) if user.origin is not None and user.origin != 0: @@ -75,10 +77,10 @@ async def login_change_pwd(session: SessionDep, trans: Trans, editor: LoginPwdEd if await initial_pwd_disabled(session) and new_pwd == default_pwd(): raise HTTPException(status_code=400, detail=trans('i18n_login.new_pwd_is_initial')) db_user = get_db_user(session=session, user_id=user.id) - db_user.password = md5pwd(new_pwd) + db_user.password = await run_in_threadpool(get_password_hash, new_pwd) session.add(db_user) await clean_user_cache(user.id) - return db_user + return {"id": db_user.id} @router.post("/logout") async def logout(session: SessionDep, request: Request, dto: LogoutSchema): diff --git a/backend/apps/system/api/user.py b/backend/apps/system/api/user.py index 9fb06fefe..fbf0c642c 100644 --- a/backend/apps/system/api/user.py +++ b/backend/apps/system/api/user.py @@ -1,5 +1,6 @@ from collections import defaultdict from typing import Optional +from starlette.concurrency import run_in_threadpool from fastapi import APIRouter, File, HTTPException, Path, Query, UploadFile from sqlmodel import SQLModel, case, or_, select, delete as sqlmodel_delete from apps.system.crud.user import check_account_exists, check_email_exists, check_email_format, check_pwd_format, get_db_user, single_delete, user_ws_options @@ -15,7 +16,7 @@ from common.core.deps import CurrentUser, SessionDep, Trans from common.core.pagination import Paginator from common.core.schemas import PaginatedResponse, PaginationParams -from common.core.security import default_md5_pwd, md5pwd, verify_md5pwd +from common.core.security import default_password_hash, get_password_hash, verify_password from common.core.sqlbot_cache import clear_cache from common.core.config import settings from apps.swagger.i18n import PLACEHOLDER_PREFIX @@ -208,7 +209,7 @@ async def pager( user_id = item['id'] merged[user_id].append(ws_oid) if user_id not in extra_attrs: - extra_attrs[user_id] = {k: v for k, v in item.items() if k != "ws_oid"} + extra_attrs[user_id] = {k: v for k, v in item.items() if k not in {"ws_oid", "password"}} # 组合结果 result = [ @@ -280,6 +281,7 @@ async def create(session: SessionDep, creator: UserCreator, trans: Trans): raise Exception(trans('i18n_format_invalid', key = f"{trans('i18n_user.email')} [{creator.email}]")) #data = creator.model_dump(exclude_unset=True) data = creator.model_dump() + data["password"] = await run_in_threadpool(default_password_hash) user_model = UserModel.model_validate(data) #user_model.create_time = get_timestamp() user_model.language = "zh-CN" @@ -297,7 +299,7 @@ async def create(session: SessionDep, creator: UserCreator, trans: Trans): session.add_all(db_model_list) user_model.oid = creator.oid_list[0] session.add(user_model) - return user_model + return user_model.model_dump(exclude={"password"}) @router.put("", summary=f"{PLACEHOLDER_PREFIX}user_update_api", description=f"{PLACEHOLDER_PREFIX}user_update_api") @@ -386,7 +388,7 @@ async def pwdReset(session: SessionDep, current_user: CurrentUser, trans: Trans, if not current_user.isAdmin: raise Exception(trans('i18n_permission.no_permission', url = " patch[/user/pwd/id],", msg = trans('i18n_permission.only_admin'))) db_user: UserModel = get_db_user(session=session, user_id=id) - db_user.password = default_md5_pwd() + db_user.password = await run_in_threadpool(default_password_hash) session.add(db_user) @router.put("/pwd", summary=f"{PLACEHOLDER_PREFIX}update_pwd", description=f"{PLACEHOLDER_PREFIX}update_pwd") @@ -397,13 +399,13 @@ async def pwdUpdate(session: SessionDep, current_user: CurrentUser, trans: Trans if not check_pwd_format(new_pwd): raise Exception(trans('i18n_format_invalid', key = trans('i18n_user.password'))) db_user: UserModel = get_db_user(session=session, user_id=current_user.id) - if not verify_md5pwd(editor.pwd, db_user.password): + if not await run_in_threadpool(verify_password, editor.pwd, db_user.password): raise Exception(trans('i18n_error', key = trans('i18n_user.password'))) if await initial_pwd_disabled(session) and new_pwd == settings.DEFAULT_PWD: raise HTTPException(status_code=400, detail=trans('i18n_login.new_pwd_is_initial')) - db_user.password = md5pwd(new_pwd) + db_user.password = await run_in_threadpool(get_password_hash, new_pwd) session.add(db_user) - return db_user + return {"id": db_user.id} @router.patch("/status", summary=f"{PLACEHOLDER_PREFIX}update_status", description=f"{PLACEHOLDER_PREFIX}update_status") diff --git a/backend/apps/system/crud/user.py b/backend/apps/system/crud/user.py index 5f9928b1b..9eb5c9416 100644 --- a/backend/apps/system/crud/user.py +++ b/backend/apps/system/crud/user.py @@ -1,12 +1,14 @@ from typing import Optional +from sqlalchemy import update from sqlmodel import Session, func, select, delete as sqlmodel_delete from apps.system.models.system_model import UserWsModel, WorkspaceModel from apps.system.schemas.auth import CacheName, CacheNamespace from apps.system.schemas.system_schema import EMAIL_REGEX, PWD_REGEX, BaseUserDTO, UserInfoDTO, UserWs +from common.core.passwords import hash_password, needs_password_upgrade from common.core.deps import SessionDep -from common.core.security import verify_md5pwd +from common.core.security import verify_password from common.core.sqlbot_cache import cache, clear_cache from common.utils.locale import I18n, I18nHelper from common.utils.utils import SQLBotLogUtil @@ -45,7 +47,7 @@ def authenticate(*, session: Session, account: str, password: str) -> BaseUserDT db_user = get_user_by_account(session=session, account=account) if not db_user: return None - if not verify_md5pwd(password, db_user.password): + if not verify_password(password, db_user.password): return None return db_user @@ -105,3 +107,19 @@ def check_email_format(email: str) -> bool: def check_pwd_format(pwd: str) -> bool: return bool(PWD_REGEX.fullmatch(pwd)) + + +def upgrade_password(*, session: Session, user: BaseUserDTO, password: str) -> None: + """CAS protects a concurrent reset; the caller owns the transaction. + + Invoke only after all login eligibility checks have passed. + """ + if user.status != 1 or user.origin not in (None, 0) or not user.oid: + return + if not needs_password_upgrade(user.password): + return + replacement = hash_password(password) + session.execute( + update(UserModel).where(UserModel.id == user.id, UserModel.password == user.password) + .values(password=replacement).execution_options(synchronize_session=False) + ) diff --git a/backend/apps/system/crud/user_excel.py b/backend/apps/system/crud/user_excel.py index 80c9e5fbb..5736fd675 100644 --- a/backend/apps/system/crud/user_excel.py +++ b/backend/apps/system/crud/user_excel.py @@ -1,3 +1,4 @@ +from common.core.security import default_password_hash import asyncio @@ -7,6 +8,7 @@ import uuid import atexit import threading +from starlette.concurrency import run_in_threadpool from fastapi import HTTPException from fastapi.responses import StreamingResponse, FileResponse import os @@ -129,6 +131,8 @@ async def batchUpload(session: SessionDep, trans, file) -> UploadResultDTO: error_file_id = generate_error_file(error_list, head_list) result = UploadResultDTO(successCount=len(success_list), errorCount=len(error_list), dataKey=error_file_id) if success_list: + for row in success_list: + row["password"] = await run_in_threadpool(default_password_hash) user_po_list = [UserModel.model_validate(row) for row in success_list] session.add_all(user_po_list) session.commit() diff --git a/backend/apps/system/models/user.py b/backend/apps/system/models/user.py index 0004f057e..6a36e4f31 100644 --- a/backend/apps/system/models/user.py +++ b/backend/apps/system/models/user.py @@ -5,7 +5,7 @@ from sqlmodel import SQLModel, Field from common.core.models import SnowflakeBase -from common.core.security import default_md5_pwd +from common.core.security import default_password_hash from common.utils.time import get_timestamp @@ -13,7 +13,7 @@ class BaseUserPO(SQLModel): account: str = Field(max_length=255, unique=True) oid: int = Field(nullable=False, sa_type=BigInteger(), default=0) name: str = Field(max_length=255, unique=True) - password: str = Field(default_factory=default_md5_pwd, max_length=255) + password: str = Field(default_factory=default_password_hash, max_length=255) email: str = Field(max_length=255) status: int = Field(default=0, nullable=False) origin: int = Field(nullable=False, default=0) diff --git a/backend/common/core/passwords.py b/backend/common/core/passwords.py new file mode 100644 index 000000000..fb66a4ce8 --- /dev/null +++ b/backend/common/core/passwords.py @@ -0,0 +1,74 @@ +"""Versioned local credentials. No configuration or application imports. + +Legacy MD5 is read-only compatibility. New writes always use Argon2id. +""" + +import hashlib +import hmac +import re + +from argon2 import PasswordHasher, Type, extract_parameters +from argon2.exceptions import InvalidHashError, VerificationError + +HASHER = PasswordHasher( + time_cost=2, + memory_cost=19456, + parallelism=1, + salt_len=16, + hash_len=32, + type=Type.ID, +) +WRAPPED_MD5 = "sqlbot-md5-argon2id-v1:" +LEGACY_MD5 = re.compile(r"[0-9a-f]{32}") + + +def is_legacy_password(value: str) -> bool: + return isinstance(value, str) and LEGACY_MD5.fullmatch(value) is not None + + +def legacy_md5(password: str) -> str: + return hashlib.md5(password.encode("utf-8")).hexdigest() + + +def hash_password(password: str) -> str: + return HASHER.hash(password) + + +def wrap_legacy_password(value: str) -> str: + if not is_legacy_password(value): + raise ValueError("Expected a legacy local credential") + return WRAPPED_MD5 + HASHER.hash(value) + + +def verify_password(password: str, stored: str) -> bool: + if not isinstance(password, str) or not isinstance(stored, str): + return False + if is_legacy_password(stored): + return hmac.compare_digest(legacy_md5(password), stored) + wrapped = stored.startswith(WRAPPED_MD5) + encoded = stored[len(WRAPPED_MD5) :] if wrapped else stored + if not encoded.startswith("$argon2id$v=19$"): + return False + try: + params = extract_parameters(encoded) + # Reject corrupt/untrusted encodings before allocating excessive resources. + if not ( + 1 <= params.time_cost <= 10 + and 8 <= params.memory_cost <= 262144 + and 1 <= params.parallelism <= 8 + and 8 <= params.salt_len <= 64 + and 16 <= params.hash_len <= 64 + ): + return False + return HASHER.verify(encoded, legacy_md5(password) if wrapped else password) + except (VerificationError, InvalidHashError, ValueError): + return False + + +def needs_password_upgrade(stored: str) -> bool: + if is_legacy_password(stored) or stored.startswith(WRAPPED_MD5): + return True + try: + return HASHER.check_needs_rehash(stored) + except (InvalidHashError, ValueError): + return False diff --git a/backend/common/core/security.py b/backend/common/core/security.py index 3b8167749..acb177719 100644 --- a/backend/common/core/security.py +++ b/backend/common/core/security.py @@ -1,44 +1,49 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any import jwt -from passlib.context import CryptContext -import hashlib -from common.core.config import settings -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +from common.core.config import settings +# Keep these public names for integrations; credential formats live in one module. +from common.core.passwords import ( + hash_password as get_password_hash, +) +from common.core.passwords import ( + legacy_md5, + verify_password, +) ALGORITHM = "HS256" def create_access_token(data: dict | Any, expires_delta: timedelta) -> str: to_encode = data.copy() - expire = datetime.now(timezone.utc) + expires_delta + expire = datetime.now(UTC) + expires_delta to_encode.update({"exp": expire}) # to_encode = {"exp": expire, "account": str(subject)} encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt -def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_context.verify(plain_password, hashed_password) - - -def get_password_hash(password: str) -> str: - return pwd_context.hash(password) - def md5pwd(password: str) -> str: - m = hashlib.md5() - m.update(password.encode("utf-8")) - return m.hexdigest() + """Legacy fixture/integration helper; never use for new credential writes.""" + return legacy_md5(password) + def verify_md5pwd(plain_password: str, md5_password: str) -> bool: - return md5pwd(plain_password) == md5_password + """Compatibility name; accepts all supported versioned credential formats.""" + return verify_password(plain_password, md5_password) + def default_pwd() -> str: return settings.DEFAULT_PWD + +def default_password_hash() -> str: + return get_password_hash(default_pwd()) + + def default_md5_pwd() -> str: - pwd = default_pwd() - return md5pwd(pwd) \ No newline at end of file + """Compatibility alias: new default credentials must never be bare MD5.""" + return default_password_hash() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 776bdf6ca..23a1c3397 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,6 +6,7 @@ requires-python = "==3.11.*" dependencies = [ "fastapi[standard]<0.116.0,>=0.115.12", "python-multipart<1.0.0,>=0.0.7", + "argon2-cffi>=23.1.0,<26", "passlib[bcrypt]<2.0.0,>=1.7.4", "tenacity<9.0.0,>=8.2.3", "pydantic>2.0", diff --git a/backend/tests/test_initial_password_policy.py b/backend/tests/test_initial_password_policy.py index 8f4fad7fe..7ec77f204 100644 --- a/backend/tests/test_initial_password_policy.py +++ b/backend/tests/test_initial_password_policy.py @@ -14,6 +14,9 @@ import pytest from fastapi import HTTPException +from starlette.concurrency import run_in_threadpool + +from common.core.passwords import hash_password, verify_password ROOT = Path(__file__).resolve().parents[2] @@ -55,8 +58,9 @@ def context(): "get_group_args": args, "HTTPException": HTTPException, "get_db_user": Mock(return_value=user), - "md5pwd": md5, - "verify_md5pwd": lambda value, hashed: md5(value) == hashed, + "get_password_hash": hash_password, + "verify_password": verify_password, + "run_in_threadpool": run_in_threadpool, "default_pwd": lambda: "InitialPassword42!", "settings": SimpleNamespace(DEFAULT_PWD="InitialPassword42!"), } @@ -115,14 +119,16 @@ def test_disabled_off_allows_initial_password(context, value): user, session, args, _, _ = context args.return_value = [SimpleNamespace(pkey="login.initial_pwd_disabled", pval=value)] update(context) - assert user.password == md5("InitialPassword42!") + assert user.password.startswith("$argon2id$") + assert verify_password("InitialPassword42!", user.password) session.add.assert_called_once_with(user) def test_enabled_allows_other_password(context): user, session, _, _, _ = context update(context, new="DifferentPassword42!") - assert user.password == md5("DifferentPassword42!") + assert user.password.startswith("$argon2id$") + assert verify_password("DifferentPassword42!", user.password) session.add.assert_called_once_with(user) diff --git a/backend/tests/test_local_password_hashes.py b/backend/tests/test_local_password_hashes.py new file mode 100644 index 000000000..9b96d4b11 --- /dev/null +++ b/backend/tests/test_local_password_hashes.py @@ -0,0 +1,794 @@ +"""Real hashing/SQLModel/routes/middleware on synthetic isolated data. + +Default: SQLite, no network. Explicit PostgreSQL: SQLBOT_PASSWORD_TEST_PG=1 +and SQLBOT_TEST_DATABASE_URL; a unique schema is created and removed. External +crypto and audit sinks are boundaries; production encryption is checked separately. +""" + +# isort: skip_file +# Import system routes before MCP: the released xpack imports these routes. +import asyncio +import importlib.util +import io +import os +import uuid +from datetime import timedelta +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import jwt +import pandas as pd +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import BigInteger, text, update +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.compiler import compiles +from sqlmodel import Session, create_engine, select + +from apps.system.api import login, user +from apps.mcp import mcp +from apps.system.crud import user as user_crud, user_excel +from apps.system.middleware import auth +from apps.system.models.system_model import AssistantModel, UserWsModel, WorkspaceModel +from apps.system.models.user import UserModel +from common.audit.schemas import logger_decorator +from common.audit.schemas.request_context import RequestContextMiddlewareCommon +from common.core.config import settings +from common.core.db import get_session +from apps.system.schemas.permission import RequestContextMiddleware +from common.core.response_middleware import exception_handler +from common.core.deps import get_i18n +from common.core.passwords import ( + WRAPPED_MD5, + hash_password, + legacy_md5, + needs_password_upgrade, + verify_password, + wrap_legacy_password, +) +from common.core.security import create_access_token +from common.utils.crypto import ( + sqlbot_decrypt as real_decrypt, + sqlbot_encrypt as real_encrypt, +) +from apps.chat.models.chat_model import Chat +from sqlbot_xpack.config.model import SysArgModel +from sqlbot_xpack.license.model import LicenseModel + +PWD = "SyntheticOnly42!" +NEW = "ReplacementOnly42!" +ROOT = Path(__file__).resolve().parents[2] + + +@compiles(JSONB, "sqlite") +def sqlite_jsonb(_type, _compiler, **_kw): + return "JSON" + + +@compiles(BigInteger, "sqlite") +def sqlite_bigint(_type, _compiler, **_kw): + return "INTEGER" + + +def trans(key, **_kwargs): + return key + + +@pytest.fixture +def environment(tmp_path, monkeypatch): + pg = os.environ.get("SQLBOT_PASSWORD_TEST_PG") == "1" + admin = None + schema = "password_" + uuid.uuid4().hex + if pg: + admin = create_engine(os.environ["SQLBOT_TEST_DATABASE_URL"]) + with admin.begin() as c: + c.execute(text(f'CREATE SCHEMA "{schema}"')) + db = create_engine( + os.environ["SQLBOT_TEST_DATABASE_URL"], + connect_args={"options": f"-csearch_path={schema}"}, + ) + else: + db = create_engine( + f"sqlite:///{tmp_path / 'synthetic.db'}", + connect_args={"check_same_thread": False}, + ) + try: + for model in ( + UserModel, + UserWsModel, + WorkspaceModel, + AssistantModel, + Chat, + SysArgModel, + LicenseModel, + ): + model.__table__.create(db) + monkeypatch.setattr(settings, "CACHE_TYPE", "None") + monkeypatch.setattr(auth, "engine", db) + monkeypatch.setattr(logger_decorator, "engine", db) + monkeypatch.setattr( + logger_decorator.SystemLogger, "create_log_record", AsyncMock() + ) + # Keep route validation/transactions/auth real; replace only transport crypto. + monkeypatch.setattr(login, "sqlbot_decrypt", AsyncMock(side_effect=lambda x: x)) + monkeypatch.setattr( + "common.utils.crypto.sqlbot_decrypt", AsyncMock(side_effect=lambda x: x) + ) + + class Policy: + @property + def return_value(self): + raise AttributeError("write-only test configuration") + + @return_value.setter + def return_value(self, value): + with Session(db) as s: + row = s.exec( + select(SysArgModel).where( + SysArgModel.pkey == "login.initial_pwd_disabled" + ) + ).first() + if row is None: + row = SysArgModel( + pkey="login.initial_pwd_disabled", pval="false" + ) + row.pval = str(value).lower() + s.add(row) + s.commit() + + policy = Policy() + app = FastAPI() + app.include_router(login.router, prefix="/api/v1") + app.include_router(user.router, prefix="/api/v1") + app.include_router(mcp.router, prefix="/api/v1") + from fastapi import Request + + @app.get("/api/v1/password-test/identity") + def identity(request: Request): + assistant = getattr(request.state, "assistant", None) + return { + "uid": request.state.current_user.id, + "assistant_type": assistant.type if assistant else None, + } + + app.add_middleware(RequestContextMiddleware) + app.add_middleware(RequestContextMiddlewareCommon) + app.add_exception_handler(Exception, exception_handler.global_exception_handler) + app.add_middleware(auth.TokenMiddleware) + + def sessions(): + with Session(db) as session: + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + + monkeypatch.setattr("common.core.db.get_session", sessions) + app.dependency_overrides[get_session] = sessions + app.dependency_overrides[get_i18n] = lambda: trans + with Session(db) as s: + s.add(WorkspaceModel(id=1, name="synthetic")) + s.commit() + with TestClient(app, raise_server_exceptions=False) as client: + yield SimpleNamespace(db=db, client=client, policy=policy) + finally: + db.dispose() + if admin: + with admin.begin() as c: + c.execute(text(f'DROP SCHEMA "{schema}" CASCADE')) + admin.dispose() + + +def seed(env, stored=None, uid=2, **changes): + values = { + "id": uid, + "account": "admin" if uid == 1 else f"audit{uid}", + "name": f"Synthetic {uid}", + "email": f"audit{uid}@example.invalid", + "oid": 1, + "status": 1, + "origin": 0, + "password": stored or legacy_md5(PWD), + } + values.update(changes) + with Session(env.db) as s: + model = UserModel(**values) + s.add(model) + s.add(UserWsModel(uid=uid, oid=1, weight=0)) + s.commit() + return values["account"] + + +def stored(env, uid=2): + with Session(env.db) as s: + return s.get(UserModel, uid).password + + +def headers(uid=2): + return { + settings.TOKEN_KEY: "Bearer " + + create_access_token( + {"id": uid, "account": "admin" if uid == 1 else f"audit{uid}", "oid": 1}, + timedelta(minutes=5), + ) + } + + +def log_in(env, account="audit2", password=PWD): + return env.client.post( + "/api/v1/login/access-token", data={"username": account, "password": password} + ) + + +@pytest.mark.parametrize( + "encode", [legacy_md5, lambda p: wrap_legacy_password(legacy_md5(p)), hash_password] +) +def test_login_compatibility_and_upgrade(environment, encode): + e = environment + seed(e, encode(PWD)) + before = stored(e) + assert log_in(e, password="Incorrect42!").status_code == 400 + assert log_in(e, account="absent").status_code == 400 + assert stored(e) == before + response = log_in(e) + assert response.status_code == 200, response.text + assert response.json()["access_token"] + after = stored(e) + assert after.startswith("$argon2id$") and verify_password(PWD, after) + assert not needs_password_upgrade(after) + assert log_in(e).status_code == 200 + assert stored(e) == after + assert PWD not in response.text and "password" not in response.text + + +@pytest.mark.parametrize("changes", [{"status": 0}, {"oid": 0}, {"origin": 1}]) +def test_rejected_local_account_never_upgrades(environment, changes): + seed(environment, **changes) + before = stored(environment) + assert log_in(environment).status_code == 400 + assert stored(environment) == before + + +@pytest.mark.parametrize( + "encode", [legacy_md5, lambda p: wrap_legacy_password(legacy_md5(p)), hash_password] +) +def test_initial_policy_and_first_change(environment, encode): + e = environment + seed(e, encode(settings.DEFAULT_PWD)) + e.policy.return_value = True + response = log_in(e, password=settings.DEFAULT_PWD) + assert response.status_code == 200, response.text + assert response.json()["need_change_pwd"] is True + assert response.json()["access_token"] == "" + before = stored(e) + payload = { + "account": "audit2", + "pwd": settings.DEFAULT_PWD, + "new_pwd": settings.DEFAULT_PWD, + } + assert e.client.post("/api/v1/login/change-pwd", json=payload).status_code == 400 + assert stored(e) == before + payload["new_pwd"] = NEW + response = e.client.post("/api/v1/login/change-pwd", json=payload) + assert response.status_code == 200, response.text + assert "password" not in response.text + assert stored(e).startswith("$argon2id$") + assert log_in(e, password=NEW).json()["access_token"] + assert log_in(e, password=settings.DEFAULT_PWD).status_code == 400 + + +def test_admin_initial_password_exemption(environment): + seed(environment, legacy_md5(settings.DEFAULT_PWD), uid=1) + environment.policy.return_value = True + assert log_in(environment, "admin", settings.DEFAULT_PWD).json()["access_token"] + assert stored(environment, 1).startswith("$argon2id$") + + +@pytest.mark.parametrize( + "encode", [legacy_md5, lambda p: wrap_legacy_password(legacy_md5(p)), hash_password] +) +def test_modify_reset_and_permissions(environment, encode): + e = environment + seed(e, uid=1) + seed(e, encode(PWD)) + before = stored(e) + assert ( + e.client.put("/api/v1/user/pwd", json={"pwd": PWD, "new_pwd": NEW}).status_code + == 401 + ) + denied = e.client.patch("/api/v1/user/pwd/2", headers=headers()) + assert denied.status_code == 500 + assert "管理员" in denied.text or "admin" in denied.text.lower() + assert stored(e) == before + for old, new in [("Incorrect42!", NEW), (PWD, "weak")]: + assert ( + e.client.put( + "/api/v1/user/pwd", headers=headers(), json={"pwd": old, "new_pwd": new} + ).status_code + != 200 + ) + assert stored(e) == before + response = e.client.put( + "/api/v1/user/pwd", headers=headers(), json={"pwd": PWD, "new_pwd": NEW} + ) + assert response.status_code == 200, response.text + assert "password" not in response.text + assert log_in(e, password=PWD).status_code == 400 + assert log_in(e, password=NEW).status_code == 200 + for _ in range(2): + prev = stored(e) + response = e.client.patch("/api/v1/user/pwd/2", headers=headers(1)) + assert response.status_code == 200, response.text + assert stored(e) != prev + assert stored(e).startswith("$argon2id$") + assert verify_password(settings.DEFAULT_PWD, stored(e)) + # Existing signed session still authenticates after credential replacement. + assert e.client.get("/api/v1/user/ws", headers=headers()).status_code == 200 + + +def test_create_import_and_no_digest_response(environment): + e = environment + seed(e, uid=1) + response = e.client.post( + "/api/v1/user", + headers=headers(1), + json={ + "account": "created", + "name": "Created", + "email": "created@example.invalid", + "oid": 1, + "oid_list": [1], + }, + ) + assert response.status_code == 200, response.text + assert "password" not in response.text + with Session(e.db) as s: + created = s.exec(select(UserModel).where(UserModel.account == "created")).one() + assert created.password.startswith("$argon2id$") + first = created.password + # Exercise actual Excel parser/validators/model defaults, not a stub row. + cols = [ + "account", + "name", + "email", + "workspace", + "role", + "status", + "origin", + "platform_user_id", + ] + frame = pd.DataFrame( + [ + [ + "imported", + "Imported", + "imported@example.invalid", + "", + "", + "已启用", + "本地创建", + "", + ] + ], + columns=cols, + ) + buffer = io.BytesIO() + frame.to_excel(buffer, index=False) + buffer.seek(0) + buffer.filename = "synthetic.xlsx" + + def translation(key): + return key.split(".")[-1] + + with Session(e.db) as s: + result = asyncio.run(user_excel.batchUpload(s, translation, buffer)) + assert result.successCount == 1 + imported = s.exec( + select(UserModel).where(UserModel.account == "imported") + ).one() + assert imported.password.startswith("$argon2id$") + assert imported.password != first + assert verify_password(settings.DEFAULT_PWD, imported.password) + + +@pytest.mark.parametrize( + "encode", [legacy_md5, lambda p: wrap_legacy_password(legacy_md5(p)), hash_password] +) +def test_mcp_password_and_existing_token(environment, encode): + e = environment + seed(e, encode(PWD)) + before = stored(e) + response = e.client.post( + "/api/v1/mcp/access_token", + json={"username": "audit2", "password": "Incorrect42!"}, + ) + assert response.status_code == 400 and stored(e) == before + response = e.client.post( + "/api/v1/mcp/access_token", json={"username": "audit2", "password": PWD} + ) + assert response.status_code == 200, response.text + assert stored(e).startswith("$argon2id$") + with Session(e.db) as s: + assert mcp.get_user(s, response.json()["access_token"]).id == 2 + + +def test_concurrent_reset_is_not_overwritten(environment): + e = environment + seed(e) + with Session(e.db) as s: + old = user_crud.get_user_by_account(session=s, account="audit2") + reset = hash_password(NEW) + with Session(e.db) as s: + s.execute(update(UserModel).where(UserModel.id == 2).values(password=reset)) + s.commit() + with Session(e.db) as s: + user_crud.upgrade_password(session=s, user=old, password=PWD) + s.commit() + assert stored(e) == reset + + +def test_storage_properties_and_corrupt_formats(): + a, b = hash_password(PWD), hash_password(PWD) + assert a != b and len(a) <= 255 + wrapped = wrap_legacy_password(legacy_md5(PWD)) + assert len(wrapped) <= 255 and verify_password(PWD, wrapped) + assert not verify_password(legacy_md5(PWD), wrapped) + for invalid in [ + "", + "plaintext", + "$argon2id$broken", + WRAPPED_MD5, + a.replace("m=19456", "m=999999999"), + "A" * 32, + None, + ]: + assert not verify_password(PWD, invalid) + + +def test_migration_resumable_and_preserves_changed_password(environment, monkeypatch): + e = environment + seed(e, legacy_md5(NEW), uid=1) + seed(e, uid=2) + seed(e, hash_password(PWD), uid=3) + spec = importlib.util.spec_from_file_location( + "password_migration", + ROOT / "backend/alembic/versions/075_local_password_hashes.py", + ) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + from alembic.migration import MigrationContext + from alembic.operations import Operations + + original_new = stored(e, 3) + with e.db.connect() as c: + context = MigrationContext.configure(c, opts={"transactional_ddl": True}) + with context.begin_transaction(): + monkeypatch.setattr(migration, "op", Operations(context)) + monkeypatch.setattr(migration, "BATCH_SIZE", 1) + migration.upgrade() + assert stored(e, 1).startswith(WRAPPED_MD5) and verify_password(NEW, stored(e, 1)) + assert not verify_password(settings.DEFAULT_PWD, stored(e, 1)) + assert stored(e).startswith(WRAPPED_MD5) + assert stored(e, 3) == original_new + before = [stored(e, i) for i in (1, 2, 3)] + with e.db.connect() as c: + context = MigrationContext.configure(c, opts={"transactional_ddl": True}) + with context.begin_transaction(): + monkeypatch.setattr(migration, "op", Operations(context)) + migration.upgrade() + migration.downgrade() + assert [stored(e, i) for i in (1, 2, 3)] == before + assert log_in(e, "admin", NEW).status_code == 200 + + +def test_real_encrypted_login_transport(environment, monkeypatch): + e = environment + seed(e) + monkeypatch.setattr(login, "sqlbot_decrypt", real_decrypt) + monkeypatch.setattr("common.utils.crypto.sqlbot_decrypt", real_decrypt) + from sqlbot_xpack.config import rsa_manage + from sqlbot_xpack.config.model import RsaModel + + RsaModel.__table__.create(e.db) + + def sessions(): + with Session(e.db) as session: + yield session + + monkeypatch.setattr(rsa_manage, "dynamic_get_session", lambda: sessions) + rsa_manage.get_rsa_keypair().rsa_key_check() + account = asyncio.run(real_encrypt("audit2")) + password = asyncio.run(real_encrypt(PWD)) + assert account != "audit2" and password != PWD + response = log_in(e, account, password) + assert response.status_code == 200, response.text + assert response.json()["access_token"] + assert stored(e).startswith("$argon2id$") + + +@pytest.mark.parametrize( + "encode", [legacy_md5, lambda p: wrap_legacy_password(legacy_md5(p)), hash_password] +) +def test_mcp_start_persists_chat_and_upgrades(environment, encode): + e = environment + seed(e, encode(PWD)) + response = e.client.post( + "/api/v1/mcp/mcp_start", json={"username": "audit2", "password": PWD} + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["access_token"] and body["chat_id"] + assert stored(e).startswith("$argon2id$") + response = e.client.post( + "/api/v1/mcp/mcp_start", json={"token": body["access_token"]} + ) + assert response.status_code == 200, response.text + + +@pytest.mark.parametrize("endpoint", ["access_token", "mcp_start"]) +@pytest.mark.parametrize("changes", [{"status": 0}, {"origin": 1}, {"oid": 0}]) +def test_mcp_ineligible_credentials_never_upgrade(environment, endpoint, changes): + e = environment + seed(e, **changes) + before = stored(e) + response = e.client.post( + f"/api/v1/mcp/{endpoint}", json={"username": "audit2", "password": PWD} + ) + # mcp_start's existing save_on_success_only audit wrapper suppresses an + # exception into null; either way it must never yield a token or write. + assert response.status_code == 400 or response.json() is None + assert stored(e) == before + + +@pytest.mark.parametrize( + "encode", [legacy_md5, lambda p: wrap_legacy_password(legacy_md5(p)), hash_password] +) +def test_embedded_and_assistant_tokens_survive_hash_changes(environment, encode): + e = environment + seed(e, encode(PWD)) + seed(e, uid=1) + secret = "synthetic-embedded-secret-" + uuid.uuid4().hex + with Session(e.db) as s: + for aid, typ in [(7, 4), (8, 0), (9, 1)]: + s.add( + AssistantModel( + id=aid, + name=f"Synthetic app {aid}", + type=typ, + domain="http://example.invalid", + app_secret=secret, + oid=1, + ) + ) + s.commit() + endpoint = "/api/v1/password-test/identity" + + def embedded(account="audit2", aid=7, key=secret): + token = jwt.encode( + {"account": account, "embeddedId": aid, "appId": "synthetic"}, + key, + algorithm="HS256", + ) + return {settings.ASSISTANT_TOKEN_KEY: "Embedded " + token} + + initial = embedded() + assert e.client.get(endpoint, headers=initial).status_code == 200 + assert log_in(e).status_code == 200 + assert e.client.get(endpoint, headers=initial).json()["uid"] == 2 + for invalid in [ + embedded("admin"), + embedded(aid=8), + embedded(key="wrong-synthetic-signature-key-123456"), + ]: + assert e.client.get(endpoint, headers=invalid).status_code == 401 + # Ordinary and advanced assistants use their own signed identity, independent + # of local password storage. Exercise actual middleware and assistant lookup. + for aid in [8, 9]: + token = create_access_token( + {"id": -1, "assistant_id": aid, "oid": 1}, timedelta(minutes=5) + ) + response = e.client.get( + endpoint, headers={settings.ASSISTANT_TOKEN_KEY: "Assistant " + token} + ) + assert response.status_code == 200, response.text + assert response.json()["assistant_type"] in (0, 1) + with Session(e.db) as s: + s.execute(update(UserModel).where(UserModel.id == 2).values(status=0)) + s.commit() + assert e.client.get(endpoint, headers=initial).status_code == 401 + + +def test_historical_admin_initialization_then_migration(environment, monkeypatch): + e = environment + + def migration_file(filename, name): + spec = importlib.util.spec_from_file_location( + name, ROOT / "backend/alembic/versions" / filename + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + from alembic.migration import MigrationContext + from alembic.operations import Operations + + init = migration_file("001_ddl.py", "historical_user_init") + upgrade = migration_file("075_local_password_hashes.py", "password_upgrade_init") + # Only the historical user initialization and this data migration; the full + # migration chain is checked separately. No existing application rows exist. + UserModel.__table__.drop(e.db) + with e.db.connect() as c: + context = MigrationContext.configure(c, opts={"transactional_ddl": True}) + with context.begin_transaction(): + monkeypatch.setattr(init, "op", Operations(context)) + init.upgrade() + monkeypatch.setattr(upgrade, "op", Operations(context)) + upgrade.upgrade() + value = c.scalar(text("SELECT password FROM sys_user WHERE id=1")) + assert value.startswith(WRAPPED_MD5) + assert verify_password("SQLBot@123456", value) + + +def test_migration_interruption_is_resumable(environment, monkeypatch): + e = environment + seed(e, uid=1) + seed(e, uid=2) + spec = importlib.util.spec_from_file_location( + "password_interrupted", + ROOT / "backend/alembic/versions/075_local_password_hashes.py", + ) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + from alembic.migration import MigrationContext + from alembic.operations import Operations + + calls = 0 + + def interrupt(stored): + nonlocal calls + calls += 1 + if calls == 2: + raise RuntimeError("synthetic interruption") + return wrap_legacy_password(stored) + + monkeypatch.setattr(migration, "wrap_legacy_password", interrupt) + with pytest.raises(RuntimeError, match="synthetic interruption"): + with e.db.connect() as c: + context = MigrationContext.configure(c, opts={"transactional_ddl": True}) + with context.begin_transaction(): + monkeypatch.setattr(migration, "op", Operations(context)) + migration.upgrade() + first = stored(e, 1) + assert first.startswith(WRAPPED_MD5) + assert stored(e) == legacy_md5(PWD) + monkeypatch.setattr(migration, "wrap_legacy_password", wrap_legacy_password) + with e.db.connect() as c: + context = MigrationContext.configure(c, opts={"transactional_ddl": True}) + with context.begin_transaction(): + monkeypatch.setattr(migration, "op", Operations(context)) + migration.upgrade() + assert stored(e, 1) == first + assert stored(e).startswith(WRAPPED_MD5) + + +@pytest.mark.skipif( + os.environ.get("SQLBOT_PASSWORD_TEST_PG") != "1", + reason="full migration chain requires explicit isolated PostgreSQL opt-in", +) +def test_full_fresh_install_and_upgrade_head(environment, monkeypatch): + from alembic import command + from alembic.config import Config + from sqlalchemy.engine import make_url + + e = environment + with e.db.begin() as c: + schema = c.scalar(text("SELECT current_schema()")) + assert schema.startswith("password_") and len(schema) == 41 + c.execute(text(f'DROP SCHEMA "{schema}" CASCADE')) + c.execute(text(f'CREATE SCHEMA "{schema}"')) + assert ( + c.scalar(text("SELECT count(*) FROM pg_extension WHERE extname='vector'")) + == 1 + ) + uri = make_url(os.environ["SQLBOT_TEST_DATABASE_URL"]).update_query_dict( + {"options": f"-csearch_path={schema}"} + ) + monkeypatch.setattr( + settings, "SQLBOT_DB_URL", uri.render_as_string(hide_password=False) + ) + config = Config(str(ROOT / "backend/alembic.ini")) + config.set_main_option("script_location", str(ROOT / "backend/alembic")) + # Qualify only the already-installed vector type, never shared app tables. + from pgvector.sqlalchemy import VECTOR + + original_spec = VECTOR.get_col_spec + monkeypatch.setattr( + VECTOR, "get_col_spec", lambda self, **kw: "public." + original_spec(self, **kw) + ) + from sqlalchemy import event + from sqlalchemy.engine import Engine + + def guard_schema( + connection, cursor, _statement, _parameters, _context, _executemany + ): + if connection.info.get("password_test_schema") != schema: + check = cursor.connection.cursor() + try: + check.execute("SELECT current_schema(), current_setting('search_path')") + assert check.fetchone() == (schema, schema), ( + "Refusing a non-isolated migration connection" + ) + finally: + check.close() + connection.info["password_test_schema"] = schema + + event.listen(Engine, "before_cursor_execute", guard_schema) + try: + command.upgrade(config, "head") + finally: + event.remove(Engine, "before_cursor_execute", guard_schema) + with e.db.connect() as c: + assert ( + c.scalar(text("SELECT version_num FROM alembic_version")) == "d91543ca7b21" + ) + value = c.scalar(text("SELECT password FROM sys_user WHERE id=1")) + assert value.startswith(WRAPPED_MD5) + assert verify_password("SQLBot@123456", value) + assert ( + c.scalar( + text("SELECT count(*) FROM sys_user WHERE password ~ '^[0-9a-f]{32}$'") + ) + == 0 + ) + command.downgrade(config, "-1") + with e.db.connect() as c: + assert c.scalar(text("SELECT password FROM sys_user WHERE id=1")) == value + command.upgrade(config, "head") + with e.db.connect() as c: + assert c.scalar(text("SELECT password FROM sys_user WHERE id=1")) == value + + +@pytest.mark.skipif( + os.environ.get("SQLBOT_PASSWORD_TEST_PG") != "1", reason="PostgreSQL schema guard" +) +def test_migration_requires_users_in_current_schema(environment, monkeypatch): + from alembic.migration import MigrationContext + from alembic.operations import Operations + + e = environment + UserModel.__table__.drop(e.db) + spec = importlib.util.spec_from_file_location( + "password_schema_guard", + ROOT / "backend/alembic/versions/075_local_password_hashes.py", + ) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + with e.db.connect() as c: + context = MigrationContext.configure(c, opts={"transactional_ddl": True}) + with context.begin_transaction(): + monkeypatch.setattr(migration, "op", Operations(context)) + with pytest.raises(RuntimeError, match="current migration schema"): + migration.upgrade() + + +def test_user_list_and_default_helpers_do_not_expose_digests(environment): + from common.core.security import ( + default_password_hash, + default_md5_pwd, + verify_md5pwd, + ) + + e = environment + seed(e, uid=1) + seed(e, hash_password(PWD), uid=2) + response = e.client.get("/api/v1/user/pager/1/10", headers=headers(1)) + # The existing route name is asserted rather than accepting a missing route. + assert response.status_code == 200, response.text + assert "password" not in response.text + a, b = default_password_hash(), default_md5_pwd() + assert a != b and a.startswith("$argon2id$") and b.startswith("$argon2id$") + assert verify_md5pwd(settings.DEFAULT_PWD, a) diff --git a/docs/agents/security.md b/docs/agents/security.md index 2cc0343b0..0761fc5bb 100644 --- a/docs/agents/security.md +++ b/docs/agents/security.md @@ -93,3 +93,12 @@ - embedded token 错误助手类型、错误签名、管理员账号和无效账号; - 上传路径穿越、非法扩展、超限大小和重复扩展名; - 前端错误传播和未净化内容渲染。 + +## 本地密码凭据 + +- 统一存储/校验入口为 `backend/common/core/passwords.py`。新密码使用 Argon2id(19 MiB、2 次迭代、并行度 1、16 字节随机盐);传输 RSA 与持久化哈希是独立机制。 +- 裸 MD5 仅用于历史读取兼容。迁移 `075_local_password_hashes.py` 分页读取并通过条件更新包装为带版本前缀的 Argon2id(MD5),每次写入独立提交,可在中断后重跑。不要用配置的初始密码覆盖数据库现有凭据。 +- 本地及 MCP 密码登录通过资格校验后,历史凭据升级为直接 Argon2id;条件更新避免覆盖并发重置。初始密码判断必须验证候选密码,不比较摘要是否相等。 +- 初始化、创建、导入、重置、改密必须生成新的随机盐哈希;密码摘要不得进入 API 响应、token 或日志。未知或损坏格式拒绝认证,不尝试其他算法兜底。 +- 上线先备份并停止旧版本写入,统一升级所有 API/MCP 实例,再执行迁移和启用新服务;不支持新旧程序混跑。迁移 downgrade 保留强哈希,不能恢复原 MD5;回退旧程序需要协调恢复升级前备份(评估后续业务数据损失)或受控重置,不能只回退二进制。旧备份可能仍含裸 MD5,迁移不能撤回已泄露凭据。 +- 大用户量部署先在同规格隔离环境测量登录并发、内存和迁移耗时;不要为提升速度降低参数。登录哈希在线程池执行;数据库迁移中的计算同步执行且不持有长期行锁。 diff --git a/docs/agents/testing.md b/docs/agents/testing.md index a5297b151..f780f4274 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -100,3 +100,18 @@ uv run ruff format --check - 没有为了通过测试削弱安全约束; - 默认测试不隐式访问外部服务;集成验证的环境、选择方式和限制已说明,提交中不含密钥; - 本次暂存和提交的 diff 只包含任务相关变更;用户原有无关改动保留原样。 + +## 本地密码升级回归 + +`tests/test_local_password_hashes.py` 默认在临时 SQLite 中运行真实 SQLModel、路由、事务与认证/权限中间件。覆盖历史 MD5、包装格式、直接 Argon2id、初始密码策略、创建/Excel 导入/重置/改密、两种 MCP 密码入口与已有 token、嵌入/普通及高级助手认证、摘要响应隔离、并发重置保护和迁移中断重跑。审计落盘为隔离边界;多数用例替换传输解密,另有真实 wheel RSA 加解密用例,密钥只生成在测试数据库中。 + +显式 PostgreSQL 验证使用已授权的开发数据库,每个用例创建并清理独立随机 schema,不读取既有用户记录: + +```bash +cd backend +uv run --no-sync pytest -q tests/test_local_password_hashes.py tests/test_initial_password_policy.py tests/test_embedded_auth_bypass_fix.py +# 在进程环境中安全提供 SQLBOT_TEST_DATABASE_URL,不把凭据写入命令或日志。 +SQLBOT_PASSWORD_TEST_PG=1 uv run --no-sync pytest -q tests/test_local_password_hashes.py +``` + +迁移用例分别执行历史 `001` 初始化与 `075`、以及 `075` 的重跑/中断恢复;显式 PostgreSQL 模式另从空 schema 执行全链升级、一级降级和重新升级。完整迁移测试的 `search_path` 只能包含当前测试 schema,禁止添加 `public`;共享扩展类型使用显式类型限定名。执行前检查实际连接的 schema/search_path,防止 Alembic 从共享 schema 读取版本表并更新业务表。现有 `tests/browser/password.config.mjs` 为真实改密组件的隔离浏览器回归,拦截 HTTP 响应,不等同于完整部署的 UI 验收。嵌入用例验证服务端签名/身份链,不模拟宿主浏览器 SDK 握手。交付必须区分这些层级。