add change email api

pull/22333/head
Yansong Zhang 11 months ago
parent 6029f24ebe
commit a8d92e59d2

@ -477,6 +477,7 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
# Reset password token expiry minutes # Reset password token expiry minutes
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
CREATE_TIDB_SERVICE_JOB_ENABLED=false CREATE_TIDB_SERVICE_JOB_ENABLED=false

@ -31,6 +31,10 @@ class SecurityConfig(BaseSettings):
description="Duration in minutes for which a password reset token remains valid", description="Duration in minutes for which a password reset token remains valid",
default=5, default=5,
) )
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: PositiveInt = Field(
description="Duration in minutes for which a change email token remains valid",
default=5,
)
LOGIN_DISABLED: bool = Field( LOGIN_DISABLED: bool = Field(
description="Whether to disable login checks", description="Whether to disable login checks",
@ -580,6 +584,11 @@ class AuthConfig(BaseSettings):
default=86400, default=86400,
) )
CHANGE_EMAIL_LOCKOUT_DURATION: PositiveInt = Field(
description="Time (in seconds) a user must wait before retrying change email after exceeding the rate limit.",
default=86400,
)
class ModerationConfig(BaseSettings): class ModerationConfig(BaseSettings):
""" """

@ -30,6 +30,10 @@ class PasswordResetRateLimitExceededError(BaseHTTPException):
description = "Too many password reset emails have been sent. Please try again in 1 minutes." description = "Too many password reset emails have been sent. Please try again in 1 minutes."
code = 429 code = 429
class EmailChangeRateLimitExceededError(BaseHTTPException):
error_code = "email_change_rate_limit_exceeded"
description = "Too many email change emails have been sent. Please try again in 1 minutes."
code = 429
class EmailCodeError(BaseHTTPException): class EmailCodeError(BaseHTTPException):
error_code = "email_code_error" error_code = "email_code_error"
@ -65,3 +69,8 @@ class EmailPasswordResetLimitError(BaseHTTPException):
error_code = "email_password_reset_limit" error_code = "email_password_reset_limit"
description = "Too many failed password reset attempts. Please try again in 24 hours." description = "Too many failed password reset attempts. Please try again in 24 hours."
code = 429 code = 429
class EmailChangeLimitError(BaseHTTPException):
error_code = "email_change_limit"
description = "Too many failed email change attempts. Please try again in 24 hours."
code = 429

@ -235,3 +235,14 @@ def email_password_login_enabled(view):
abort(403) abort(403)
return decorated return decorated
def enable_change_email(view):
@wraps(view)
def decorated(*args, **kwargs):
features = FeatureService.get_system_features()
if features.enable_change_email:
return view(*args, **kwargs)
# otherwise, return 403
abort(403)
return decorated

