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
Prev Previous commit
Next Next commit
integrate frontend and backend for forgot password email functionality
  • Loading branch information
xielong committed Jun 26, 2024
commit 75012e992528eedd16fe0ed9b0d1538d6fe2db95
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
28 changes: 27 additions & 1 deletion api/controllers/console/workspace/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@
from controllers.console.workspace.error import (
AccountAlreadyInitedError,
CurrentPasswordIncorrectError,
EmailNotRegisteredError,
InvalidEmailError,
InvalidInvitationCodeError,
RepeatPasswordNotMatchError,
)
from controllers.console.wraps import account_initialization_required
from extensions.ext_database import db
from fields.member_fields import account_fields
from libs.helper import TimestampField, timezone
from libs.helper import email as email_validate
from libs.login import login_required
from models.account import AccountIntegrate, InvitationCode
from models.account import Account, AccountIntegrate, InvitationCode
from services.account_service import AccountService
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError

Expand Down Expand Up @@ -244,6 +247,28 @@ def get(self):

return {'data': integrate_data}

class AccountForgotPasswordApi(Resource):

@setup_required
@account_initialization_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()


# Register API resources
api.add_resource(AccountInitApi, '/account/init')
Expand All @@ -257,3 +282,4 @@ def get(self):
api.add_resource(AccountIntegrateApi, '/account/integrates')
# api.add_resource(AccountEmailApi, '/account/email')
# api.add_resource(AccountEmailVerifyApi, '/account/email-verify')
api.add_resource(AccountForgotPasswordApi, '/account/forgot-password')
10 changes: 10 additions & 0 deletions api/controllers/console/workspace/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,13 @@ 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 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
30 changes: 30 additions & 0 deletions api/services/account_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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 @@ -221,6 +222,35 @@ 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}'

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

Expand Down
42 changes: 1 addition & 41 deletions api/tasks/mail_invite_member_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,44 +50,4 @@ def send_invite_member_mail_task(language: str, to: str, token: str, inviter_nam
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))


@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")}/activate?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))
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")}/activate?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))
19 changes: 9 additions & 10 deletions web/app/forgot-password/ForgotPasswordForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import Loading from '../components/base/loading'
import Button from '@/app/components/base/button'

import { fetchInitValidateStatus, fetchSetupStatus, setup, verifyEmail } from '@/service/common'
import { fetchInitValidateStatus, fetchSetupStatus, forgotPassword, setup } from '@/service/common'
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'

const accountFormSchema = z.object({
Expand All @@ -28,7 +28,7 @@ const ForgotPasswordForm = () => {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [isEmailSent, setIsEmailSent] = useState(false)
const { register, trigger, formState: { errors } } = useForm<AccountFormValues>({
const { register, trigger, getValues, formState: { errors } } = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues: { email: '' },
})
Expand All @@ -44,15 +44,14 @@ const ForgotPasswordForm = () => {

const handleVerifyEmail = async (email: string) => {
try {
const res = await verifyEmail({
url: '/verify-email',
const res = await forgotPassword({
url: 'account/forgot-password',
body: { email },
})
if (res.result === 'success') {
console.log('Email verified successfully')
if (res.result === 'success')
setIsEmailSent(true)
}
else { console.error('Email verification failed') }

else console.error('Email verification failed')
}
catch (error) {
console.error('Request failed:', error)
Expand All @@ -64,9 +63,9 @@ const ForgotPasswordForm = () => {
router.push('/signin')
}
else {
const isValid = await trigger()
const isValid = await trigger('email')
if (isValid) {
const email = (await trigger('email')).toString()
const email = getValues('email')
await handleVerifyEmail(email)
}
}
Expand Down
2 changes: 1 addition & 1 deletion web/service/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,5 +299,5 @@ export const enableModel = (url: string, body: { model: string; model_type: Mode
export const disableModel = (url: string, body: { model: string; model_type: ModelTypeEnum }) =>
patch<CommonResponse>(url, { body })

export const verifyEmail: Fetcher<CommonResponse, { url: string; body: { email: string } }> = ({ url, body }) =>
export const forgotPassword: Fetcher<CommonResponse, { url: string; body: { email: string } }> = ({ url, body }) =>
post<CommonResponse>(url, { body })