From ab2839497540cc008da5a437e349c5c69719fcd2 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Fri, 11 Jul 2025 16:06:51 +0800 Subject: [PATCH] add api to transfer owner --- api/configs/feature/__init__.py | 2 +- api/controllers/console/auth/error.py | 5 ++- api/controllers/console/workspace/members.py | 41 ++++++++++++-------- api/controllers/console/wraps.py | 7 ++-- api/services/account_service.py | 23 +++++------ api/services/feature_service.py | 1 + api/tasks/mail_owner_transfer_task.py | 17 ++++---- 7 files changed, 54 insertions(+), 42 deletions(-) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 3b5831fbc6..e0f7f75421 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -596,7 +596,7 @@ class AuthConfig(BaseSettings): default=86400, ) - OWNER_TRANSFER_LOCKOUT_DURATION : PositiveInt = Field( + 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, ) diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index 5e55132e90..829fc797bd 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -36,11 +36,13 @@ 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" description = "Email code is invalid or expired." @@ -88,7 +90,8 @@ class EmailAlreadyInUseError(BaseHTTPException): 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 + code = 429 diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 10032b8877..27f0c7354c 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -1,4 +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 @@ -6,23 +7,23 @@ from flask_restful import Resource, abort, marshal_with, reqparse import services from configs import dify_config from controllers.console import api -from controllers.console.error import WorkspaceMembersLimitExceeded +from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError, OwnerTransferLimitError +from controllers.console.error import EmailSendIpLimitError, WorkspaceMembersLimitExceeded from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_resource_check, - setup_required, is_allow_transfer_owner, + setup_required, ) from extensions.ext_database import db from fields.member_fields import account_with_role_list_fields +from libs.helper import extract_remote_ip from libs.login import login_required from models.account import Account, TenantAccountRole -from services.account_service import RegisterService, TenantService,AccountService +from services.account_service import AccountService, RegisterService, TenantService 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.""" @@ -166,11 +167,11 @@ class SendOwnerTransferEmailApi(Resource): @login_required @account_initialization_required @is_allow_transfer_owner - def post(self,member_id): + 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() @@ -184,13 +185,20 @@ class SendOwnerTransferEmailApi(Resource): member = db.session.get(Account, str(member_id)) if not member: abort(404) + else: + member_name = member.name token = AccountService.send_owner_transfer_email( - account=current_user, email=email, language=language, workspace=current_user.current_tenant, member=member + account=current_user, + email=email, + language=language, + workspace_name=current_user.current_tenant.name, + member_name=member_name, ) return {"result": "success", "data": token} + class OwnerTransferCheckEApi(Resource): @setup_required @login_required @@ -223,13 +231,12 @@ class OwnerTransferCheckEApi(Resource): 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={} - ) + _, 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 @@ -249,7 +256,7 @@ class OwnerTransfer(Resource): 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)) @@ -270,7 +277,9 @@ 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") +# 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 +api.add_resource(OwnerTransfer, "/workspaces/current/members//owner-transfer") diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 7db8787a0c..d862dac373 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -249,14 +249,15 @@ def enable_change_email(view): return decorated + def is_allow_transfer_owner(view): @wraps(view) def decorated(*args, **kwargs): - features = FeatureService.get_features() - if features.is_allow_transfer_owner: + features = FeatureService.get_features(current_user.current_tenant_id) + if features.is_allow_transfer_workspace: return view(*args, **kwargs) # otherwise, return 403 abort(403) - return decorated \ No newline at end of file + return decorated diff --git a/api/services/account_service.py b/api/services/account_service.py index 6c2631277b..055e4b8dd8 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -55,8 +55,8 @@ from tasks.mail_account_deletion_task import send_account_deletion_verification_ 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 +from tasks.mail_reset_password_task import send_reset_password_mail_task class TokenPair(BaseModel): @@ -461,8 +461,8 @@ class AccountService: account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US", - workspace: Optional[Tenant] = None, - member: Optional[Account] = None, + workspace_name: Optional[str] = "", + member_name: Optional[str] = "", ): account_email = account.email if account else email if account_email is None: @@ -479,8 +479,8 @@ class AccountService: language=language, to=account_email, code=code, - workspace=workspace.name, - member=member.name, + workspace=workspace_name, + member=member_name, ) cls.owner_transfer_rate_limiter.increment_rate_limit(account_email) return token @@ -518,7 +518,7 @@ class AccountService: account=account, email=email, token_type="change_email", additional_data=additional_data ) return code, token - + @classmethod def generate_owner_transfer_token( cls, @@ -695,7 +695,7 @@ 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: @@ -705,7 +705,7 @@ class AccountService: 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: @@ -717,18 +717,13 @@ class AccountService: 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 3b8a5318eb..9e5a1a4305 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -151,6 +151,7 @@ class SystemFeatureModel(BaseModel): plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel() enable_change_email: bool = True + class FeatureService: @classmethod def get_features(cls, tenant_id: str) -> FeatureModel: diff --git a/api/tasks/mail_owner_transfer_task.py b/api/tasks/mail_owner_transfer_task.py index d87be78cdb..bcd9ee7c8a 100644 --- a/api/tasks/mail_owner_transfer_task.py +++ b/api/tasks/mail_owner_transfer_task.py @@ -31,25 +31,28 @@ def send_owner_transfer_confirm_task(language: str, to: str, code: str, workspac 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) + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace, NewOwner=member) + mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content) else: - html_content = render_template(template, to=to, code=code, WorkspaceName=workspace,NewOwner=member) + 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) + 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) else: - html_content = render_template(template, to=to, code=code, WorkspaceName=workspace,NewOwner=member) + 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") + 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))