From 447041d2656e8781baa337a3a79eb93f2ff25874 Mon Sep 17 00:00:00 2001 From: Billy Bao Date: Thu, 16 Oct 2025 15:07:49 +0800 Subject: [PATCH] Feat: add forgot password reset, solve #8547 (#10586) ### What problem does this PR solve? Feat: add forgot password reset, solve #8547 ### Type of change - [X] New Feature (non-breaking change which adds functionality) --- api/apps/user_app.py | 185 ++++++++++++++++++++++++++++++++++- api/utils/email_templates.py | 25 +++++ api/utils/web_utils.py | 70 +++++++++---- pyproject.toml | 3 +- uv.lock | 14 +++ 5 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 api/utils/email_templates.py diff --git a/api/apps/user_app.py b/api/apps/user_app.py index f99b7c112..083ec30b6 100644 --- a/api/apps/user_app.py +++ b/api/apps/user_app.py @@ -15,11 +15,14 @@ # import json import logging +import string +import os import re import secrets +import time from datetime import datetime -from flask import redirect, request, session +from flask import redirect, request, session, Response from flask_login import current_user, login_required, login_user, logout_user from werkzeug.security import check_password_hash, generate_password_hash @@ -46,6 +49,19 @@ from api.utils.api_utils import ( validate_request, ) from api.utils.crypt import decrypt +from rag.utils.redis_conn import REDIS_CONN +from api.apps import smtp_mail_server +from api.utils.web_utils import ( + send_email_html, + OTP_LENGTH, + OTP_TTL_SECONDS, + ATTEMPT_LIMIT, + ATTEMPT_LOCK_SECONDS, + RESEND_COOLDOWN_SECONDS, + otp_keys, + hash_code, + captcha_key, +) @manager.route("/login", methods=["POST", "GET"]) # noqa: F821 @@ -825,3 +841,170 @@ def set_tenant_info(): return get_json_result(data=True) except Exception as e: return server_error_response(e) + + +@manager.route("/forget/get-captcha", methods=["GET"]) # noqa: F821 +def forget_get_otp(): + """ + GET /forget/get-captcha?email= + - Generate an image captcha and cache it in Redis under key captcha:{email} with TTL = OTP_TTL_SECONDS. + - Returns the captcha as a PNG image. + """ + email = (request.args.get("email") or "") + if not email: + return get_json_result(data=False, code=settings.RetCode.ARGUMENT_ERROR, message="email is required") + + users = UserService.query(email=email) + if not users: + return get_json_result(data=False, code=settings.RetCode.DATA_ERROR, message="invalid email") + + # Generate captcha text + allowed = string.ascii_uppercase + string.digits + captcha_text = "".join(secrets.choice(allowed) for _ in range(OTP_LENGTH)) + REDIS_CONN.set(captcha_key(email), captcha_text, 60) # Valid for 60 seconds + + from captcha.image import ImageCaptcha + image = ImageCaptcha(width=300, height=120, font_sizes=[50, 60, 70]) + img_bytes = image.generate(captcha_text).read() + return Response(img_bytes, mimetype="image/png") + + +@manager.route("/forget/send-otp", methods=["POST"]) # noqa: F821 +def forget_send_otp(): + """ + POST /forget/send-otp + - Verify the image captcha stored at captcha:{email} (case-insensitive). + - On success, generate an email OTP (A–Z with length = OTP_LENGTH), store hash + salt (and timestamp) in Redis with TTL, reset attempts and cooldown, and send the OTP via email. + """ + req = request.get_json() + email = req.get("email") or "" + captcha = (req.get("captcha") or "").strip() + + if not email or not captcha: + return get_json_result(data=False, code=settings.RetCode.ARGUMENT_ERROR, message="email and captcha required") + + users = UserService.query(email=email) + if not users: + return get_json_result(data=False, code=settings.RetCode.DATA_ERROR, message="invalid email") + + stored_captcha = REDIS_CONN.get(captcha_key(email)) + if not stored_captcha: + return get_json_result(data=False, code=settings.RetCode.NOT_EFFECTIVE, message="invalid or expired captcha") + if (stored_captcha or "").strip().lower() != captcha.lower(): + return get_json_result(data=False, code=settings.RetCode.AUTHENTICATION_ERROR, message="invalid or expired captcha") + + # Delete captcha to prevent reuse + REDIS_CONN.delete(captcha_key(email)) + + k_code, k_attempts, k_last, k_lock = otp_keys(email) + now = int(time.time()) + last_ts = REDIS_CONN.get(k_last) + if last_ts: + try: + elapsed = now - int(last_ts) + except Exception: + elapsed = RESEND_COOLDOWN_SECONDS + remaining = RESEND_COOLDOWN_SECONDS - elapsed + if remaining > 0: + return get_json_result(data=False, code=settings.RetCode.NOT_EFFECTIVE, message=f"you still have to wait {remaining} seconds") + + # Generate OTP (uppercase letters only) and store hashed + otp = "".join(secrets.choice(string.ascii_uppercase) for _ in range(OTP_LENGTH)) + salt = os.urandom(16) + code_hash = hash_code(otp, salt) + REDIS_CONN.set(k_code, f"{code_hash}:{salt.hex()}", OTP_TTL_SECONDS) + REDIS_CONN.set(k_attempts, 0, OTP_TTL_SECONDS) + REDIS_CONN.set(k_last, now, OTP_TTL_SECONDS) + REDIS_CONN.delete(k_lock) + + ttl_min = OTP_TTL_SECONDS // 60 + + if not smtp_mail_server: + logging.warning("SMTP mail server not initialized; skip sending email.") + else: + try: + send_email_html( + subject="Your Password Reset Code", + to_email=email, + template_key="reset_code", + code=otp, + ttl_min=ttl_min, + ) + except Exception: + return get_json_result(data=False, code=settings.RetCode.SERVER_ERROR, message="failed to send email") + + return get_json_result(data=True, code=settings.RetCode.SUCCESS, message="verification passed, email sent") + + +@manager.route("/forget", methods=["POST"]) # noqa: F821 +def forget(): + """ + POST: Verify email + OTP and reset password, then log the user in. + Request JSON: { email, otp, new_password, confirm_new_password } + """ + req = request.get_json() + email = req.get("email") or "" + otp = (req.get("otp") or "").strip() + new_pwd = req.get("new_password") + new_pwd2 = req.get("confirm_new_password") + + if not all([email, otp, new_pwd, new_pwd2]): + return get_json_result(data=False, code=settings.RetCode.ARGUMENT_ERROR, message="email, otp and passwords are required") + + # For reset, passwords are provided as-is (no decrypt needed) + if new_pwd != new_pwd2: + return get_json_result(data=False, code=settings.RetCode.ARGUMENT_ERROR, message="passwords do not match") + + users = UserService.query(email=email) + if not users: + return get_json_result(data=False, code=settings.RetCode.DATA_ERROR, message="invalid email") + + user = users[0] + # Verify OTP from Redis + k_code, k_attempts, k_last, k_lock = otp_keys(email) + if REDIS_CONN.get(k_lock): + return get_json_result(data=False, code=settings.RetCode.NOT_EFFECTIVE, message="too many attempts, try later") + + stored = REDIS_CONN.get(k_code) + if not stored: + return get_json_result(data=False, code=settings.RetCode.NOT_EFFECTIVE, message="expired otp") + + try: + stored_hash, salt_hex = str(stored).split(":", 1) + salt = bytes.fromhex(salt_hex) + except Exception: + return get_json_result(data=False, code=settings.RetCode.EXCEPTION_ERROR, message="otp storage corrupted") + + # Case-insensitive verification: OTP generated uppercase + calc = hash_code(otp.upper(), salt) + if calc != stored_hash: + # bump attempts + try: + attempts = int(REDIS_CONN.get(k_attempts) or 0) + 1 + except Exception: + attempts = 1 + REDIS_CONN.set(k_attempts, attempts, OTP_TTL_SECONDS) + if attempts >= ATTEMPT_LIMIT: + REDIS_CONN.set(k_lock, int(time.time()), ATTEMPT_LOCK_SECONDS) + return get_json_result(data=False, code=settings.RetCode.AUTHENTICATION_ERROR, message="expired otp") + + # Success: consume OTP and reset password + REDIS_CONN.delete(k_code) + REDIS_CONN.delete(k_attempts) + REDIS_CONN.delete(k_last) + REDIS_CONN.delete(k_lock) + + try: + UserService.update_user_password(user.id, new_pwd) + except Exception as e: + logging.exception(e) + return get_json_result(data=False, code=settings.RetCode.EXCEPTION_ERROR, message="failed to reset password") + + # Auto login (reuse login flow) + user.access_token = get_uuid() + login_user(user) + user.update_time = (current_timestamp(),) + user.update_date = (datetime_format(datetime.now()),) + user.save() + msg = "Password reset successful. Logged in." + return construct_response(data=user.to_json(), auth=user.get_id(), message=msg) diff --git a/api/utils/email_templates.py b/api/utils/email_templates.py new file mode 100644 index 000000000..10473908a --- /dev/null +++ b/api/utils/email_templates.py @@ -0,0 +1,25 @@ +""" +Reusable HTML email templates and registry. +""" + +# Invitation email template +INVITE_EMAIL_TMPL = """ +

