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

Merged
merged 55 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
11e7ecb
feat: account delete
GareArc Dec 19, 2024
cbd8045
fix: use get method for verification code
GareArc Dec 19, 2024
4fde7a5
feat: add rate limiter
GareArc Dec 21, 2024
ed2e260
feat: add migration
GareArc Dec 21, 2024
b7e8e74
update
GareArc Dec 21, 2024
5e25799
fix: token wrong position
GareArc Dec 21, 2024
ff5b846
fix: params of celery function should be serializable
GareArc Dec 21, 2024
7b49e74
fix: db session error
GareArc Dec 21, 2024
e1b0903
fix: refactor task
GareArc Dec 22, 2024
d31161d
minor fix
GareArc Dec 22, 2024
0dc870a
feat: add email templates
douxc Dec 23, 2024
341cc22
fix: update emial template style
douxc Dec 23, 2024
c016c44
fix: add detailed deletion log
GareArc Dec 23, 2024
40653f4
fix: migration
GareArc Dec 23, 2024
bf7d30f
fix: remove custom json serializer
GareArc Dec 23, 2024
be1b7bc
fix: serializer
GareArc Dec 23, 2024
bdfc41d
fix: bad import
GareArc Dec 23, 2024
4254c4d
fix: add email check in register service
GareArc Dec 23, 2024
ee52a74
reformat
GareArc Dec 23, 2024
fa7393d
fix: rebase migration head
GareArc Dec 23, 2024
968ee8c
fix: remove deletion logic
GareArc Dec 24, 2024
6caf17f
fix: delete migration and config
GareArc Dec 24, 2024
8275a0f
reformat
GareArc Dec 24, 2024
4cf190f
fix wrong import
GareArc Dec 24, 2024
8b453f8
fix: change celery queue name
GareArc Dec 25, 2024
3492f15
fix: type check
GareArc Dec 25, 2024
65eed9a
fix: bugs
GareArc Dec 25, 2024
d313a71
fix: ignore type for celey in mypy
GareArc Dec 25, 2024
f65fa9b
reformat
GareArc Dec 25, 2024
7c2d43f
feat: add seperate feedback api
GareArc Dec 25, 2024
a1205ea
fix: change task queue
GareArc Dec 25, 2024
018e932
fix: wrong api route
GareArc Dec 25, 2024
e96a6e6
fix: bug
GareArc Dec 25, 2024
a37ac27
reformat
GareArc Dec 25, 2024
e768fb2
fix: update api
GareArc Dec 25, 2024
a9c0bc6
fix: add logic for blocking login when email is in freeze
GareArc Dec 26, 2024
99a0178
reformat
GareArc Dec 26, 2024
e6bf5e4
reformat
GareArc Dec 26, 2024
aef4e98
fix: wrong error object
GareArc Dec 26, 2024
6f02bad
reformat
GareArc Dec 26, 2024
271dd9d
fix: error handling
GareArc Dec 26, 2024
056c95f
fix: error description
GareArc Dec 26, 2024
cc79543
fix: reject before sending email
GareArc Dec 26, 2024
b5b989b
fix: block login if email in freeze
GareArc Dec 26, 2024
3c2d401
fix: wrong email value
GareArc Dec 26, 2024
e127283
fix: update email template
douxc Dec 26, 2024
ae0aee5
fix: update email workflow
GareArc Dec 26, 2024
f008c26
fix: remove redundant error description
GareArc Dec 26, 2024
7ac4113
fix: update delete account email template style
douxc Dec 26, 2024
d45958a
fix: bug
GareArc Dec 26, 2024
79c96aa
fix: update delete account email template style
douxc Dec 26, 2024
f0c30ea
fix: update delete account email template style
douxc Dec 26, 2024
04cb7bf
fix: add account register error message
GareArc Dec 26, 2024
0169c6d
fix: use only numbers for verification code
GareArc Dec 27, 2024
30c17e5
fix: wrong api url
GareArc Dec 27, 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 @@ -765,6 +765,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 @@ -792,6 +799,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
12 changes: 5 additions & 7 deletions api/controllers/console/auth/forgot_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,16 @@

