add forget/verify-otp and forget/reset-password
This commit is contained in:
parent
34fc06c283
commit
5121c86ec4
2 changed files with 86 additions and 26 deletions
|
|
@ -45,7 +45,7 @@ from api.utils.api_utils import (
|
||||||
)
|
)
|
||||||
from api.utils.crypt import decrypt
|
from api.utils.crypt import decrypt
|
||||||
from rag.utils.redis_conn import REDIS_CONN
|
from rag.utils.redis_conn import REDIS_CONN
|
||||||
from api.apps import login_required, current_user, login_user, logout_user
|
from api.apps import manager, login_required, current_user, login_user, logout_user
|
||||||
from api.utils.web_utils import (
|
from api.utils.web_utils import (
|
||||||
send_email_html,
|
send_email_html,
|
||||||
OTP_LENGTH,
|
OTP_LENGTH,
|
||||||
|
|
@ -947,27 +947,39 @@ async def forget_send_otp():
|
||||||
@manager.route("/forget", methods=["POST"]) # noqa: F821
|
@manager.route("/forget", methods=["POST"]) # noqa: F821
|
||||||
async def forget():
|
async def forget():
|
||||||
"""
|
"""
|
||||||
POST: Verify email + OTP and reset password, then log the user in.
|
Deprecated single-step reset endpoint.
|
||||||
Request JSON: { email, otp, new_password, confirm_new_password }
|
Use /forget/verify-otp then /forget/reset-password.
|
||||||
|
"""
|
||||||
|
return get_json_result(
|
||||||
|
data=False,
|
||||||
|
code=RetCode.NOT_EFFECTIVE,
|
||||||
|
message="Use /forget/verify-otp then /forget/reset-password",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verified_key(email: str) -> str:
|
||||||
|
return f"otp:verified:{email}"
|
||||||
|
|
||||||
|
|
||||||
|
@manager.route("/forget/verify-otp", methods=["POST"]) # noqa: F821
|
||||||
|
async def forget_verify_otp():
|
||||||
|
"""
|
||||||
|
Verify email + OTP only. On success:
|
||||||
|
- consume the OTP and attempt counters
|
||||||
|
- set a short-lived verified flag in Redis for the email
|
||||||
|
Request JSON: { email, otp }
|
||||||
"""
|
"""
|
||||||
req = await get_request_json()
|
req = await get_request_json()
|
||||||
email = req.get("email") or ""
|
email = req.get("email") or ""
|
||||||
otp = (req.get("otp") or "").strip()
|
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]):
|
if not all([email, otp]):
|
||||||
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="email, otp and passwords are required")
|
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="email and otp are required")
|
||||||
|
|
||||||
# For reset, passwords are provided as-is (no decrypt needed)
|
|
||||||
if new_pwd != new_pwd2:
|
|
||||||
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="passwords do not match")
|
|
||||||
|
|
||||||
users = UserService.query(email=email)
|
users = UserService.query(email=email)
|
||||||
if not users:
|
if not users:
|
||||||
return get_json_result(data=False, code=RetCode.DATA_ERROR, message="invalid email")
|
return get_json_result(data=False, code=RetCode.DATA_ERROR, message="invalid email")
|
||||||
|
|
||||||
user = users[0]
|
|
||||||
# Verify OTP from Redis
|
# Verify OTP from Redis
|
||||||
k_code, k_attempts, k_last, k_lock = otp_keys(email)
|
k_code, k_attempts, k_last, k_lock = otp_keys(email)
|
||||||
if REDIS_CONN.get(k_lock):
|
if REDIS_CONN.get(k_lock):
|
||||||
|
|
@ -983,7 +995,6 @@ async def forget():
|
||||||
except Exception:
|
except Exception:
|
||||||
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="otp storage corrupted")
|
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="otp storage corrupted")
|
||||||
|
|
||||||
# Case-insensitive verification: OTP generated uppercase
|
|
||||||
calc = hash_code(otp.upper(), salt)
|
calc = hash_code(otp.upper(), salt)
|
||||||
if calc != stored_hash:
|
if calc != stored_hash:
|
||||||
# bump attempts
|
# bump attempts
|
||||||
|
|
@ -996,23 +1007,72 @@ async def forget():
|
||||||
REDIS_CONN.set(k_lock, int(time.time()), ATTEMPT_LOCK_SECONDS)
|
REDIS_CONN.set(k_lock, int(time.time()), ATTEMPT_LOCK_SECONDS)
|
||||||
return get_json_result(data=False, code=RetCode.AUTHENTICATION_ERROR, message="expired otp")
|
return get_json_result(data=False, code=RetCode.AUTHENTICATION_ERROR, message="expired otp")
|
||||||
|
|
||||||
# Success: consume OTP and reset password
|
# Success: consume OTP and attempts; mark verified
|
||||||
REDIS_CONN.delete(k_code)
|
REDIS_CONN.delete(k_code)
|
||||||
REDIS_CONN.delete(k_attempts)
|
REDIS_CONN.delete(k_attempts)
|
||||||
REDIS_CONN.delete(k_last)
|
REDIS_CONN.delete(k_last)
|
||||||
REDIS_CONN.delete(k_lock)
|
REDIS_CONN.delete(k_lock)
|
||||||
|
|
||||||
|
# set verified flag with limited TTL, reuse OTP_TTL_SECONDS or smaller window
|
||||||
|
try:
|
||||||
|
REDIS_CONN.set(_verified_key(email), "1", OTP_TTL_SECONDS)
|
||||||
|
except Exception:
|
||||||
|
return get_json_result(data=False, code=RetCode.SERVER_ERROR, message="failed to set verification state")
|
||||||
|
|
||||||
|
return get_json_result(data=True, code=RetCode.SUCCESS, message="otp verified")
|
||||||
|
|
||||||
|
|
||||||
|
@manager.route("/forget/reset-password", methods=["POST"]) # noqa: F821
|
||||||
|
async def forget_reset_password():
|
||||||
|
"""
|
||||||
|
Reset password after successful OTP verification.
|
||||||
|
Requires: { email, new_password, confirm_new_password }
|
||||||
|
Steps:
|
||||||
|
- check verified flag in Redis
|
||||||
|
- update user password
|
||||||
|
- auto login
|
||||||
|
- clear verified flag
|
||||||
|
"""
|
||||||
|
req = await get_request_json()
|
||||||
|
email = req.get("email") or ""
|
||||||
|
new_pwd = req.get("new_password")
|
||||||
|
new_pwd2 = req.get("confirm_new_password")
|
||||||
|
|
||||||
|
if not all([email, new_pwd, new_pwd2]):
|
||||||
|
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="email and passwords are required")
|
||||||
|
|
||||||
|
if new_pwd != new_pwd2:
|
||||||
|
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="passwords do not match")
|
||||||
|
|
||||||
|
users = UserService.query(email=email)
|
||||||
|
if not users:
|
||||||
|
return get_json_result(data=False, code=RetCode.DATA_ERROR, message="invalid email")
|
||||||
|
|
||||||
|
user = users[0]
|
||||||
try:
|
try:
|
||||||
UserService.update_user_password(user.id, new_pwd)
|
UserService.update_user_password(user.id, new_pwd)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="failed to reset password")
|
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="failed to reset password")
|
||||||
|
|
||||||
# Auto login (reuse login flow)
|
# login
|
||||||
|
try:
|
||||||
user.access_token = get_uuid()
|
user.access_token = get_uuid()
|
||||||
login_user(user)
|
login_user(user)
|
||||||
user.update_time = current_timestamp()
|
user.update_time = current_timestamp()
|
||||||
user.update_date = datetime_format(datetime.now())
|
user.update_date = datetime_format(datetime.now())
|
||||||
user.save()
|
user.save()
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(e)
|
||||||
|
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="failed to login after reset")
|
||||||
|
|
||||||
|
# clear verified flag
|
||||||
|
try:
|
||||||
|
REDIS_CONN.delete(_verified_key(email))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
msg = "Password reset successful. Logged in."
|
msg = "Password reset successful. Logged in."
|
||||||
return await construct_response(data=user.to_json(), auth=user.get_id(), message=msg)
|
return await construct_response(data=user.to_json(), auth=user.get_id(), message=msg)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,10 @@ from webdriver_manager.chrome import ChromeDriverManager
|
||||||
|
|
||||||
|
|
||||||
OTP_LENGTH = 8
|
OTP_LENGTH = 8
|
||||||
OTP_TTL_SECONDS = 5 * 60
|
OTP_TTL_SECONDS = 5 * 60 # valid for 5 minutes
|
||||||
ATTEMPT_LIMIT = 5
|
ATTEMPT_LIMIT = 5 # maximum attempts
|
||||||
ATTEMPT_LOCK_SECONDS = 30 * 60
|
ATTEMPT_LOCK_SECONDS = 30 * 60 # lock for 30 minutes
|
||||||
RESEND_COOLDOWN_SECONDS = 60
|
RESEND_COOLDOWN_SECONDS = 60 # cooldown for 1 minute
|
||||||
|
|
||||||
|
|
||||||
CONTENT_TYPE_MAP = {
|
CONTENT_TYPE_MAP = {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue