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: implement forgot password feature #5534

Merged
merged 14 commits into from
Jul 5, 2024
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,5 @@ sdks/python-client/dify_client.egg-info
.vscode/*
!.vscode/launch.json
pyrightconfig.json

.idea/
4 changes: 4 additions & 0 deletions api/configs/feature/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ class SecurityConfig(BaseModel):
default=None,
)

RESET_TOKEN_EXPIRY_HOURS: PositiveInt = Field(
description='Expiry time in hours for reset token',
default=24,
)

class AppExecutionConfig(BaseModel):
"""
Expand Down
2 changes: 1 addition & 1 deletion api/controllers/console/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
)

# Import auth controllers
from .auth import activate, data_source_bearer_auth, data_source_oauth, login, oauth
from .auth import activate, data_source_bearer_auth, data_source_oauth, forgot_password, login, oauth

# Import billing controllers
from .billing import billing
Expand Down
24 changes: 24 additions & 0 deletions api/controllers/console/auth/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,27 @@ class ApiKeyAuthFailedError(BaseHTTPException):
error_code = 'auth_failed'
description = "{message}"
code = 500


class InvalidEmailError(BaseHTTPException):
error_code = 'invalid_email'
description = "The email address is not valid."
code = 400


class EmailNotRegisteredError(BaseHTTPException):
error_code = 'email_not_registered'
description = "Sorry, the email address is not registered."
code = 400


class PasswordMismatchError(BaseHTTPException):
error_code = 'password_mismatch'
description = "The passwords do not match."
code = 400


class InvalidTokenError(BaseHTTPException):
error_code = 'invalid_or_expired_token'
description = "The token is invalid or has expired."
code = 400
99 changes: 99 additions & 0 deletions api/controllers/console/auth/forgot_password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import base64
import secrets

from flask_restful import Resource, reqparse

from controllers.console import api
from controllers.console.auth.error import (
EmailNotRegisteredError,
InvalidEmailError,
InvalidTokenError,
PasswordMismatchError,
)
from controllers.console.setup import setup_required
from extensions.ext_database import db
from libs.helper import email as email_validate
from libs.password import hash_password, valid_password
from models.account import Account
from services.account_service import AccountService


class ForgotPasswordSendEmailApi(Resource):

@setup_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('email', type=str, required=True, location='json')
args = parser.parse_args()

email = args['email']

if not email_validate(email):
raise InvalidEmailError()

account = Account.query.filter_by(email=email).first()

if account:
AccountService.send_reset_password_email(account=account)
return {"result": "success"}
else:
raise EmailNotRegisteredError()


class ForgotPasswordCheckApi(Resource):

@setup_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('token', type=str, required=True, nullable=False, location='json')
args = parser.parse_args()
token = args['token']

reset_data = AccountService.get_reset_data(token)

if reset_data is None:
return {'is_valid': False, 'email': None}
return {'is_valid': True, 'email': reset_data.get('email')}


class ForgotPasswordResetApi(Resource):

@setup_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('token', type=str, required=True, nullable=False, location='json')
parser.add_argument('new_password', type=valid_password, required=True, nullable=False, location='json')
parser.add_argument('password_confirm', type=valid_password, required=True, nullable=False, location='json')
args = parser.parse_args()

new_password = args['new_password']
password_confirm = args['password_confirm']

if str(new_password).strip() != str(password_confirm).strip():
raise PasswordMismatchError()

token = args['token']
reset_data = AccountService.get_reset_data(token)

if reset_data is None:
raise InvalidTokenError()

AccountService.revoke_reset_token(token)

salt = secrets.token_bytes(16)
base64_salt = base64.b64encode(salt).decode()

password_hashed = hash_password(new_password, salt)
base64_password_hashed = base64.b64encode(password_hashed).decode()

account = Account.query.filter_by(email=reset_data.get('email')).first()
account.password = base64_password_hashed
account.password_salt = base64_salt
db.session.commit()

return {'result': 'success'}


api.add_resource(ForgotPasswordSendEmailApi, '/forgot-password')
api.add_resource(ForgotPasswordCheckApi, '/forgot-password/validity')
api.add_resource(ForgotPasswordResetApi, '/forgot-password/resets')
2 changes: 2 additions & 0 deletions api/controllers/console/workspace/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ def get(self):
return {'data': integrate_data}




# Register API resources
api.add_resource(AccountInitApi, '/account/init')
api.add_resource(AccountProfileApi, '/account/profile')
Expand Down
43 changes: 43 additions & 0 deletions api/services/account_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
TenantNotFound,
)
from tasks.mail_invite_member_task import send_invite_member_mail_task
from tasks.mail_reset_password_task import send_reset_password_mail_task


class AccountService:
Expand Down Expand Up @@ -222,6 +223,48 @@ def load_logged_in_account(*, account_id: str, token: str):
return None
return AccountService.load_user(account_id)

@classmethod
def send_reset_password_email(cls, account: Account):
token = cls.generate_reset_token(account)
send_reset_password_mail_task.delay(
language=account.interface_language,
to=account.email,
token=token
)
return token

@classmethod
def generate_reset_token(cls, account: Account) -> str:
token = str(uuid.uuid4())
reset_data = {
'account_id': account.id,
'email': account.email,
}
expiryHours = current_app.config['RESET_TOKEN_EXPIRY_HOURS']
redis_client.setex(
cls._get_reset_token_key(token),
expiryHours * 60 * 60,
json.dumps(reset_data)
)
return token

@classmethod
def _get_reset_token_key(cls, token: str) -> str:
return f'reset_password:token:{token}'

@classmethod
def revoke_reset_token(cls, token: str):
redis_client.delete(cls._get_reset_token_key(token))

@classmethod
def get_reset_data(cls, token: str) -> Optional[dict[str, Any]]:
key = cls._get_reset_token_key(token)
reset_data_json = redis_client.get(key)
if reset_data_json is None:
return None
reset_data = json.loads(reset_data_json)
return reset_data

def _get_login_cache_key(*, account_id: str, token: str):
return f"account_login:{account_id}:{token}"

Expand Down
11 changes: 5 additions & 6 deletions api/tasks/mail_invite_member_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,15 @@ def send_invite_member_mail_task(language: str, to: str, token: str, inviter_nam
mail.send(to=to, subject="立即加入 Dify 工作空间", html=html_content)
else:
html_content = render_template('invite_member_mail_template_en-US.html',
to=to,
inviter_name=inviter_name,
workspace_name=workspace_name,
url=url)
to=to,
inviter_name=inviter_name,
workspace_name=workspace_name,
url=url)
mail.send(to=to, subject="Join Dify Workspace Now", html=html_content)


end_at = time.perf_counter()
logging.info(
click.style('Send invite member mail to {} succeeded: latency: {}'.format(to, end_at - start_at),
fg='green'))
except Exception:
logging.exception("Send invite member mail to {} failed".format(to))
logging.exception("Send invite member mail to {} failed".format(to))
48 changes: 48 additions & 0 deletions api/tasks/mail_reset_password_task.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import logging
import time

import click
from celery import shared_task
from flask import current_app, render_template

from extensions.ext_mail import mail


@shared_task(queue='mail')
def send_reset_password_mail_task(language: str, to: str, token: str):
"""
Async Send reset password mail
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
:param to: Recipient email address
:param token: Reset password token to be included in the email
:param user_name: Name of the user who requested the password reset
:param reset_link: Link to the password reset page

Usage: send_reset_password_mail_task.delay(language, to, token, user_name, reset_link)
"""
if not mail.is_inited():
return

logging.info(click.style('Start password reset mail to {}'.format(to), fg='green'))
start_at = time.perf_counter()

# send invite member mail using different languages
try:
url = f'{current_app.config.get("CONSOLE_WEB_URL")}/forgot-password?token={token}'
if language == 'zh-Hans':
html_content = render_template('reset_password_mail_template_zh-CN.html',
to=to,
url=url)
mail.send(to=to, subject="重置您的 Dify 密码", html=html_content)
else:
html_content = render_template('reset_password_mail_template_en-US.html',
to=to,
url=url)
mail.send(to=to, subject="Reset Your Dify Password", html=html_content)

end_at = time.perf_counter()
logging.info(
click.style('Send password reset mail to {} succeeded: latency: {}'.format(to, end_at - start_at),
fg='green'))
except Exception:
logging.exception("Send password reset mail to {} failed".format(to))
72 changes: 72 additions & 0 deletions api/templates/reset_password_mail_template_en-US.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 560px;
margin: 40px auto;
padding: 20px;
background-color: #F3F4F6;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header img {
max-width: 100px;
height: auto;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #2970FF;
color: white;
text-decoration: none;
border-radius: 4px;
text-align: center;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #265DD4;
}
.footer {
font-size: 0.9em;
color: #777777;
margin-top: 30px;
}
.content {
margin-top: 20px;
}
</style>
</head>

<body>
<div class="container">
<div class="header">
<img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo">
</div>
<div class="content">
<p>Dear {{ to }},</p>
<p>We have received a request to reset your password. If you initiated this request, please click the button below to reset your password:</p>
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Reset Password</a></p>
<p>If you did not request a password reset, please ignore this email and your account will remain secure.</p>
</div>
<div class="footer">
<p>Best regards,</p>
<p>Dify Team</p>
<p>Please do not reply directly to this email; it is automatically sent by the system.</p>
</div>
</div>
</body>
</html>
Loading