from constants.languages import languages
from controllers.console import api
from controllers.console.auth.error import (
EmailCodeError,
InvalidEmailError,
InvalidTokenError,
PasswordMismatchError,
)
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError, PasswordMismatchError
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
from controllers.console.wraps import setup_required
from events.tenant_event import tenant_was_created
from extensions.ext_database import db
from libs.helper import email, extract_remote_ip
from libs.password import hash_password, valid_password
from models.account import Account
from services.account_service import AccountService, TenantService
from services.errors.account import AccountRegisterError
from services.errors.workspace import WorkSpaceNotAllowedCreateError
from services.feature_service import FeatureService

Expand Down Expand Up @@ -129,6 +125,8 @@ def post(self):
)
except WorkSpaceNotAllowedCreateError:
pass
except AccountRegisterError as are:
raise AccountInFreezeError()

return {"result": "success"}

Expand Down
25 changes: 21 additions & 4 deletions api/controllers/console/auth/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from flask_restful import Resource, reqparse # type: ignore

import services
from configs import dify_config
from constants.languages import languages
from controllers.console import api
from controllers.console.auth.error import (
Expand All @@ -16,6 +17,7 @@
)
from controllers.console.error import (
AccountBannedError,
AccountInFreezeError,
AccountNotFound,
EmailSendIpLimitError,
NotAllowedCreateWorkspace,
Expand All @@ -26,6 +28,8 @@
from libs.password import valid_password
from models.account import Account
from services.account_service import AccountService, RegisterService, TenantService
from services.billing_service import BillingService
from services.errors.account import AccountRegisterError
from services.errors.workspace import WorkSpaceNotAllowedCreateError
from services.feature_service import FeatureService

Expand All @@ -44,6 +48,9 @@ def post(self):
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()
Expand Down Expand Up @@ -113,8 +120,10 @@ def post(self):
language = "zh-Hans"
else:
language = "en-US"

account = AccountService.get_user_through_email(args["email"])
try:
account = AccountService.get_user_through_email(args["email"])
except AccountRegisterError as are:
raise AccountInFreezeError()
if account is None:
if FeatureService.get_system_features().is_allow_register:
token = AccountService.send_reset_password_email(email=args["email"], language=language)
Expand Down Expand Up @@ -142,8 +151,11 @@ def post(self):
language = "zh-Hans"
else:
language = "en-US"
try:
account = AccountService.get_user_through_email(args["email"])
except AccountRegisterError as are:
raise AccountInFreezeError()

account = AccountService.get_user_through_email(args["email"])
if account is None:
if FeatureService.get_system_features().is_allow_register:
token = AccountService.send_email_code_login_email(email=args["email"], language=language)
Expand Down Expand Up @@ -177,7 +189,10 @@ def post(self):
raise EmailCodeError()

AccountService.revoke_email_code_login_token(args["token"])
account = AccountService.get_user_through_email(user_email)
try:
account = AccountService.get_user_through_email(user_email)
except AccountRegisterError as are:
raise AccountInFreezeError()
if account:
tenant = TenantService.get_join_tenants(account)
if not tenant:
Expand All @@ -196,6 +211,8 @@ def post(self):
)
except WorkSpaceNotAllowedCreateError:
return NotAllowedCreateWorkspace()
except AccountRegisterError as are:
raise AccountInFreezeError()
token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(args["email"])
return {"result": "success", "data": token_pair.model_dump()}
Expand Down
4 changes: 3 additions & 1 deletion api/controllers/console/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from models import Account
from models.account import AccountStatus
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import AccountNotFoundError
from services.errors.account import AccountNotFoundError, AccountRegisterError
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
from services.feature_service import FeatureService

Expand Down Expand Up @@ -99,6 +99,8 @@ def get(self, provider: str):
f"{dify_config.CONSOLE_WEB_URL}/signin"
"?message=Workspace not found, please contact system admin to invite you to join in a workspace."
)
except AccountRegisterError as e:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={e.description}")

