Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: account delete #11829

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
571495c
feat: account delete
GareArc Dec 19, 2024
db66860
fix: use get method for verification code
GareArc Dec 19, 2024
cffcca3
feat: add rate limiter
GareArc Dec 21, 2024
56fe09e
feat: add migration
GareArc Dec 21, 2024
5647fe7
update
GareArc Dec 21, 2024
f7ca0a2
fix: token wrong position
GareArc Dec 21, 2024
4f39322
fix: params of celery function should be serializable
GareArc Dec 21, 2024
4be59e1
fix: db session error
GareArc Dec 21, 2024
ed97f76
fix: refactor task
GareArc Dec 22, 2024
3ee1de6
minor fix
GareArc Dec 22, 2024
424c7ee
feat: add email templates
douxc Dec 23, 2024
cd437ef
fix: update emial template style
douxc Dec 23, 2024
6d93a09
fix: add detailed deletion log
GareArc Dec 23, 2024
3c090e7
fix: migration
GareArc Dec 23, 2024
a4921c1
fix: remove custom json serializer
GareArc Dec 23, 2024
577eb8f
fix: serializer
GareArc Dec 23, 2024
44c2bfb
fix: bad import
GareArc Dec 23, 2024
d9687ee
fix: add email check in register service
GareArc Dec 23, 2024
d9bba39
reformat
GareArc Dec 23, 2024
ab4afa5
fix: rebase migration head
GareArc Dec 23, 2024
356c3d5
fix: remove deletion logic
GareArc Dec 24, 2024
ec5f312
fix: delete migration and config
GareArc Dec 24, 2024
f475431
reformat
GareArc Dec 24, 2024
5bf4431
fix wrong import
GareArc Dec 24, 2024
ec1c126
fix: change celery queue name
GareArc Dec 25, 2024
53e1127
fix: type check
GareArc Dec 25, 2024
5f4bb97
fix: bugs
GareArc Dec 25, 2024
c99254d
fix: ignore type for celey in mypy
GareArc Dec 25, 2024
3a62679
reformat
GareArc Dec 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api/configs/feature/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -794,6 +801,7 @@ class FeatureConfig(
WorkflowNodeExecutionConfig,
WorkspaceConfig,
LoginConfig,
AccountConfig,
# hosted services config
HostedServiceConfig,
CeleryBeatConfig,
Expand Down
6 changes: 6 additions & 0 deletions api/controllers/console/auth/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 39 additions & 0 deletions api/controllers/console/workspace/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,43 @@ 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"]):
return {"result": "fail", "error": "Verification code is invalid."}, 400

AccountService.delete_account(account, args["reason"])

return {"result": "success"}


# Register API resources
api.add_resource(AccountInitApi, "/account/init")
api.add_resource(AccountProfileApi, "/account/profile")
Expand All @@ -252,5 +289,7 @@ 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(AccountEmailApi, '/account/email')
# api.add_resource(AccountEmailVerifyApi, '/account/email-verify')
59 changes: 58 additions & 1 deletion api/services/account_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +32,7 @@
TenantStatus,
)
from models.model import DifySetup
from services.billing_service import BillingService
from services.errors.account import (
AccountAlreadyInTenantError,
AccountLoginError,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions api/services/billing_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,18 @@ 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
26 changes: 26 additions & 0 deletions api/tasks/delete_account_task.py
Original file line number Diff line number Diff line change
@@ -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="account_deletion")
def delete_account_task(account_id, reason: str):
try:
BillingService.delete_account(account_id, reason)
except Exception as e:
logger.exception(f"Failed to delete account {account_id} from billing service.")
raise

account = db.session.query(Account).filter(Account.id == account_id).first()
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)
82 changes: 82 additions & 0 deletions api/tasks/mail_account_deletion_task.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading