关于 Dify 平台多租户的几项测试

关于 Dify 平台多租户的几项测试

  • 测试1:创建多个账户后,只保留自己的工作区,关闭多租户创建功能。(实现对多工作区的权限管控)
  • 测试2:创建账号后,在数据库删除工作区ID,使其失去拥有完全权限的工作区。
  • 测试3:多账户登录,多个账号同时登录是否有影响。
  • 探索1:编写shell脚本替换所有Dify文字和图标。
  • 探索2:编写程序实现对docker中数据库的读写,实现独立程序对Dify应用的权限管控。

环境说明:

系统版本:Linux ubuntu24 6.8.0-51-generic #52-Ubuntu SMP PREEMPT_DYNAMIC Thu Dec 5 13:09:44 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux

image

Dify 版本:0.15.3

image

浏览器版本:Version 131.0.6778.264 (Official Build) (64-bit)

image

一、测试1:多工作区的开启与关闭

1.1 使用 docker 启动 dify

image

1.2 创建用户验证无多租户功能

创建新用户:

image

新用户登录(无新工作区):

image

1.3 从 docker 更改源代码开启多租户

执行 sudo docker exec -it docker-api-1 /bin/bash 进入容器命令行

image

执行 apt update && apt install -y vim 安装编辑器

image

执行 services/account_service.py 编辑文件增加创建工作区的代码(增加.filter_by(role="owner"),意思是自己账户下没有工作区的时候就创建一个)

image

执行 vim services/feature_service.py 编辑文件开启创建工作区的开关(将此变量改成True,图中是拷贝了一行再修改的,也可以直接修改)

image

1.4 重启容器

执行 sudo docker restart docker-api-1 重启

image

1.5 创建新用户验证多租户开启

创建新用户:

image

登录新用户发现已经有自己的工作区:

image

二、测试2: 操作数据库记录对 Dify 平台的影响

可操作 accounts 表,控制账户的信息和登录使能。
可操作 tenants 表,控制工作区。

三、测试3:多端登录是否可行

3.1 创建新用户 test3login@example.com

  1. 设置中创建新用户

image

  1. 访问邀请链接并设置秘密

image

  1. 设置用户名和界面语言等信息

image

3.2 不同环境同时登录该用户

设置用户名后会自动登录该账号,为避免受创建用户首次登录的特殊性。将此账号退出后,在此浏览器重新登录。然后使用另一电脑的浏览器访问该服务,使用此账号登录。经验证,两个账号可以同时保持登录状态,不会出现冲突下线情况。

3.3 不同环境同时用此用户进行流程操作

分别在两台电脑登录此账号,在A电脑创建一个工作流,在B电脑打开此工作流,然后同时进行编辑,经验证,数据以最后执行保存操作时所在机器上的数据为准(即覆盖)。 如果A电脑进行了内容更新,如果进行另比如发布、退出应用等会执行保存的操作后,B电脑刷新页面可更新到最新数据(比如工作流中刷新页面,会显示A电脑进行保存后的页面,但之前此电脑进行的操作会丢失),如果B电脑没有刷新而进行了保存操作,则会覆盖掉A电脑上进行的操作。

image

3.4 根据源代码确认

  1. /login api 匹配的登录函数
# /api/controllers/console/auth/login.py

from controllers.console import api

class LoginApi(Resource):
    """Resource for user login."""

    @setup_required
    def post(self):
        """Authenticate user and login."""
        parser = reqparse.RequestParser()
        parser.add_argument("email", type=email, required=True, location="json")
        parser.add_argument("password", type=valid_password, required=True, location="json")
        parser.add_argument("remember_me", type=bool, required=False, default=False, location="json")
        parser.add_argument("invite_token", type=str, required=False, default=None, location="json")
        parser.add_argument("language", type=str, required=False, default="en-US", location="json")
        args = parser.parse_args()

        if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args["email"]):
            raise AccountInFreezeError()

        is_login_error_rate_limit = AccountService.is_login_error_rate_limit(args["email"])
        if is_login_error_rate_limit:
            raise EmailPasswordLoginLimitError()

        invitation = args["invite_token"]
        if invitation:
            invitation = RegisterService.get_invitation_if_token_valid(None, args["email"], invitation)

        if args["language"] is not None and args["language"] == "zh-Hans":
            language = "zh-Hans"
        else:
            language = "en-US"

        try:
            if invitation:
                data = invitation.get("data", {})
                invitee_email = data.get("email") if data else None
                if invitee_email != args["email"]:
                    raise InvalidEmailError()
                account = AccountService.authenticate(args["email"], args["password"], args["invite_token"])
            else:
                account = AccountService.authenticate(args["email"], args["password"])
        except services.errors.account.AccountLoginError:
            raise AccountBannedError()
        except services.errors.account.AccountPasswordError:
            AccountService.add_login_error_rate_limit(args["email"])
            raise EmailOrPasswordMismatchError()
        except services.errors.account.AccountNotFoundError:
            if FeatureService.get_system_features().is_allow_register:
                token = AccountService.send_reset_password_email(email=args["email"], language=language)
                return {"result": "fail", "data": token, "code": "account_not_found"}
            else:
                raise AccountNotFound()
        # SELF_HOSTED only have one workspace
        tenants = TenantService.get_join_tenants(account)
        if len(tenants) == 0:
            return {
                "result": "fail",
                "data": "workspace not found, please contact system admin to invite you to join in a workspace",
            }

        token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
        AccountService.reset_login_error_rate_limit(args["email"])
        return {"result": "success", "data": token_pair.model_dump()}

api.add_resource(LoginApi, "/login")
  1. 认证方法函数体
# /api/services/account_service.py

class AccountService:
    reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
    email_code_login_rate_limiter = RateLimiter(
        prefix="email_code_login_rate_limit", max_attempts=1, time_window=60 * 1
    )
    email_code_account_deletion_rate_limiter = RateLimiter(
        prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1
    )
    LOGIN_MAX_ERROR_LIMITS = 5

    @staticmethod
    def get_account_jwt_token(account: Account) -> str:
        exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)
        exp = int(exp_dt.timestamp())
        payload = {
            "user_id": account.id,
            "exp": exp,
            "iss": dify_config.EDITION,
            "sub": "Console API Passport",
        }

        token: str = PassportService().issue(payload)
        return token

    @staticmethod
    def authenticate(email: str, password: str, invite_token: Optional[str] = None) -> Account:
        """authenticate account with email and password"""

        account = Account.query.filter_by(email=email).first()
        if not account:
            raise AccountNotFoundError()

        if account.status == AccountStatus.BANNED.value:
            raise AccountLoginError("Account is banned.")

        if password and invite_token and account.password is None:
            # if invite_token is valid, set password and password_salt
            salt = secrets.token_bytes(16)
            base64_salt = base64.b64encode(salt).decode()
            password_hashed = hash_password(password, salt)
            base64_password_hashed = base64.b64encode(password_hashed).decode()
            account.password = base64_password_hashed
            account.password_salt = base64_salt

        if account.password is None or not compare_password(password, account.password, account.password_salt):
            raise AccountPasswordError("Invalid email or password.")

        if account.status == AccountStatus.PENDING.value:
            account.status = AccountStatus.ACTIVE.value
            account.initialized_at = datetime.now(UTC).replace(tzinfo=None)

        db.session.commit()

        return cast(Account, account)

    @staticmethod
    def update_login_info(account: Account, *, ip_address: str) -> None:
        """Update last login time and ip"""
        account.last_login_at = datetime.now(UTC).replace(tzinfo=None)
        account.last_login_ip = ip_address
        db.session.add(account)
        db.session.commit()

    @staticmethod
    def login(account: Account, *, ip_address: Optional[str] = None) -> TokenPair:
        if ip_address:
            AccountService.update_login_info(account=account, ip_address=ip_address)

        if account.status == AccountStatus.PENDING.value:
            account.status = AccountStatus.ACTIVE.value
            db.session.commit()

        access_token = AccountService.get_account_jwt_token(account=account)
        refresh_token = _generate_refresh_token()

        AccountService._store_refresh_token(refresh_token, account.id)

        return TokenPair(access_token=access_token, refresh_token=refresh_token)

上述文件中没有看到限制多端登录的代码,结合实际测试,认为 Dify 可以多端登录。

登录相关概念补充:

单地登录:指一个账号同一时间只能在一个地方登录,新登录会挤掉旧登录,也可以叫:单端登录。
多地登录:指一个账号同一时间可以在不同地方登录,新登录会和旧登录共存,也可以叫:多端登录。
同端互斥登录:在同一类型设备上只允许单地点登录,在不同类型设备上允许同时在线,参考腾讯QQ的登录模式:手机和电脑可以同时在线,但不能两个手机同时在线。

使用 Sa-Token 实现不同的登录模式:单地登录、多地登录、同端互斥登录

五、探索2:独立应用实现 Dify 平台的权限管理

posted @   那个白熊  阅读(607)  评论(2编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· .NET10 - 预览版1新功能体验(一)
历史上的今天:
2020-02-16 二维码,QR码,编码原理与实现
2020-02-16 关于域名申请、注册、购买、解析、续费的问题
2020-02-16 天翼网关获取超级密码
点击右上角即可分享
微信分享提示