Hi {{email}},

+

{{inviter}} has invited you to join their team (ID: {{tenant_id}}).

+

Click the link below to complete your registration:
+{{invite_url}}

+

If you did not request this, please ignore this email.

+""" + +# Password reset code template +RESET_CODE_EMAIL_TMPL = """ +

Hello,

+

Your password reset code is: {{ code }}

+

This code will expire in {{ ttl_min }} minutes.

+""" + +# Template registry +EMAIL_TEMPLATES = { + "invite": INVITE_EMAIL_TMPL, + "reset_code": RESET_CODE_EMAIL_TMPL, +} diff --git a/api/utils/web_utils.py b/api/utils/web_utils.py index 55ce561ea..e0e47f472 100644 --- a/api/utils/web_utils.py +++ b/api/utils/web_utils.py @@ -24,6 +24,7 @@ from urllib.parse import urlparse from api.apps import smtp_mail_server from flask_mail import Message from flask import render_template_string +from api.utils.email_templates import EMAIL_TEMPLATES from selenium import webdriver from selenium.common.exceptions import TimeoutException from selenium.webdriver.chrome.options import Options @@ -34,6 +35,12 @@ from selenium.webdriver.support.ui import WebDriverWait from webdriver_manager.chrome import ChromeDriverManager +OTP_LENGTH = 8 +OTP_TTL_SECONDS = 5 * 60 +ATTEMPT_LIMIT = 5 +ATTEMPT_LOCK_SECONDS = 30 * 60 +RESEND_COOLDOWN_SECONDS = 60 + CONTENT_TYPE_MAP = { # Office @@ -178,24 +185,49 @@ def get_float(req: dict, key: str, default: float | int = 10.0) -> float: return default -INVITE_EMAIL_TMPL = """ -