@ -52,6 +52,7 @@ from services.errors.workspace import WorkSpaceNotAllowedCreateError, Workspaces
from services.feature_service import FeatureService from services.feature_service import FeatureService
from tasks.delete_account_task import delete_account_task from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code from tasks.mail_account_deletion_task import send_account_deletion_verification_code
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_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_invite_member_task import send_invite_member_mail_task
from tasks.mail_reset_password_task import send_reset_password_mail_task from tasks.mail_reset_password_task import send_reset_password_mail_task
@ -75,8 +76,12 @@ class AccountService:
email_code_account_deletion_rate_limiter = RateLimiter( email_code_account_deletion_rate_limiter = RateLimiter(
prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1 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=2, time_window=60 * 1
)
LOGIN_MAX_ERROR_LIMITS = 5 LOGIN_MAX_ERROR_LIMITS = 5
FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5 FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5
CHANGE_EMAIL_MAX_ERROR_LIMITS = 5
@staticmethod @staticmethod
def _get_refresh_token_key(refresh_token: str) -> str: def _get_refresh_token_key(refresh_token: str) -> str:
@ -419,6 +424,35 @@ class AccountService:
cls.reset_password_rate_limiter.increment_rate_limit(account_email) cls.reset_password_rate_limiter.increment_rate_limit(account_email)
return token return token
@classmethod
def send_change_email_email(
cls,
account: Optional[Account] = None,
email: Optional[str] = None,
old_email: Optional[str] = None,
language: Optional[str] = "en-US",
phase: Optional[str] = None,
):
account_email = account.email if account else email
if account_email is None:
raise ValueError("Email must be provided.")
if cls.change_email_rate_limiter.is_rate_limited(account_email):
from controllers.console.auth.error import EmailChangeRateLimitExceededError
raise EmailChangeRateLimitExceededError()
code, token = cls.generate_change_email_token(account_email, account, old_email=old_email)
send_change_mail_task.delay(
language=language,
to=account_email,
code=code,
phase=phase,
)
cls.change_email_rate_limiter.increment_rate_limit(account_email)
return token
@classmethod @classmethod
def generate_reset_password_token( def generate_reset_password_token(
cls, cls,
@ -435,13 +469,39 @@ class AccountService:
) )
return code, token return code, token
@classmethod
def generate_change_email_token(
cls,
email: str,
account: Optional[Account] = None,
code: Optional[str] = None,
old_email: 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
additional_data["old_email"] = old_email
token = TokenManager.generate_token(
account=account, email=email, token_type="change_email", additional_data=additional_data
)
return code, token
@classmethod @classmethod
def revoke_reset_password_token(cls, token: str): def revoke_reset_password_token(cls, token: str):
TokenManager.revoke_token(token, "reset_password") TokenManager.revoke_token(token, "reset_password")
@classmethod
def revoke_change_email_token(cls, token: str):
TokenManager.revoke_token(token, "change_email")
@classmethod @classmethod
def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]: def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]:
return TokenManager.get_token_data(token, "reset_password") return TokenManager.get_token_data(token, "reset_password")
@classmethod
def get_change_email_data(cls, token: str) -> Optional[dict[str, Any]]:
return TokenManager.get_token_data(token, "change_email")
@classmethod @classmethod
def send_email_code_login_email( def send_email_code_login_email(
@ -545,13 +605,41 @@ class AccountService:
if count > AccountService.FORGOT_PASSWORD_MAX_ERROR_LIMITS: if count > AccountService.FORGOT_PASSWORD_MAX_ERROR_LIMITS:
return True return True
return False return False
@staticmethod @staticmethod
@redis_fallback(default_return=None) @redis_fallback(default_return=None)
def reset_forgot_password_error_rate_limit(email: str): def reset_forgot_password_error_rate_limit(email: str):
key = f"forgot_password_error_rate_limit:{email}" key = f"forgot_password_error_rate_limit:{email}"
redis_client.delete(key) redis_client.delete(key)
@staticmethod
@redis_fallback(default_return=None)
def add_change_email_error_rate_limit(email: str) -> None:
key = f"change_email_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.CHANGE_EMAIL_LOCKOUT_DURATION, count)
@staticmethod
@redis_fallback(default_return=False)
def is_change_email_error_rate_limit(email: str) -> bool:
key = f"change_email_error_rate_limit:{email}"
count = redis_client.get(key)
if count is None:
return False
count = int(count)
if count > AccountService.CHANGE_EMAIL_MAX_ERROR_LIMITS:
return True
return False
@staticmethod
@redis_fallback(default_return=None)
def reset_change_email_error_rate_limit(email: str):
key = f"change_email_error_rate_limit:{email}"
redis_client.delete(key)
@staticmethod @staticmethod
@redis_fallback(default_return=False) @redis_fallback(default_return=False)
def is_email_send_ip_limit(ip_address: str): def is_email_send_ip_limit(ip_address: str):

@ -123,7 +123,7 @@ class FeatureModel(BaseModel):
dataset_operator_enabled: bool = False dataset_operator_enabled: bool = False
webapp_copyright_enabled: bool = False webapp_copyright_enabled: bool = False
workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0) workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
allow_transfer_workspace: bool = True
# pydantic configs # pydantic configs
model_config = ConfigDict(protected_namespaces=()) model_config = ConfigDict(protected_namespaces=())
@ -149,6 +149,7 @@ class SystemFeatureModel(BaseModel):
branding: BrandingModel = BrandingModel() branding: BrandingModel = BrandingModel()
webapp_auth: WebAppAuthModel = WebAppAuthModel() webapp_auth: WebAppAuthModel = WebAppAuthModel()
plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel() plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
enable_change_email: bool = True
class FeatureService: class FeatureService:
@ -186,6 +187,7 @@ class FeatureService:
if dify_config.ENTERPRISE_ENABLED: if dify_config.ENTERPRISE_ENABLED:
system_features.branding.enabled = True system_features.branding.enabled = True
system_features.webapp_auth.enabled = True system_features.webapp_auth.enabled = True
system_features.enable_change_email = False
cls._fulfill_params_from_enterprise(system_features) cls._fulfill_params_from_enterprise(system_features)
if dify_config.MARKETPLACE_ENABLED: if dify_config.MARKETPLACE_ENABLED:
@ -228,6 +230,7 @@ class FeatureService:
if features.billing.subscription.plan != "sandbox": if features.billing.subscription.plan != "sandbox":
features.webapp_copyright_enabled = True features.webapp_copyright_enabled = True
features.allow_transfer_workspace = False
if "members" in billing_info: if "members" in billing_info:
features.members.size = billing_info["members"]["size"] features.members.size = billing_info["members"]["size"]

@ -0,0 +1,47 @@
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_change_mail_task(language: str, to: str, code: str, phase: str):
"""
Async Send change email 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 phase: Change email phase (new_email, old_email)
"""
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 phase == "old_email" :
template = "change_mail_confirm_old_template_en-US.html"
elif phase == "new_email":
template = "change_mail_confirm_new_template_en-US.html"
else:
raise ValueError("Invalid phase")
if language == "zh-Hans":
html_content = render_template(template, to=to, code=code)
mail.send(to=to, subject="检测您现在的邮箱", html=html_content)
else:
html_content = render_template(template, to=to, code=code)
mail.send(to=to, subject="Check your current email", html=html_content)
end_at = time.perf_counter()
logging.info(
click.style("Send change email mail to {} succeeded: latency: {}".format(to, end_at - start_at), fg="green")
)
except Exception:
logging.exception("Send change email mail to {} failed".format(to))

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">Confirm Your New Email Address</p>
<p class="description">Youre updating the email address linked to your Dify account.
To confirm this action, please use the verification code below.
This code will only be valid for the next 5 minutes:</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">If you didnt make this request, please ignore this email or contact support immediately.</p>
</div>
</body>
</html>

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">Verify Your Request to Change Email</p>
<p class="description">We received a request to change the email address associated with your Dify account.
To confirm this action, please use the verification code below.
This code will only be valid for the next 5 minutes:</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">If you didnt make this request, please ignore this email or contact support immediately.</p>
</div>
</body>
</html>

@ -203,6 +203,7 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
# Reset password token expiry minutes # Reset password token expiry minutes
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
CREATE_TIDB_SERVICE_JOB_ENABLED=false CREATE_TIDB_SERVICE_JOB_ENABLED=false

@ -763,6 +763,7 @@ INVITE_EXPIRY_HOURS=72
# Reset password token valid time (minutes), # Reset password token valid time (minutes),
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
# The sandbox service endpoint. # The sandbox service endpoint.
CODE_EXECUTION_ENDPOINT=http://sandbox:8194 CODE_EXECUTION_ENDPOINT=http://sandbox:8194

@ -332,6 +332,7 @@ x-shared-env: &shared-api-worker-env
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72} INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5} RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5}
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: ${CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES:-5}
CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194} CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194}
CODE_EXECUTION_API_KEY: ${CODE_EXECUTION_API_KEY:-dify-sandbox} CODE_EXECUTION_API_KEY: ${CODE_EXECUTION_API_KEY:-dify-sandbox}
CODE_MAX_NUMBER: ${CODE_MAX_NUMBER:-9223372036854775807} CODE_MAX_NUMBER: ${CODE_MAX_NUMBER:-9223372036854775807}

Loading…
Cancel
Save