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
update password with token
  • Loading branch information
xielong committed Jul 1, 2024
commit 9f22f7befc13e9bb096f7b64f4ad6a70859aec3b
2 changes: 1 addition & 1 deletion api/controllers/console/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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')
25 changes: 1 addition & 24 deletions api/controllers/console/workspace/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,15 @@
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 Account, AccountIntegrate, InvitationCode
from models.account import AccountIntegrate, InvitationCode
from services.account_service import AccountService
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError

Expand Down Expand Up @@ -247,26 +244,7 @@ def get(self):

return {'data': integrate_data}

class AccountForgotPasswordApi(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()


# Register API resources
Expand All @@ -281,4 +259,3 @@ def post(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: 0 additions & 10 deletions api/controllers/console/workspace/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,3 @@ 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
13 changes: 13 additions & 0 deletions api/services/account_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,19 @@ def generate_reset_token(cls, account: Account) -> str:
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
Loading