You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

forgot_password.py 8.6KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import base64
  2. import secrets
  3. from flask import request
  4. from flask_restx import Resource, fields, reqparse
  5. from sqlalchemy import select
  6. from sqlalchemy.orm import Session
  7. from controllers.console import api, console_ns
  8. from controllers.console.auth.error import (
  9. EmailCodeError,
  10. EmailPasswordResetLimitError,
  11. InvalidEmailError,
  12. InvalidTokenError,
  13. PasswordMismatchError,
  14. )
  15. from controllers.console.error import AccountNotFound, EmailSendIpLimitError
  16. from controllers.console.wraps import email_password_login_enabled, setup_required
  17. from events.tenant_event import tenant_was_created
  18. from extensions.ext_database import db
  19. from libs.helper import email, extract_remote_ip
  20. from libs.password import hash_password, valid_password
  21. from models.account import Account
  22. from services.account_service import AccountService, TenantService
  23. from services.feature_service import FeatureService
  24. @console_ns.route("/forgot-password")
  25. class ForgotPasswordSendEmailApi(Resource):
  26. @api.doc("send_forgot_password_email")
  27. @api.doc(description="Send password reset email")
  28. @api.expect(
  29. api.model(
  30. "ForgotPasswordEmailRequest",
  31. {
  32. "email": fields.String(required=True, description="Email address"),
  33. "language": fields.String(description="Language for email (zh-Hans/en-US)"),
  34. },
  35. )
  36. )
  37. @api.response(
  38. 200,
  39. "Email sent successfully",
  40. api.model(
  41. "ForgotPasswordEmailResponse",
  42. {
  43. "result": fields.String(description="Operation result"),
  44. "data": fields.String(description="Reset token"),
  45. "code": fields.String(description="Error code if account not found"),
  46. },
  47. ),
  48. )
  49. @api.response(400, "Invalid email or rate limit exceeded")
  50. @setup_required
  51. @email_password_login_enabled
  52. def post(self):
  53. parser = reqparse.RequestParser()
  54. parser.add_argument("email", type=email, required=True, location="json")
  55. parser.add_argument("language", type=str, required=False, location="json")
  56. args = parser.parse_args()
  57. ip_address = extract_remote_ip(request)
  58. if AccountService.is_email_send_ip_limit(ip_address):
  59. raise EmailSendIpLimitError()
  60. if args["language"] is not None and args["language"] == "zh-Hans":
  61. language = "zh-Hans"
  62. else:
  63. language = "en-US"
  64. with Session(db.engine) as session:
  65. account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none()
  66. token = AccountService.send_reset_password_email(
  67. account=account,
  68. email=args["email"],
  69. language=language,
  70. is_allow_register=FeatureService.get_system_features().is_allow_register,
  71. )
  72. return {"result": "success", "data": token}
  73. @console_ns.route("/forgot-password/validity")
  74. class ForgotPasswordCheckApi(Resource):
  75. @api.doc("check_forgot_password_code")
  76. @api.doc(description="Verify password reset code")
  77. @api.expect(
  78. api.model(
  79. "ForgotPasswordCheckRequest",
  80. {
  81. "email": fields.String(required=True, description="Email address"),
  82. "code": fields.String(required=True, description="Verification code"),
  83. "token": fields.String(required=True, description="Reset token"),
  84. },
  85. )
  86. )
  87. @api.response(
  88. 200,
  89. "Code verified successfully",
  90. api.model(
  91. "ForgotPasswordCheckResponse",
  92. {
  93. "is_valid": fields.Boolean(description="Whether code is valid"),
  94. "email": fields.String(description="Email address"),
  95. "token": fields.String(description="New reset token"),
  96. },
  97. ),
  98. )
  99. @api.response(400, "Invalid code or token")
  100. @setup_required
  101. @email_password_login_enabled
  102. def post(self):
  103. parser = reqparse.RequestParser()
  104. parser.add_argument("email", type=str, required=True, location="json")
  105. parser.add_argument("code", type=str, required=True, location="json")
  106. parser.add_argument("token", type=str, required=True, nullable=False, location="json")
  107. args = parser.parse_args()
  108. user_email = args["email"]
  109. is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(args["email"])
  110. if is_forgot_password_error_rate_limit:
  111. raise EmailPasswordResetLimitError()
  112. token_data = AccountService.get_reset_password_data(args["token"])
  113. if token_data is None:
  114. raise InvalidTokenError()
  115. if user_email != token_data.get("email"):
  116. raise InvalidEmailError()
  117. if args["code"] != token_data.get("code"):
  118. AccountService.add_forgot_password_error_rate_limit(args["email"])
  119. raise EmailCodeError()
  120. # Verified, revoke the first token
  121. AccountService.revoke_reset_password_token(args["token"])
  122. # Refresh token data by generating a new token
  123. _, new_token = AccountService.generate_reset_password_token(
  124. user_email, code=args["code"], additional_data={"phase": "reset"}
  125. )
  126. AccountService.reset_forgot_password_error_rate_limit(args["email"])
  127. return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
  128. @console_ns.route("/forgot-password/resets")
  129. class ForgotPasswordResetApi(Resource):
  130. @api.doc("reset_password")
  131. @api.doc(description="Reset password with verification token")
  132. @api.expect(
  133. api.model(
  134. "ForgotPasswordResetRequest",
  135. {
  136. "token": fields.String(required=True, description="Verification token"),
  137. "new_password": fields.String(required=True, description="New password"),
  138. "password_confirm": fields.String(required=True, description="Password confirmation"),
  139. },
  140. )
  141. )
  142. @api.response(
  143. 200,
  144. "Password reset successfully",
  145. api.model("ForgotPasswordResetResponse", {"result": fields.String(description="Operation result")}),
  146. )
  147. @api.response(400, "Invalid token or password mismatch")
  148. @setup_required
  149. @email_password_login_enabled
  150. def post(self):
  151. parser = reqparse.RequestParser()
  152. parser.add_argument("token", type=str, required=True, nullable=False, location="json")
  153. parser.add_argument("new_password", type=valid_password, required=True, nullable=False, location="json")
  154. parser.add_argument("password_confirm", type=valid_password, required=True, nullable=False, location="json")
  155. args = parser.parse_args()
  156. # Validate passwords match
  157. if args["new_password"] != args["password_confirm"]:
  158. raise PasswordMismatchError()
  159. # Validate token and get reset data
  160. reset_data = AccountService.get_reset_password_data(args["token"])
  161. if not reset_data:
  162. raise InvalidTokenError()
  163. # Must use token in reset phase
  164. if reset_data.get("phase", "") != "reset":
  165. raise InvalidTokenError()
  166. # Revoke token to prevent reuse
  167. AccountService.revoke_reset_password_token(args["token"])
  168. # Generate secure salt and hash password
  169. salt = secrets.token_bytes(16)
  170. password_hashed = hash_password(args["new_password"], salt)
  171. email = reset_data.get("email", "")
  172. with Session(db.engine) as session:
  173. account = session.execute(select(Account).filter_by(email=email)).scalar_one_or_none()
  174. if account:
  175. self._update_existing_account(account, password_hashed, salt, session)
  176. else:
  177. raise AccountNotFound()
  178. return {"result": "success"}
  179. def _update_existing_account(self, account, password_hashed, salt, session):
  180. # Update existing account credentials
  181. account.password = base64.b64encode(password_hashed).decode()
  182. account.password_salt = base64.b64encode(salt).decode()
  183. session.commit()
  184. # Create workspace if needed
  185. if (
  186. not TenantService.get_join_tenants(account)
  187. and FeatureService.get_system_features().is_allow_create_workspace
  188. ):
  189. tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
  190. TenantService.create_tenant_member(tenant, account, role="owner")
  191. account.current_tenant = tenant
  192. tenant_was_created.send(tenant)
  193. api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password")
  194. api.add_resource(ForgotPasswordCheckApi, "/forgot-password/validity")
  195. api.add_resource(ForgotPasswordResetApi, "/forgot-password/resets")