feat: refine email verification login flow
This commit is contained in:
+92
-33
@@ -103,8 +103,18 @@ def consume_fresh_authorization(purpose: str) -> None:
|
||||
session.pop(f"fresh_auth_{purpose}", None)
|
||||
|
||||
|
||||
def render_auth_template(mode: str, status_code: int = 200, captcha: str = ""):
|
||||
return render_template("login.html", mode=mode, captcha=captcha), status_code
|
||||
def render_auth_template(
|
||||
mode: str,
|
||||
status_code: int = 200,
|
||||
captcha: str = "",
|
||||
auth_error_field: str | None = None,
|
||||
):
|
||||
return render_template(
|
||||
"login.html",
|
||||
mode=mode,
|
||||
captcha=captcha,
|
||||
auth_error_field=auth_error_field,
|
||||
), status_code
|
||||
|
||||
|
||||
def refresh_captcha() -> str:
|
||||
@@ -112,9 +122,14 @@ def refresh_captcha() -> str:
|
||||
return session["captcha"]
|
||||
|
||||
|
||||
def render_auth_error(mode: str, message: str, status_code: int = 400):
|
||||
def render_auth_error(
|
||||
mode: str,
|
||||
message: str,
|
||||
status_code: int = 400,
|
||||
auth_error_field: str | None = None,
|
||||
):
|
||||
flash(message, "error")
|
||||
return render_auth_template(mode, status_code, refresh_captcha())
|
||||
return render_auth_template(mode, status_code, refresh_captcha(), auth_error_field)
|
||||
|
||||
|
||||
def captcha_is_valid() -> bool:
|
||||
@@ -192,12 +207,17 @@ def code_hash(email: str, purpose: str, code: str) -> str:
|
||||
return hashlib.sha256(f"{email}:{purpose}:{code}".encode()).hexdigest()
|
||||
|
||||
|
||||
def issue_email_code(email: str, purpose: str) -> bool:
|
||||
def issue_email_code(email: str, purpose: str) -> str | None:
|
||||
"""Issue an email code, returning a failure reason when it cannot be sent."""
|
||||
now = utc_now()
|
||||
resend_at = now - timedelta(seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"])
|
||||
recent = (
|
||||
EmailVerificationCode.query.filter_by(email=email, purpose=purpose)
|
||||
.filter(EmailVerificationCode.created_at >= resend_at)
|
||||
.filter(
|
||||
EmailVerificationCode.created_at >= resend_at,
|
||||
EmailVerificationCode.expires_at > now,
|
||||
EmailVerificationCode.used_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
ip = request.remote_addr or ""
|
||||
@@ -205,8 +225,10 @@ def issue_email_code(email: str, purpose: str) -> bool:
|
||||
EmailVerificationCode.created_at >= now - timedelta(hours=1),
|
||||
EmailVerificationCode.requested_ip == ip,
|
||||
).count()
|
||||
if recent or hourly >= 20:
|
||||
return False
|
||||
if recent:
|
||||
return "resend_wait"
|
||||
if hourly >= 20:
|
||||
return "rate_limited"
|
||||
EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None).update({"used_at": now})
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
record = EmailVerificationCode(
|
||||
@@ -221,7 +243,7 @@ def issue_email_code(email: str, purpose: str) -> bool:
|
||||
try:
|
||||
send_transactional_email(
|
||||
to=email,
|
||||
subject=f"供水管道健康评估系统:{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码",
|
||||
subject=f"【管道健康】{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码",
|
||||
html=verification_code_email(
|
||||
code=code,
|
||||
minutes=current_app.config["EMAIL_CODE_MINUTES"],
|
||||
@@ -231,8 +253,16 @@ def issue_email_code(email: str, purpose: str) -> bool:
|
||||
except (EmailConfigurationError, EmailDeliveryError):
|
||||
db.session.delete(record)
|
||||
db.session.commit()
|
||||
return False
|
||||
return True
|
||||
return "delivery_failed"
|
||||
return None
|
||||
|
||||
|
||||
def email_code_issue_message(reason: str) -> str:
|
||||
if reason == "resend_wait":
|
||||
return f"验证码已发送,请 {current_app.config['EMAIL_CODE_RESEND_SECONDS']} 秒后再试。"
|
||||
if reason == "rate_limited":
|
||||
return "请求过于频繁,请稍后再试。"
|
||||
return "验证码发送失败,请稍后重试。"
|
||||
|
||||
|
||||
def consume_email_code(email: str, purpose: str, code: str) -> bool:
|
||||
@@ -318,17 +348,26 @@ def login():
|
||||
return redirect(url_for("main.home"))
|
||||
if request.method == "GET":
|
||||
return render_auth_template("login", captcha=refresh_captcha())
|
||||
email, password = normal_email(request.form.get("email", "")), request.form.get("password", "")
|
||||
if not captcha_is_valid(): return render_auth_error("login", "验证码错误")
|
||||
user = User.query.filter_by(email=email).first()
|
||||
identifier, password = request.form.get("identifier", "").strip(), request.form.get("password", "")
|
||||
if not captcha_is_valid():
|
||||
return render_auth_error("login", "图形验证码错误", auth_error_field="captcha")
|
||||
user = User.query.filter(
|
||||
or_(User.username == identifier, User.email == normal_email(identifier))
|
||||
).first()
|
||||
if not user or not user.is_active_account or not user.check_password(password):
|
||||
return render_auth_error("login", "邮箱或密码错误")
|
||||
return render_auth_error("login", "用户名、邮箱或密码错误")
|
||||
remember = bool(request.form.get("remember"))
|
||||
if trusted_device_for(user):
|
||||
return login_response(user, remember)
|
||||
session.update(pending_email=email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login")
|
||||
if not issue_email_code(email, "login"):
|
||||
return render_auth_error("login", "验证码发送失败,请稍后重试", 503)
|
||||
session.update(pending_email=user.email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login")
|
||||
if reason := issue_email_code(user.email, "login"):
|
||||
if reason == "resend_wait":
|
||||
flash("邮箱验证码已发送,请输入验证码完成二次认证。", "info")
|
||||
return redirect(url_for("main.verify_email", purpose="login"))
|
||||
return render_auth_error(
|
||||
"login", email_code_issue_message(reason),
|
||||
503 if reason == "delivery_failed" else 429,
|
||||
)
|
||||
return redirect(url_for("main.verify_email", purpose="login"))
|
||||
|
||||
|
||||
@@ -336,16 +375,24 @@ def login():
|
||||
def register():
|
||||
if request.method == "GET": return render_auth_template("register", captcha=refresh_captcha())
|
||||
username, email, password = request.form.get("username", "").strip(), normal_email(request.form.get("email", "")), request.form.get("password", "")
|
||||
if not captcha_is_valid(): return render_auth_error("register", "验证码错误")
|
||||
if not captcha_is_valid():
|
||||
return render_auth_error("register", "图形验证码错误", auth_error_field="captcha")
|
||||
if not registration_allowed(): return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403)
|
||||
if not username or len(username) > 100: return render_auth_error("register", "显示名不能为空且不能超过100个字符")
|
||||
if not valid_email(email): return render_auth_error("register", "请输入有效的邮箱地址")
|
||||
if not valid_password(password): return render_auth_error("register", PASSWORD_RULE_MESSAGE)
|
||||
if not username or len(username) > 100:
|
||||
return render_auth_error("register", "显示名不能为空且不能超过100个字符", auth_error_field="username")
|
||||
if not valid_email(email):
|
||||
return render_auth_error("register", "请输入有效的邮箱地址", auth_error_field="email")
|
||||
if not valid_password(password):
|
||||
return render_auth_error("register", PASSWORD_RULE_MESSAGE, auth_error_field="password")
|
||||
if User.query.filter((User.username == username) | (User.email == email)).first(): return render_auth_error("register", "显示名或邮箱已被使用")
|
||||
user = User(username=username, email=email, is_admin=False, is_active_account=False)
|
||||
user.set_password(password); db.session.add(user); db.session.commit()
|
||||
session.update(pending_email=email, pending_user_id=user.id, pending_purpose="register")
|
||||
if not issue_email_code(email, "register"): return render_auth_error("register", "验证码发送失败,请稍后重试", 503)
|
||||
if reason := issue_email_code(email, "register"):
|
||||
return render_auth_error(
|
||||
"register", email_code_issue_message(reason),
|
||||
503 if reason == "delivery_failed" else 429,
|
||||
)
|
||||
return redirect(url_for("main.verify_email", purpose="register"))
|
||||
|
||||
|
||||
@@ -353,24 +400,33 @@ def register():
|
||||
def verify_email(purpose: str):
|
||||
if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose:
|
||||
abort(400)
|
||||
if request.method == "GET": return verification_page("邮箱验证", purpose)
|
||||
verification_title = "邮箱二次认证" if purpose == "login" else "邮箱验证"
|
||||
if request.method == "GET": return verification_page(verification_title, purpose)
|
||||
email = session.get("pending_email", "")
|
||||
if not consume_email_code(email, purpose, request.form.get("code", "")):
|
||||
flash("验证码无效、过期或尝试次数已用尽。", "error"); return verification_page("邮箱验证", purpose), 400
|
||||
flash("邮箱验证码无效、过期或尝试次数已用尽。", "error"); return verification_page(verification_title, purpose), 400
|
||||
user = db.session.get(User, session.get("pending_user_id"))
|
||||
if not user or (purpose != "change_email_new" and user.email != email): abort(400)
|
||||
if purpose == "register":
|
||||
user.email_verified_at = utc_now(); user.is_active_account = True; db.session.commit(); session.clear(); flash("邮箱验证成功,请登录。", "info"); return redirect(url_for("main.login"))
|
||||
user.email_verified_at = utc_now()
|
||||
user.is_active_account = True
|
||||
db.session.commit()
|
||||
session.clear()
|
||||
flash("邮箱验证成功,欢迎使用系统。", "info")
|
||||
return login_response(user, remember=False)
|
||||
if purpose == "login":
|
||||
remember = bool(session.get("pending_remember")); session.clear(); return login_response(user, remember, trust_device=True)
|
||||
remember = bool(session.get("pending_remember"))
|
||||
trust_device = bool(request.form.get("trust_device"))
|
||||
session.clear()
|
||||
return login_response(user, remember, trust_device=trust_device)
|
||||
if purpose == "reset":
|
||||
grant_fresh_authorization(user, "password_reset")
|
||||
return redirect(url_for("main.set_password"))
|
||||
if purpose == "change_email_old":
|
||||
new_email = session.get("new_email", "")
|
||||
session.update(pending_email=new_email, pending_purpose="change_email_new")
|
||||
if not issue_email_code(new_email, "change_email_new"):
|
||||
flash("新邮箱验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security"))
|
||||
if reason := issue_email_code(new_email, "change_email_new"):
|
||||
flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security"))
|
||||
return redirect(url_for("main.verify_email", purpose="change_email_new"))
|
||||
if purpose == "change_email_new":
|
||||
if User.query.filter(User.email == email, User.id != user.id).first():
|
||||
@@ -384,7 +440,8 @@ def verify_email(purpose: str):
|
||||
def resend_code(purpose: str):
|
||||
if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose:
|
||||
abort(400)
|
||||
if not issue_email_code(session.get("pending_email", ""), purpose): flash("发送过于频繁或服务暂不可用,请稍后再试。", "error")
|
||||
if reason := issue_email_code(session.get("pending_email", ""), purpose):
|
||||
flash(email_code_issue_message(reason), "error")
|
||||
else: flash("验证码已发送,请查收邮箱。", "info")
|
||||
return redirect(url_for("main.verify_email", purpose=purpose))
|
||||
|
||||
@@ -437,10 +494,12 @@ def account_security():
|
||||
if not valid_email(new_email) or User.query.filter_by(email=new_email).first():
|
||||
flash("请输入未被使用的有效邮箱地址。", "error"); return redirect(url_for("main.account_security"))
|
||||
session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_email_old", new_email=new_email)
|
||||
if not issue_email_code(current_user.email, "change_email_old"): flash("验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security"))
|
||||
if reason := issue_email_code(current_user.email, "change_email_old"):
|
||||
flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security"))
|
||||
return redirect(url_for("main.verify_email", purpose="change_email_old"))
|
||||
session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_password")
|
||||
if not issue_email_code(current_user.email, "change_password"): flash("验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security"))
|
||||
if reason := issue_email_code(current_user.email, "change_password"):
|
||||
flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security"))
|
||||
return redirect(url_for("main.verify_email", purpose="change_password"))
|
||||
|
||||
|
||||
@@ -514,7 +573,7 @@ def admin_password_reset(user_id: int):
|
||||
try:
|
||||
send_transactional_email(
|
||||
to=user.email,
|
||||
subject="供水管道健康评估系统:请重置密码",
|
||||
subject="【管道健康】密码重置通知",
|
||||
html=password_reset_notice_email(
|
||||
username=user.username,
|
||||
reset_url=url_for("main.forgot_password", _external=True),
|
||||
|
||||
Reference in New Issue
Block a user