# Check account status
if account.status == AccountStatus.BANNED.value:
Expand Down
9 changes: 9 additions & 0 deletions api/controllers/console/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,12 @@ class UnauthorizedAndForceLogout(BaseHTTPException):
error_code = "unauthorized_and_force_logout"
description = "Unauthorized and force logout."
code = 401


class AccountInFreezeError(BaseHTTPException):
error_code = "account_in_freeze"
code = 400
description = (
"This email account has been deleted within the past 30 days"
"and is temporarily unavailable for new account registration."
)
53 changes: 53 additions & 0 deletions api/controllers/console/workspace/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from controllers.console.workspace.error import (
AccountAlreadyInitedError,
CurrentPasswordIncorrectError,
InvalidAccountDeletionCodeError,
InvalidInvitationCodeError,
RepeatPasswordNotMatchError,
)
Expand All @@ -21,6 +22,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


Expand Down Expand Up @@ -242,6 +244,54 @@ def get(self):
return {"data": integrate_data}


class AccountDeleteVerifyApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
account = current_user

token, code = AccountService.generate_account_deletion_verification_code(account)
AccountService.send_account_deletion_verification_email(account, code)

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")
args = parser.parse_args()

if not AccountService.verify_account_deletion_code(args["token"], args["code"]):
raise InvalidAccountDeletionCodeError()

AccountService.delete_account(account)

return {"result": "success"}


class AccountDeleteUpdateFeedbackApi(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")
Expand All @@ -252,5 +302,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(AccountDeleteUpdateFeedbackApi, "/account/delete/feedback")
# api.add_resource(AccountEmailApi, '/account/email')
# api.add_resource(AccountEmailVerifyApi, '/account/email-verify')
6 changes: 6 additions & 0 deletions api/controllers/console/workspace/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ class AccountNotInitializedError(BaseHTTPException):
error_code = "account_not_initialized"
description = "The account has not been initialized yet. Please proceed with the initialization process first."
code = 400


class InvalidAccountDeletionCodeError(BaseHTTPException):
error_code = "invalid_account_deletion_code"
description = "Invalid account deletion code."
code = 400
64 changes: 64 additions & 0 deletions api/services/account_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,15 @@ 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(
description=(
"This email account has been deleted within the past "
"30 days and is temporarily unavailable for new account registration"
)
)

account = Account()
account.email = email
account.name = name
Expand Down Expand Up @@ -240,6 +255,42 @@ def create_account_and_tenant(

return account

@staticmethod
def generate_account_deletion_verification_code(account: Account) -> tuple[str, str]:
code = "".join([str(random.randint(0, 9)) for _ in range(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):
email = 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(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) -> None:
"""Delete account. This method only adds a task to the queue for deletion."""
delete_account_task.delay(account.id)

@staticmethod
def link_account_integrate(provider: str, open_id: str, account: Account) -> None:
"""Link account integrate"""
Expand Down Expand Up @@ -379,6 +430,7 @@ def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]:
def send_email_code_login_email(
cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US"
):
email = account.email if account else email
if email is None:
raise ValueError("Email must be provided.")
if cls.email_code_login_rate_limiter.is_rate_limited(email):
Expand Down Expand Up @@ -408,6 +460,14 @@ def revoke_email_code_login_token(cls, token: str):

@classmethod
def get_user_through_email(cls, email: str):
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
"30 days and is temporarily unavailable for new account registration"
)
)

account = db.session.query(Account).filter(Account.email == email).first()
if not account:
return None
Expand Down Expand Up @@ -824,6 +884,10 @@ def register(
db.session.commit()
except WorkSpaceNotAllowedCreateError:
db.session.rollback()
except AccountRegisterError as are:
db.session.rollback()
logging.exception("Register failed")
raise are
except Exception as e:
db.session.rollback()
logging.exception("Register failed")
Expand Down
Loading
Loading