Hi {{email}},

-

{{inviter}} has invited you to join their team (ID: {{tenant_id}}).

-

Click the link below to complete your registration:
-{{invite_url}}

-

If you did not request this, please ignore this email.

-""" +def send_email_html(subject: str, to_email: str, template_key: str, **context): + """Generic HTML email sender using shared templates. + template_key must exist in EMAIL_TEMPLATES. + """ + from api.apps import app + tmpl = EMAIL_TEMPLATES.get(template_key) + if not tmpl: + raise ValueError(f"Unknown email template: {template_key}") + with app.app_context(): + msg = Message(subject=subject, recipients=[to_email]) + msg.html = render_template_string(tmpl, **context) + smtp_mail_server.send(msg) + def send_invite_email(to_email, invite_url, tenant_id, inviter): - from api.apps import app - with app.app_context(): - msg = Message(subject="RAGFlow Invitation", - recipients=[to_email]) - msg.html = render_template_string( - INVITE_EMAIL_TMPL, - email=to_email, - invite_url=invite_url, - tenant_id=tenant_id, - inviter=inviter, - ) - smtp_mail_server.send(msg) + # Reuse the generic HTML sender with 'invite' template + send_email_html( + subject="RAGFlow Invitation", + to_email=to_email, + template_key="invite", + email=to_email, + invite_url=invite_url, + tenant_id=tenant_id, + inviter=inviter, + ) + + +def otp_keys(email: str): + email = (email or "").strip().lower() + return ( + f"otp:{email}", + f"otp_attempts:{email}", + f"otp_last_sent:{email}", + f"otp_lock:{email}", + ) + + +def hash_code(code: str, salt: bytes) -> str: + import hashlib + import hmac + return hmac.new(salt, (code or "").encode("utf-8"), hashlib.sha256).hexdigest() + + +def captcha_key(email: str) -> str: + return f"captcha:{email}" + diff --git a/pyproject.toml b/pyproject.toml index 98ecd5127..486de7b40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,7 +114,7 @@ dependencies = [ "xpinyin==0.7.6", "yfinance==0.2.65", "zhipuai==2.0.1", - "google-generativeai>=0.8.1,<0.9.0", # Needed for cv_model and embedding_model + "google-generativeai>=0.8.1,<0.9.0", # Needed for cv_model and embedding_model "python-docx>=1.1.2,<2.0.0", "pypdf2>=3.0.1,<4.0.0", "graspologic>=3.4.1,<4.0.0", @@ -136,6 +136,7 @@ dependencies = [ "lark>=1.2.2", "mammoth>=1.11.0", "markdownify>=1.2.0", + "captcha>=0.7.1", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index e14dd35aa..1099579b1 100644 --- a/uv.lock +++ b/uv.lock @@ -658,6 +658,18 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/2b/a64c2d25a37aeb921fddb929111413049fc5f8b9a4c1aefaffaafe768d54/cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945", size = 9325, upload-time = "2024-02-26T20:33:20.308Z" }, ] +[[package]] +name = "captcha" +version = "0.7.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/65/8e186bb798f33ba390eab897c995b0fcee92bc030e0f40cb8ea01f34dd07/captcha-0.7.1.tar.gz", hash = "sha256:a1b462bcc633a64d8db5efa7754548a877c698d98f87716c620a707364cabd6b", size = 226561, upload-time = "2025-03-01T05:00:13.395Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/ff/3f0982ecd37c2d6a7266c22e7ea2e47d0773fe449984184c5316459d2776/captcha-0.7.1-py3-none-any.whl", hash = "sha256:8b73b5aba841ad1e5bdb856205bf5f09560b728ee890eb9dae42901219c8c599", size = 147606, upload-time = "2025-03-01T05:00:10.433Z" }, +] + [[package]] name = "cbor" version = "1.0.0" @@ -5471,6 +5483,7 @@ dependencies = [ { name = "boto3" }, { name = "botocore" }, { name = "cachetools" }, + { name = "captcha" }, { name = "chardet" }, { name = "click" }, { name = "cn2an" }, @@ -5628,6 +5641,7 @@ requires-dist = [ { name = "boto3", specifier = "==1.34.140" }, { name = "botocore", specifier = "==1.34.140" }, { name = "cachetools", specifier = "==5.3.3" }, + { name = "captcha", specifier = ">=0.7.1" }, { name = "chardet", specifier = "==5.2.0" }, { name = "click", specifier = ">=8.1.8" }, { name = "cn2an", specifier = "==0.5.22" },