diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index d5676efdf8..3b5831fbc6 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -596,6 +596,11 @@ class AuthConfig(BaseSettings): default=86400, ) + OWNER_TRANSFER_LOCKOUT_DURATION : PositiveInt = Field( + description="Time (in seconds) a user must wait before retrying owner transfer after exceeding the rate limit.", + default=86400, + ) + class ModerationConfig(BaseSettings): """ diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index 7076c850a8..5e55132e90 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -36,6 +36,10 @@ class EmailChangeRateLimitExceededError(BaseHTTPException): description = "Too many email change emails have been sent. Please try again in 1 minutes." code = 429 +class OwnerTransferRateLimitExceededError(BaseHTTPException): + error_code = "owner_transfer_rate_limit_exceeded" + description = "Too many owner tansfer emails have been sent. Please try again in 1 minutes." + code = 429 class EmailCodeError(BaseHTTPException): error_code = "email_code_error" @@ -83,3 +87,8 @@ class EmailAlreadyInUseError(BaseHTTPException): error_code = "email_already_in_use" description = "A user with this email already exists." code = 400 + +class OwnerTransferLimitError(BaseHTTPException): + error_code = "owner_transfer_limit" + description = "Too many failed owner transfer attempts. Please try again in 24 hours." + code = 429 \ No newline at end of file diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 48225ac90d..10032b8877 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -1,5 +1,5 @@ from urllib import parse - +from flask import request from flask_login import current_user from flask_restful import Resource, abort, marshal_with, reqparse @@ -11,15 +11,18 @@ from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_resource_check, setup_required, + is_allow_transfer_owner, ) from extensions.ext_database import db from fields.member_fields import account_with_role_list_fields from libs.login import login_required from models.account import Account, TenantAccountRole -from services.account_service import RegisterService, TenantService +from services.account_service import RegisterService, TenantService,AccountService from services.errors.account import AccountAlreadyInTenantError from services.feature_service import FeatureService - +from libs.helper import email,extract_remote_ip +from controllers.console.error import EmailSendIpLimitError +from controllers.console.auth.error import OwnerTransferLimitError,InvalidTokenError,InvalidEmailError,EmailCodeError class MemberListApi(Resource): """List all members of current tenant.""" @@ -156,8 +159,118 @@ class DatasetOperatorMemberListApi(Resource): return {"result": "success", "accounts": members}, 200 +class SendOwnerTransferEmailApi(Resource): + """Send owner transfer email.""" + + @setup_required + @login_required + @account_initialization_required + @is_allow_transfer_owner + def post(self,member_id): + parser = reqparse.RequestParser() + parser.add_argument("language", type=str, required=False, location="json") + args = parser.parse_args() + + ip_address = extract_remote_ip(request) + if AccountService.is_email_send_ip_limit(ip_address): + raise EmailSendIpLimitError() + + if args["language"] is not None and args["language"] == "zh-Hans": + language = "zh-Hans" + else: + language = "en-US" + + email = current_user.email + member = db.session.get(Account, str(member_id)) + if not member: + abort(404) + + token = AccountService.send_owner_transfer_email( + account=current_user, email=email, language=language, workspace=current_user.current_tenant, member=member + ) + + return {"result": "success", "data": token} + +class OwnerTransferCheckEApi(Resource): + @setup_required + @login_required + @account_initialization_required + @is_allow_transfer_owner + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("code", type=str, required=True, location="json") + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + + user_email = current_user.email + + is_owner_transfer_error_rate_limit = AccountService.is_owner_transfer_error_rate_limit(args["email"]) + if is_owner_transfer_error_rate_limit: + raise OwnerTransferLimitError() + + token_data = AccountService.get_owner_transfer_data(args["token"]) + if token_data is None: + raise InvalidTokenError() + + if user_email != token_data.get("email"): + raise InvalidEmailError() + + if args["code"] != token_data.get("code"): + AccountService.add_owner_transfer_error_rate_limit(args["email"]) + raise EmailCodeError() + + # Verified, revoke the first token + AccountService.revoke_owner_transfer_token(args["token"]) + + # Refresh token data by generating a new token + _, new_token = AccountService.generate_owner_transfer_token( + user_email, code=args["code"], additional_data={} + ) + + AccountService.reset_owner_transfer_error_rate_limit(args["email"]) + return {"is_valid": True, "email": token_data.get("email"), "token": new_token} + +class OwnerTransfer(Resource): + @setup_required + @login_required + @account_initialization_required + @is_allow_transfer_owner + def post(self, member_id): + parser = reqparse.RequestParser() + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + + transfer_token_data = AccountService.get_owner_transfer_data(args["token"]) + if not transfer_token_data: + raise InvalidTokenError() + + if transfer_token_data.get("phase", "") != "owner_transfer": + raise InvalidTokenError() + + if transfer_token_data.get("email") != current_user.email: + raise InvalidEmailError() + + AccountService.revoke_owner_transfer_token(args["token"]) + + member = db.session.get(Account, str(member_id)) + if not member: + abort(404) + + try: + assert member is not None, "Member not found" + TenantService.update_member_role(current_user.current_tenant, member, "owner", current_user) + except Exception as e: + raise ValueError(str(e)) + + return {"result": "success"} + + api.add_resource(MemberListApi, "/workspaces/current/members") api.add_resource(MemberInviteEmailApi, "/workspaces/current/members/invite-email") api.add_resource(MemberCancelInviteApi, "/workspaces/current/members/") api.add_resource(MemberUpdateRoleApi, "/workspaces/current/members//update-role") api.add_resource(DatasetOperatorMemberListApi, "/workspaces/current/dataset-operators") +#owner transfer +api.add_resource(SendOwnerTransferEmailApi, "/workspaces/current/members//send-owner-transfer-confirm-email") +api.add_resource(OwnerTransferCheckEApi, "/workspaces/current/members/owner-transfer-check") +api.add_resource(OwnerTransfer, "/workspaces/current/members//owner-transfer") \ No newline at end of file diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index feb4b2ec39..7db8787a0c 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -248,3 +248,15 @@ def enable_change_email(view): abort(403) return decorated + +def is_allow_transfer_owner(view): + @wraps(view) + def decorated(*args, **kwargs): + features = FeatureService.get_features() + if features.is_allow_transfer_owner: + return view(*args, **kwargs) + + # otherwise, return 403 + abort(403) + + return decorated \ No newline at end of file diff --git a/api/services/account_service.py b/api/services/account_service.py index 374b58e061..6c2631277b 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -56,6 +56,7 @@ from tasks.mail_change_mail_task import send_change_mail_task from tasks.mail_email_code_login import send_email_code_login_mail_task from tasks.mail_invite_member_task import send_invite_member_mail_task from tasks.mail_reset_password_task import send_reset_password_mail_task +from tasks.mail_owner_transfer_task import send_owner_transfer_confirm_task class TokenPair(BaseModel): @@ -77,9 +78,12 @@ class AccountService: prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1 ) change_email_rate_limiter = RateLimiter(prefix="change_email_rate_limit", max_attempts=1, time_window=60 * 1) + owner_transfer_rate_limiter = RateLimiter(prefix="owner_transfer_rate_limit", max_attempts=1, time_window=60 * 1) + LOGIN_MAX_ERROR_LIMITS = 5 FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5 CHANGE_EMAIL_MAX_ERROR_LIMITS = 5 + OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 @staticmethod def _get_refresh_token_key(refresh_token: str) -> str: @@ -451,6 +455,36 @@ class AccountService: cls.change_email_rate_limiter.increment_rate_limit(account_email) return token + @classmethod + def send_owner_transfer_email( + cls, + account: Optional[Account] = None, + email: Optional[str] = None, + language: Optional[str] = "en-US", + workspace: Optional[Tenant] = None, + member: Optional[Account] = None, + ): + account_email = account.email if account else email + if account_email is None: + raise ValueError("Email must be provided.") + + if cls.owner_transfer_rate_limiter.is_rate_limited(account_email): + from controllers.console.auth.error import OwnerTransferRateLimitExceededError + + raise OwnerTransferRateLimitExceededError() + + code, token = cls.generate_owner_transfer_token(account_email, account) + + send_owner_transfer_confirm_task.delay( + language=language, + to=account_email, + code=code, + workspace=workspace.name, + member=member.name, + ) + cls.owner_transfer_rate_limiter.increment_rate_limit(account_email) + return token + @classmethod def generate_reset_password_token( cls, @@ -484,6 +518,22 @@ class AccountService: account=account, email=email, token_type="change_email", additional_data=additional_data ) return code, token + + @classmethod + def generate_owner_transfer_token( + cls, + email: str, + account: Optional[Account] = None, + code: Optional[str] = None, + additional_data: dict[str, Any] = {}, + ): + if not code: + code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) + additional_data["code"] = code + token = TokenManager.generate_token( + account=account, email=email, token_type="owner_transfer", additional_data=additional_data + ) + return code, token @classmethod def revoke_reset_password_token(cls, token: str): @@ -493,6 +543,10 @@ class AccountService: def revoke_change_email_token(cls, token: str): TokenManager.revoke_token(token, "change_email") + @classmethod + def revoke_owner_transfer_token(cls, token: str): + TokenManager.revoke_token(token, "owner_transfer") + @classmethod def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]: return TokenManager.get_token_data(token, "reset_password") @@ -501,6 +555,10 @@ class AccountService: def get_change_email_data(cls, token: str) -> Optional[dict[str, Any]]: return TokenManager.get_token_data(token, "change_email") + @classmethod + def get_owner_transfer_data(cls, token: str) -> Optional[dict[str, Any]]: + return TokenManager.get_token_data(token, "owner_transfer") + @classmethod def send_email_code_login_email( cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US" @@ -637,7 +695,40 @@ class AccountService: def reset_change_email_error_rate_limit(email: str): key = f"change_email_error_rate_limit:{email}" redis_client.delete(key) + + @staticmethod + @redis_fallback(default_return=None) + def add_owner_transfer_error_rate_limit(email: str) -> None: + key = f"owner_transfer_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + count = 0 + count = int(count) + 1 + redis_client.setex(key, dify_config.OWNER_TRANSFER_LOCKOUT_DURATION, count) + + @staticmethod + @redis_fallback(default_return=False) + def is_owner_transfer_error_rate_limit(email: str) -> bool: + key = f"owner_transfer_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + return False + count = int(count) + if count > AccountService.OWNER_TRANSFER_MAX_ERROR_LIMITS: + return True + return False + + @staticmethod + @redis_fallback(default_return=None) + def reset_owner_transfer_error_rate_limit(email: str): + key = f"owner_transfer_error_rate_limit:{email}" + redis_client.delete(key) + + @staticmethod + @redis_fallback(default_return=None) + def reset_owner_transfer_error_rate_limit(email: str): + key = f"owner_transfer_error_rate_limit:{email}" @staticmethod @redis_fallback(default_return=False) def is_email_send_ip_limit(ip_address: str): diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 3f164431fb..3b8a5318eb 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -123,6 +123,7 @@ class FeatureModel(BaseModel): dataset_operator_enabled: bool = False webapp_copyright_enabled: bool = False workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0) + is_allow_transfer_workspace: bool = True # pydantic configs model_config = ConfigDict(protected_namespaces=()) @@ -150,7 +151,6 @@ class SystemFeatureModel(BaseModel): plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel() enable_change_email: bool = True - class FeatureService: @classmethod def get_features(cls, tenant_id: str) -> FeatureModel: @@ -229,6 +229,7 @@ class FeatureService: if features.billing.subscription.plan != "sandbox": features.webapp_copyright_enabled = True + features.is_allow_transfer_workspace = False if "members" in billing_info: features.members.size = billing_info["members"]["size"] diff --git a/api/tasks/mail_owner_transfer_task.py b/api/tasks/mail_owner_transfer_task.py new file mode 100644 index 0000000000..d87be78cdb --- /dev/null +++ b/api/tasks/mail_owner_transfer_task.py @@ -0,0 +1,55 @@ +import logging +import time + +import click +from celery import shared_task # type: ignore +from flask import render_template + +from extensions.ext_mail import mail +from services.feature_service import FeatureService + + +@shared_task(queue="mail") +def send_owner_transfer_confirm_task(language: str, to: str, code: str, workspace: str, member: str): + """ + Async Send owner transfer confirm mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param code: Change email code + :param workspace: Workspace name + :param member: Member name + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + # send change email mail using different languages + try: + if language == "zh-Hans": + template = "transfer_workspace_owner_confirm_template_zh-CN.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_owner_confirm_template_zh-CN.html" + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace,NewOwner=member) + mail.send(to=to, subject=f"验证您转移工作空间所有权的请求", html=html_content) + else: + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace,NewOwner=member) + mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content) + else: + template = "transfer_workspace_owner_confirm_template_en-US.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_owner_confirm_template_en-US.html" + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace,NewOwner=member) + mail.send(to=to, subject=f"Verify Your Request to Transfer Workspace Ownership", html=html_content) + else: + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace,NewOwner=member) + mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style("Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), fg="green") + ) + except Exception: + logging.exception("owner transfer confirm email mail to {} failed".format(to)) diff --git a/api/templates/transfer_workspace_owner_confirm_template_en-US.html b/api/templates/transfer_workspace_owner_confirm_template_en-US.html new file mode 100644 index 0000000000..6aadae8aef --- /dev/null +++ b/api/templates/transfer_workspace_owner_confirm_template_en-US.html @@ -0,0 +1,92 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Verify Your Request to Transfer Workspace Ownership

