diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 73f8a95989baaf..bceaa7ca2e8237 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -767,6 +767,13 @@ class LoginConfig(BaseSettings): ) +class AccountConfig(BaseSettings): + ACCOUNT_DELETION_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( + description="Duration in minutes for which a account deletion token remains valid", + default=5, + ) + + class FeatureConfig( # place the configs in alphabet order AppExecutionConfig, @@ -794,6 +801,7 @@ class FeatureConfig( WorkflowNodeExecutionConfig, WorkspaceConfig, LoginConfig, + AccountConfig, # hosted services config HostedServiceConfig, CeleryBeatConfig, diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index e6e30c3c0b015f..8ef10c7bbb11cd 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -53,3 +53,9 @@ class EmailCodeLoginRateLimitExceededError(BaseHTTPException): error_code = "email_code_login_rate_limit_exceeded" description = "Too many login emails have been sent. Please try again in 5 minutes." code = 429 + + +class EmailCodeAccountDeletionRateLimitExceededError(BaseHTTPException): + error_code = "email_code_account_deletion_rate_limit_exceeded" + description = "Too many account deletion emails have been sent. Please try again in 5 minutes." + code = 429 diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index f704783cfff56b..1036f1284444bc 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -21,6 +21,7 @@ from libs.login import login_required from models import AccountIntegrate, InvitationCode from services.account_service import AccountService +from services.billing_service import BillingService from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError @@ -242,6 +243,58 @@ def get(self): return {"data": integrate_data} +class AccountDeleteVerifyApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + account = current_user + + try: + token, code = AccountService.generate_account_deletion_verification_code(account) + AccountService.send_account_delete_verification_email(account, code) + except Exception as e: + return {"result": "fail", "error": str(e)}, 429 + + return {"result": "success", "data": token} + + +class AccountDeleteApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self): + account = current_user + + parser = reqparse.RequestParser() + parser.add_argument("token", type=str, required=True, location="json") + parser.add_argument("code", type=str, required=True, location="json") + parser.add_argument("reason", type=str, required=True, location="json") + args = parser.parse_args() + + if not AccountService.verify_account_deletion_code(args["token"], args["code"]): + raise ValueError("Invalid verification code.") + + AccountService.delete_account(account, args["reason"]) + + return {"result": "success"} + + +class AccountDeleleUpdateFeedbackApi(Resource): + @setup_required + def post(self): + account = current_user + + parser = reqparse.RequestParser() + parser.add_argument("email", type=str, required=True, location="json") + parser.add_argument("feedback", type=str, required=True, location="json") + args = parser.parse_args() + + BillingService.update_account_deletion_feedback(args["email"], args["feedback"]) + + return {"result": "success"} + + # Register API resources api.add_resource(AccountInitApi, "/account/init") api.add_resource(AccountProfileApi, "/account/profile") @@ -252,5 +305,8 @@ def get(self): api.add_resource(AccountTimezoneApi, "/account/timezone") api.add_resource(AccountPasswordApi, "/account/password") api.add_resource(AccountIntegrateApi, "/account/integrates") +api.add_resource(AccountDeleteVerifyApi, "/account/delete/verify") +api.add_resource(AccountDeleteApi, "/account/delete") +api.add_resource(AccountDeleleUpdateFeedbackApi, "/account/delete/feedback") # api.add_resource(AccountEmailApi, '/account/email') # api.add_resource(AccountEmailVerifyApi, '/account/email-verify') diff --git a/api/services/account_service.py b/api/services/account_service.py index 22b54a3ab87473..099fec94d63592 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -17,7 +17,7 @@ from events.tenant_event import tenant_was_created from extensions.ext_database import db from extensions.ext_redis import redis_client -from libs.helper import RateLimiter, TokenManager +from libs.helper import RateLimiter, TokenManager, generate_string from libs.passport import PassportService from libs.password import compare_password, hash_password, valid_password from libs.rsa import generate_key_pair @@ -32,6 +32,7 @@ TenantStatus, ) from models.model import DifySetup +from services.billing_service import BillingService from services.errors.account import ( AccountAlreadyInTenantError, AccountLoginError, @@ -50,6 +51,8 @@ ) from services.errors.workspace import WorkSpaceNotAllowedCreateError from services.feature_service import FeatureService +from tasks.delete_account_task import delete_account_task +from tasks.mail_account_deletion_task import send_account_deletion_verification_code 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 @@ -70,6 +73,9 @@ class AccountService: 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 @@ -201,6 +207,12 @@ def create_account( from controllers.console.error import AccountNotFound raise AccountNotFound() + + if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email): + raise AccountRegisterError( + "Unable to re-register the account because the deletion occurred less than 30 days ago" + ) + account = Account() account.email = email account.name = name @@ -240,6 +252,42 @@ def create_account_and_tenant( return account + @staticmethod + def generate_account_deletion_verification_code(account: Account) -> tuple[str, str]: + code = generate_string(6) + token = TokenManager.generate_token( + account=account, token_type="account_deletion", additional_data={"code": code} + ) + return token, code + + @classmethod + def send_account_deletion_verification_email(cls, account: Account, code: str): + language, email = account.interface_language, account.email + if cls.email_code_account_deletion_rate_limiter.is_rate_limited(email): + from controllers.console.auth.error import EmailCodeAccountDeletionRateLimitExceededError + + raise EmailCodeAccountDeletionRateLimitExceededError() + + send_account_deletion_verification_code.delay(language=language, to=email, code=code) + + cls.email_code_account_deletion_rate_limiter.increment_rate_limit(email) + + @staticmethod + def verify_account_deletion_code(token: str, code: str) -> bool: + token_data = TokenManager.get_token_data(token, "account_deletion") + if token_data is None: + return False + + if token_data["code"] != code: + return False + + return True + + @staticmethod + def delete_account(account: Account, reason="") -> None: + """Delete account. This method only adds a task to the queue for deletion.""" + delete_account_task.delay(account.id, reason) + @staticmethod def link_account_integrate(provider: str, open_id: str, account: Account) -> None: """Link account integrate""" @@ -373,6 +421,11 @@ def revoke_reset_password_token(cls, token: str): def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]: return TokenManager.get_token_data(token, "reset_password") + @classmethod + def send_account_delete_verification_email(cls, account: Account, code: str): + language, email = account.interface_language, account.email + send_account_deletion_verification_code.delay(language=language, to=email, code=code) + @classmethod def send_email_code_login_email( cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US" @@ -796,6 +849,10 @@ def register( ) -> Account: db.session.begin_nested() """Register account""" + if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email): + raise AccountRegisterError( + "Unable to re-register the account because the deletion occurred less than 30 days ago" + ) try: account = AccountService.create_account( email=email, diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 911d2346415ce5..5d8494e9e61fd4 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -59,3 +59,24 @@ def is_tenant_owner_or_admin(current_user): if not TenantAccountRole.is_privileged_role(join.role): raise ValueError("Only team owner or team admin can perform this action") + + @classmethod + def delete_account(cls, account_id: str, reason: str): + """Delete account.""" + params = {"account_id": account_id, "reason": reason} + return cls._send_request("DELETE", "/account/", params=params) + + @classmethod + def is_email_in_freeze(cls, email: str) -> bool: + params = {"email": email} + try: + response = cls._send_request("GET", "/account/in-freeze", params=params) + return bool(response.get("data", False)) + except Exception: + return False + + @classmethod + def update_account_deletion_feedback(cls, email: str, feedback: str): + """Update account deletion feedback.""" + json = {"email": email, "feedback": feedback} + return cls._send_request("POST", "/account/deletion-feedback", json=json) diff --git a/api/tasks/delete_account_task.py b/api/tasks/delete_account_task.py new file mode 100644 index 00000000000000..d005e1178f9eb7 --- /dev/null +++ b/api/tasks/delete_account_task.py @@ -0,0 +1,26 @@ +import logging + +from celery import shared_task # type: ignore + +from extensions.ext_database import db +from models.account import Account +from services.billing_service import BillingService +from tasks.mail_account_deletion_task import send_deletion_success_task + +logger = logging.getLogger(__name__) + + +@shared_task(queue="dataset") +def delete_account_task(account_id, reason: str): + account = db.session.query(Account).filter(Account.id == account_id).first() + try: + BillingService.delete_account(account_id, reason) + except Exception as e: + logger.exception(f"Failed to delete account {account_id} from billing service.") + raise + + if not account: + logger.error(f"Account {account_id} not found.") + return + # send success email + send_deletion_success_task.delay(account.interface_language, account.email) diff --git a/api/tasks/mail_account_deletion_task.py b/api/tasks/mail_account_deletion_task.py new file mode 100644 index 00000000000000..8d1434dc55098e --- /dev/null +++ b/api/tasks/mail_account_deletion_task.py @@ -0,0 +1,82 @@ +import logging +import time + +import click +from celery import shared_task # type: ignore +from flask import render_template + +from extensions.ext_mail import mail + + +@shared_task(queue="mail") +def send_deletion_success_task(language, to): + """Send email to user regarding account deletion. + + Args: + log (AccountDeletionLog): Account deletion log object + """ + if not mail.is_inited(): + return + + logging.info(click.style(f"Start send account deletion success email to {to}", fg="green")) + start_at = time.perf_counter() + + try: + if language == "zh-Hans": + html_content = render_template( + "delete_account_success_template_zh-CN.html", + to=to, + email=to, + ) + mail.send(to=to, subject="Dify 账户删除成功", html=html_content) + else: + html_content = render_template( + "delete_account_success_template_en-US.html", + to=to, + email=to, + ) + mail.send(to=to, subject="Dify Account Deleted", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send account deletion success email to {}: latency: {}".format(to, end_at - start_at), fg="green" + ) + ) + except Exception: + logging.exception("Send account deletion success email to {} failed".format(to)) + + +@shared_task(queue="mail") +def send_account_deletion_verification_code(language, to, code): + """Send email to user regarding account deletion verification code. + + Args: + to (str): Recipient email address + code (str): Verification code + """ + if not mail.is_inited(): + return + + logging.info(click.style(f"Start send account deletion verification code email to {to}", fg="green")) + start_at = time.perf_counter() + + try: + if language == "zh-Hans": + html_content = render_template("delete_account_code_email_template_zh-CN.html", to=to, code=code) + mail.send(to=to, subject="Dify 的删除账户验证码", html=html_content) + else: + html_content = render_template("delete_account_code_email_en-US.html", to=to, code=code) + mail.send(to=to, subject="Delete Your Dify Account", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send account deletion verification code email to {} succeeded: latency: {}".format( + to, end_at - start_at + ), + fg="green", + ) + ) + except Exception: + logging.exception("Send account deletion verification code email to {} failed".format(to)) diff --git a/api/templates/delete_account_code_email_en-US.html b/api/templates/delete_account_code_email_en-US.html new file mode 100644 index 00000000000000..b7f236be3c2da1 --- /dev/null +++ b/api/templates/delete_account_code_email_en-US.html @@ -0,0 +1,74 @@ + + + + + + +
+
+ + Dify Logo +
+

Delete your Dify account

+

Copy and paste this code, this code will only be valid for the next 5 minutes.

+
+ {{code}} +
+

If you didn't request, don't worry. You can safely ignore this email.

+
+ + diff --git a/api/templates/delete_account_code_email_template_zh-CN.html b/api/templates/delete_account_code_email_template_zh-CN.html new file mode 100644 index 00000000000000..5a1649402cefcb --- /dev/null +++ b/api/templates/delete_account_code_email_template_zh-CN.html @@ -0,0 +1,74 @@ + + + + + + +
+
+ + Dify Logo +
+

Dify 的删除账户验证码

+

复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。

+
+ {{code}} +
+

如果您没有请求删除账户,请不要担心。您可以安全地忽略此电子邮件。

+
+ + diff --git a/api/templates/delete_account_success_template_en-US.html b/api/templates/delete_account_success_template_en-US.html new file mode 100644 index 00000000000000..d10a913da685d2 --- /dev/null +++ b/api/templates/delete_account_success_template_en-US.html @@ -0,0 +1,76 @@ + + + + + + +
+
+ + Dify Logo +
+

Your account has finished deleting

+

You recently deleted your Dify account. It's done processing and is now deleted. As a reminder, you no longer have access to all the workspaces you used to be in with this account.

+
+ + diff --git a/api/templates/delete_account_success_template_zh-CN.html b/api/templates/delete_account_success_template_zh-CN.html new file mode 100644 index 00000000000000..7381dba81c19d1 --- /dev/null +++ b/api/templates/delete_account_success_template_zh-CN.html @@ -0,0 +1,77 @@ + + + + + + +
+
+ + Dify Logo +
+

您的账户已删除

+

您最近删除了您的 Dify 账户 。系统已完成处理,现在此账户已被删除。请注意,您不再有权访问此帐户曾经所在的所有空间。

+
+ +