+

We received a request to transfer ownership of your workspace “{{WorkspaceName}}” to the + member {{NewOwner}}. + + To confirm this action, please use the verification code below. + This code will only be valid for the next 5 minutes:

+
+ {{code}} +
+

Please note: The ownership transfer will take effect immediately once confirmed and cannot be + undone. + You’ll become a admin member, and the new owner will have full control of the workspace.If you didn’t make this + request, please ignore this email or contact support immediately.

+
+ + + \ No newline at end of file diff --git a/api/templates/transfer_workspace_owner_confirm_template_zh-CN.html b/api/templates/transfer_workspace_owner_confirm_template_zh-CN.html new file mode 100644 index 0000000000..c8a519fe1e --- /dev/null +++ b/api/templates/transfer_workspace_owner_confirm_template_zh-CN.html @@ -0,0 +1,90 @@ + + + + + + + + +
+
+ + Dify Logo +
+

验证您的工作空间所有权转移请求

+

我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移给成员{{NewOwner}}的请求。 + + 为了确认此操作,请使用以下验证码。 + 此验证码仅在5分钟内有效:

+
+ {{code}} +
+

请注意:所有权转移一旦确认将立即生效且无法撤销。您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。 + 如果您没有发起此请求,请忽略此邮件或立即联系客服。 +

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html b/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html new file mode 100644 index 0000000000..013739284f --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html @@ -0,0 +1,89 @@ + + + + + + + + +
+

Verify Your Request to Transfer Workspace Ownership

+

We received a request to transfer ownership of your workspace “{{WorkspaceName}}” to the + member {{NewOwner}}. + + To confirm this action, please use the verification code below. + This code will only be valid for the next 5 minutes:

+
+ {{code}} +
+

Please note: The ownership transfer will take effect immediately once confirmed and cannot be + undone. + You’ll become a admin member, and the new owner will have full control of the workspace.If you didn’t make + this + request, please ignore this email or contact support immediately.

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html b/api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html new file mode 100644 index 0000000000..5831c1d742 --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html @@ -0,0 +1,87 @@ + + + + + + + + +
+

验证您的工作空间所有权转移请求

+

我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移给成员{{NewOwner}}的请求。 + + 为了确认此操作,请使用以下验证码。 + 此验证码仅在5分钟内有效:

+
+ {{code}} +
+

请注意:所有权转移一旦确认将立即生效且无法撤销。 + + 您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。如果您没有发起此请求,请忽略此邮件或立即联系客服。 +

+
+ + + \ No newline at end of file