feat: refine email verification login flow

This commit is contained in:
2026-08-04 18:24:05 +08:00
parent 732c1df031
commit 87c792db44
7 changed files with 323 additions and 80 deletions
+14 -15
View File
@@ -1,48 +1,47 @@
# 环境变量模板:复制为 .env 后填写实际值。不要提交 .env 或 .env.local。 # 环境变量模板:复制为 .env 后填写实际值。不要提交 .env 或 .env.local。
# 优先级:系统环境变量 > .env.local > .env。 # 配置优先级:系统环境变量 > .env.local > .env。
# ==================== 运行模式 ==================== # ==================== 运行模式 ====================
# APP_ENVproduction(生产)/ development(开发)。 # APP_ENVproduction(生产)/ development(开发)。
# DEBUG:是否开启 Flask 调试与热重载;生产必须为 false。
APP_ENV=production APP_ENV=production
# 是否开启 Flask 调试与热重载。生产必须为 false。
DEBUG=false DEBUG=false
# ==================== 会话与基础安全 ==================== # ==================== 会话与基础安全 ====================
# SECRET_KEY会话加密密钥。执行 python -c "import secrets; print(secrets.token_hex(32))" 生成。 # SECRET_KEY:执行 python -c "import secrets; print(secrets.token_hex(32))" 生成。
SECRET_KEY=replace-with-a-long-random-secret SECRET_KEY=replace-with-a-long-random-secret
# ==================== 管理员初始化 ==================== # ==================== 管理员初始化 ====================
# 首次启动时创建管理员;之后不会用这些值覆盖管理员密码。 # 首次启动时创建管理员;之后不会用这些值覆盖管理员密码。
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-password ADMIN_PASSWORD=replace-with-a-strong-password
ADMIN_EMAIL=admin@waternetwork.cn ADMIN_EMAIL=
# ==================== 数据与文件 ==================== # ==================== 数据与文件 ====================
# DATABASE_URL数据库连接。默认使用项目 data 目录中的 SQLite 文件。 # DATABASE_URLDocker 环境下使用 /app/data 中的 SQLite 文件。
DATABASE_URL=sqlite:////app/data/pipe_survival.db DATABASE_URL=sqlite:////app/data/pipe_survival.db
# 页面展示的时区。 # APP_TIMEZONE:页面与导出文件显示时间使用的时区。
APP_TIMEZONE=Asia/Shanghai APP_TIMEZONE=Asia/Shanghai
# MAX_UPLOAD_BYTES:单次上传文件最大字节数,16 MiB = 16777216。
# 单个上传文件最大字节数,16 MiB = 16777216。
MAX_UPLOAD_BYTES=16777216 MAX_UPLOAD_BYTES=16777216
# 模型核心目录。 # FUSION_MODEL_CORE_DIRDocker 中的模型核心目录。
FUSION_MODEL_CORE_DIR=/app/model_core FUSION_MODEL_CORE_DIR=/app/model_core
# ==================== 注册与邮件服务 ==================== # ==================== 注册与邮件服务 ====================
# ALLOW_REGISTRATION:是否允许公开自助注册。 # ALLOW_REGISTRATION:是否允许公开自助注册。
ALLOW_REGISTRATION=false ALLOW_REGISTRATION=false
# Resend 邮件服务:发件地址必须已在 Resend 验证。
# Resend 邮件服务。发件地址必须已在 Resend 验证。
RESEND_API_KEY=re_xxxxxxxxx RESEND_API_KEY=re_xxxxxxxxx
RESEND_FROM_EMAIL=no-reply@waternetwork.cn RESEND_FROM_EMAIL=no-reply@waternetwork.cn
# ==================== 登录认证与会话期限 ==================== # ==================== 登录认证与会话期限 ====================
# 邮件验证码:有效期(分钟)、重发间隔(秒)最大尝试次数。 # 邮件验证码:有效期(分钟)、重发间隔(秒)最大尝试次数。
EMAIL_CODE_MINUTES=10 EMAIL_CODE_MINUTES=10
EMAIL_CODE_RESEND_SECONDS=60 EMAIL_CODE_RESEND_SECONDS=60
EMAIL_CODE_MAX_ATTEMPTS=5 EMAIL_CODE_MAX_ATTEMPTS=5
# 邮箱验证码通过后,用于改密和找回密码的短时授权(分钟)。 # FRESH_AUTH_MINUTES:改密、重置密码等敏感操作的短时授权有效期(分钟)。
FRESH_AUTH_MINUTES=5 FRESH_AUTH_MINUTES=5
# 受信设备有效期(天)。 # TRUSTED_DEVICE_DAYS受信设备有效期(天)。
TRUSTED_DEVICE_DAYS=30 TRUSTED_DEVICE_DAYS=30
# SESSION_COOKIE_SECUREHTTPS 下必须为 true;本地 HTTP 开发环境设为 false。 # SESSION_COOKIE_SECUREHTTPS 下必须为 true;本地 HTTP 开发环境设为 false。
SESSION_COOKIE_SECURE=true SESSION_COOKIE_SECURE=true
+92 -33
View File
@@ -103,8 +103,18 @@ def consume_fresh_authorization(purpose: str) -> None:
session.pop(f"fresh_auth_{purpose}", None) session.pop(f"fresh_auth_{purpose}", None)
def render_auth_template(mode: str, status_code: int = 200, captcha: str = ""): def render_auth_template(
return render_template("login.html", mode=mode, captcha=captcha), status_code 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: def refresh_captcha() -> str:
@@ -112,9 +122,14 @@ def refresh_captcha() -> str:
return session["captcha"] 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") 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: 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() 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() now = utc_now()
resend_at = now - timedelta(seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"]) resend_at = now - timedelta(seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"])
recent = ( recent = (
EmailVerificationCode.query.filter_by(email=email, purpose=purpose) 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() .first()
) )
ip = request.remote_addr or "" 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.created_at >= now - timedelta(hours=1),
EmailVerificationCode.requested_ip == ip, EmailVerificationCode.requested_ip == ip,
).count() ).count()
if recent or hourly >= 20: if recent:
return False return "resend_wait"
if hourly >= 20:
return "rate_limited"
EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None).update({"used_at": now}) EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None).update({"used_at": now})
code = f"{secrets.randbelow(1_000_000):06d}" code = f"{secrets.randbelow(1_000_000):06d}"
record = EmailVerificationCode( record = EmailVerificationCode(
@@ -221,7 +243,7 @@ def issue_email_code(email: str, purpose: str) -> bool:
try: try:
send_transactional_email( send_transactional_email(
to=email, to=email,
subject=f"供水管道健康评估系统:{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码", subject=f"管道健康{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码",
html=verification_code_email( html=verification_code_email(
code=code, code=code,
minutes=current_app.config["EMAIL_CODE_MINUTES"], minutes=current_app.config["EMAIL_CODE_MINUTES"],
@@ -231,8 +253,16 @@ def issue_email_code(email: str, purpose: str) -> bool:
except (EmailConfigurationError, EmailDeliveryError): except (EmailConfigurationError, EmailDeliveryError):
db.session.delete(record) db.session.delete(record)
db.session.commit() db.session.commit()
return False return "delivery_failed"
return True 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: def consume_email_code(email: str, purpose: str, code: str) -> bool:
@@ -318,17 +348,26 @@ def login():
return redirect(url_for("main.home")) return redirect(url_for("main.home"))
if request.method == "GET": if request.method == "GET":
return render_auth_template("login", captcha=refresh_captcha()) return render_auth_template("login", captcha=refresh_captcha())
email, password = normal_email(request.form.get("email", "")), request.form.get("password", "") identifier, password = request.form.get("identifier", "").strip(), request.form.get("password", "")
if not captcha_is_valid(): return render_auth_error("login", "验证码错误") if not captcha_is_valid():
user = User.query.filter_by(email=email).first() 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): 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")) remember = bool(request.form.get("remember"))
if trusted_device_for(user): if trusted_device_for(user):
return login_response(user, remember) return login_response(user, remember)
session.update(pending_email=email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login") session.update(pending_email=user.email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login")
if not issue_email_code(email, "login"): if reason := issue_email_code(user.email, "login"):
return render_auth_error("login", "验证码发送失败,请稍后重试", 503) 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")) return redirect(url_for("main.verify_email", purpose="login"))
@@ -336,16 +375,24 @@ def login():
def register(): def register():
if request.method == "GET": return render_auth_template("register", captcha=refresh_captcha()) 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", "") 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 registration_allowed(): return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403)
if not username or len(username) > 100: return render_auth_error("register", "显示名不能为空且不能超过100个字符") if not username or len(username) > 100:
if not valid_email(email): return render_auth_error("register", "请输入有效的邮箱地址") return render_auth_error("register", "显示名不能为空且不能超过100个字符", auth_error_field="username")
if not valid_password(password): return render_auth_error("register", PASSWORD_RULE_MESSAGE) 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", "显示名或邮箱已被使用") 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 = User(username=username, email=email, is_admin=False, is_active_account=False)
user.set_password(password); db.session.add(user); db.session.commit() user.set_password(password); db.session.add(user); db.session.commit()
session.update(pending_email=email, pending_user_id=user.id, pending_purpose="register") 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")) return redirect(url_for("main.verify_email", purpose="register"))
@@ -353,24 +400,33 @@ def register():
def verify_email(purpose: str): def verify_email(purpose: str):
if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose: if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose:
abort(400) 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", "") email = session.get("pending_email", "")
if not consume_email_code(email, purpose, request.form.get("code", "")): 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")) 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 not user or (purpose != "change_email_new" and user.email != email): abort(400)
if purpose == "register": 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": 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": if purpose == "reset":
grant_fresh_authorization(user, "password_reset") grant_fresh_authorization(user, "password_reset")
return redirect(url_for("main.set_password")) return redirect(url_for("main.set_password"))
if purpose == "change_email_old": if purpose == "change_email_old":
new_email = session.get("new_email", "") new_email = session.get("new_email", "")
session.update(pending_email=new_email, pending_purpose="change_email_new") session.update(pending_email=new_email, pending_purpose="change_email_new")
if not issue_email_code(new_email, "change_email_new"): if reason := issue_email_code(new_email, "change_email_new"):
flash("新邮箱验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security")) 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")) return redirect(url_for("main.verify_email", purpose="change_email_new"))
if purpose == "change_email_new": if purpose == "change_email_new":
if User.query.filter(User.email == email, User.id != user.id).first(): 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): def resend_code(purpose: str):
if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose: if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose:
abort(400) 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") else: flash("验证码已发送,请查收邮箱。", "info")
return redirect(url_for("main.verify_email", purpose=purpose)) 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(): if not valid_email(new_email) or User.query.filter_by(email=new_email).first():
flash("请输入未被使用的有效邮箱地址。", "error"); return redirect(url_for("main.account_security")) 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) 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")) 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") 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")) return redirect(url_for("main.verify_email", purpose="change_password"))
@@ -514,7 +573,7 @@ def admin_password_reset(user_id: int):
try: try:
send_transactional_email( send_transactional_email(
to=user.email, to=user.email,
subject="供水管道健康评估系统:请重置密码", subject="管道健康】密码重置通知",
html=password_reset_notice_email( html=password_reset_notice_email(
username=user.username, username=user.username,
reset_url=url_for("main.forgot_password", _external=True), reset_url=url_for("main.forgot_password", _external=True),
+1 -1
View File
File diff suppressed because one or more lines are too long
+45 -5
View File
@@ -35,7 +35,7 @@
{% else %} {% else %}
<form method="post" class="mt-5 flex flex-col gap-3 sm:flex-row sm:items-center"> <form method="post" class="mt-5 flex flex-col gap-3 sm:flex-row sm:items-center">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="current_password" type="password" required class="w-full rounded-lg border border-line px-3 py-2 sm:max-w-sm" placeholder="输入当前密码"> <input name="current_password" type="password" autocomplete="current-password" required class="w-full rounded-lg border border-line px-3 py-2 sm:max-w-sm" placeholder="输入当前密码">
<button class="ui-btn ui-btn-primary w-full sm:w-fit">发送密码验证码</button> <button class="ui-btn ui-btn-primary w-full sm:w-fit">发送密码验证码</button>
</form> </form>
{% endif %} {% endif %}
@@ -49,11 +49,12 @@
<p class="mt-1 text-sm leading-6 text-textSub">需验证当前密码、原邮箱和新邮箱。完成后将退出所有设备,并用新邮箱登录。</p> <p class="mt-1 text-sm leading-6 text-textSub">需验证当前密码、原邮箱和新邮箱。完成后将退出所有设备,并用新邮箱登录。</p>
</div> </div>
</div> </div>
<form method="post" class="mt-5 grid gap-3 sm:grid-cols-2"> <form method="post" class="mt-5 grid gap-3 sm:grid-cols-2" novalidate data-change-email-form>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="change_email"> <input type="hidden" name="action" value="change_email">
<input name="current_password" type="password" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="输入当前密码"> <input name="current_password" type="password" autocomplete="current-password" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="输入当前密码">
<input name="new_email" type="email" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新登录邮箱"> <input name="new_email" type="email" autocomplete="email" required aria-describedby="changeEmailFormError" class="w-full rounded-lg border border-line px-3 py-2" placeholder="新登录邮箱">
<p id="changeEmailFormError" class="hidden text-sm text-dangerText sm:col-span-2" role="alert"></p>
<button class="ui-btn ui-btn-secondary w-full sm:col-span-2 sm:w-fit">验证并更换邮箱</button> <button class="ui-btn ui-btn-secondary w-full sm:col-span-2 sm:w-fit">验证并更换邮箱</button>
</form> </form>
</section> </section>
@@ -69,10 +70,49 @@
<form method="post" class="mt-5"> <form method="post" class="mt-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="revoke_devices"> <input type="hidden" name="action" value="revoke_devices">
<input name="current_password" type="password" required class="mb-3 w-full rounded-lg border border-line px-3 py-2 sm:max-w-sm" placeholder="输入当前密码以确认"> <input name="current_password" type="password" autocomplete="current-password" required class="mb-3 w-full rounded-lg border border-line px-3 py-2 sm:max-w-sm" placeholder="输入当前密码以确认">
<button class="ui-btn ui-btn-secondary w-full sm:w-fit">撤销所有受信设备</button> <button class="ui-btn ui-btn-secondary w-full sm:w-fit">撤销所有受信设备</button>
</form> </form>
</section> </section>
</div> </div>
</section> </section>
<script>
(() => {
const form = document.querySelector('[data-change-email-form]');
if (!form) return;
const password = form.querySelector('input[name="current_password"]');
const email = form.querySelector('input[name="new_email"]');
const error = document.getElementById('changeEmailFormError');
const fields = [password, email];
function clearError() {
error.textContent = '';
error.classList.add('hidden');
fields.forEach((field) => {
field.classList.remove('border-red-300');
field.removeAttribute('aria-invalid');
});
}
function showError(field, message) {
error.textContent = message;
error.classList.remove('hidden');
field.classList.add('border-red-300');
field.setAttribute('aria-invalid', 'true');
field.focus();
}
fields.forEach((field) => field.addEventListener('input', clearError));
form.addEventListener('submit', (event) => {
clearError();
if (!password.value) {
event.preventDefault();
showError(password, '请输入当前密码。');
} else if (!email.value || !email.validity.valid) {
event.preventDefault();
showError(email, '请输入有效的新登录邮箱。');
}
});
})();
</script>
{% endblock %} {% endblock %}
+17 -18
View File
@@ -14,6 +14,7 @@
<script> <script>
window.__flashMessages = {{ flashed_messages|tojson }}; window.__flashMessages = {{ flashed_messages|tojson }};
window.__pageNotice = {{ page_notice|tojson }}; window.__pageNotice = {{ page_notice|tojson }};
window.__authErrorField = {{ auth_error_field|tojson }};
</script> </script>
<div class="sr-only" aria-hidden="true"> <div class="sr-only" aria-hidden="true">
{% for category, message in flashed_messages %}{{ message }}{% endfor %} {% for category, message in flashed_messages %}{{ message }}{% endfor %}
@@ -70,10 +71,10 @@
<form method="post" action="{{ url_for('main.login') }}" class="space-y-5" novalidate data-auth-form> <form method="post" action="{{ url_for('main.login') }}" class="space-y-5" novalidate data-auth-form>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div> <div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">邮箱</label> <label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">用户名或邮箱</label>
<div class="relative"> <div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">mail</span> <span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">person</span>
<input name="email" type="email" required data-field-label="邮箱" autocomplete="email" class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入邮箱" /> <input name="identifier" required data-field-label="用户名或邮箱" autocomplete="username" class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入用户名或邮箱" />
</div> </div>
</div> </div>
@@ -92,14 +93,14 @@
</div> </div>
<div> <div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">验证码</label> <label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">图形验证码</label>
<div class="grid grid-cols-[1fr_92px_44px] gap-3 items-center"> <div class="grid grid-cols-[1fr_92px_44px] gap-3 items-center">
<div class="relative"> <div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">verified_user</span> <span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">verified_user</span>
<input name="captcha" required data-field-label="验证码" class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入验证码" /> <input name="captcha" required data-field-label="图形验证码" class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入图形验证码" />
</div> </div>
<div class="captcha-token rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic" aria-label="验证码" data-captcha-token>{{ captcha }}</div> <div class="captcha-token rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic" aria-label="图形验证码" data-captcha-token>{{ captcha }}</div>
<a href="{{ url_for('main.login') }}" tabindex="-1" class="ui-btn ui-btn-field ui-btn-secondary px-0" aria-label="刷新验证码"> <a href="{{ url_for('main.login') }}" tabindex="-1" class="ui-btn ui-btn-field ui-btn-secondary px-0" aria-label="刷新图形验证码">
<span class="material-symbols-outlined">refresh</span> <span class="material-symbols-outlined">refresh</span>
</a> </a>
</div> </div>
@@ -143,14 +144,14 @@
</div> </div>
</div> </div>
<div> <div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">验证码</label> <label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">图形验证码</label>
<div class="grid grid-cols-[1fr_92px_44px] gap-3 items-center"> <div class="grid grid-cols-[1fr_92px_44px] gap-3 items-center">
<div class="relative"> <div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">verified_user</span> <span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">verified_user</span>
<input name="captcha" required data-field-label="验证码" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" placeholder="请输入验证码" /> <input name="captcha" required data-field-label="图形验证码" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" placeholder="请输入图形验证码" />
</div> </div>
<div class="captcha-token rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic" aria-label="验证码" data-captcha-token>{{ captcha }}</div> <div class="captcha-token rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic" aria-label="图形验证码" data-captcha-token>{{ captcha }}</div>
<a href="{{ url_for('main.register') }}" tabindex="-1" class="ui-btn ui-btn-field ui-btn-secondary px-0 {{ 'pointer-events-none opacity-50' if not allow_registration }}" aria-label="刷新验证码" aria-disabled="{{ 'true' if not allow_registration else 'false' }}"> <a href="{{ url_for('main.register') }}" tabindex="-1" class="ui-btn ui-btn-field ui-btn-secondary px-0 {{ 'pointer-events-none opacity-50' if not allow_registration }}" aria-label="刷新图形验证码" aria-disabled="{{ 'true' if not allow_registration else 'false' }}">
<span class="material-symbols-outlined">refresh</span> <span class="material-symbols-outlined">refresh</span>
</a> </a>
</div> </div>
@@ -277,21 +278,19 @@
form.querySelectorAll('.auth-input-error').forEach(clearFieldError); form.querySelectorAll('.auth-input-error').forEach(clearFieldError);
} }
function fieldForServerMessage(message) { function fieldForServerMessage() {
const activeForm = document.querySelector('[data-auth-form]'); const activeForm = document.querySelector('[data-auth-form]');
if (!activeForm) return null; if (!activeForm) return null;
if (message.includes('验证码')) return activeForm.querySelector('input[name="captcha"]'); return window.__authErrorField
if (message.includes('密码')) return activeForm.querySelector('input[name="password"]'); ? activeForm.querySelector(`[name="${window.__authErrorField}"]`)
if (message.includes('邮箱')) return activeForm.querySelector('input[name="email"]'); : null;
if (message.includes('用户名') || message.includes('显示名')) return activeForm.querySelector('input[name="username"]');
return null;
} }
const flashedMessages = window.__flashMessages || []; const flashedMessages = window.__flashMessages || [];
const pageNotice = window.__pageNotice; const pageNotice = window.__pageNotice;
if (flashedMessages.length) { if (flashedMessages.length) {
const [category, message] = flashedMessages[flashedMessages.length - 1]; const [category, message] = flashedMessages[flashedMessages.length - 1];
const serverField = category === 'error' ? fieldForServerMessage(message) : null; const serverField = category === 'error' ? fieldForServerMessage() : null;
showAppNotification(message, category === 'error' ? 'error' : 'info', undefined, serverField); showAppNotification(message, category === 'error' ? 'error' : 'info', undefined, serverField);
if (category === 'error') { if (category === 'error') {
markFieldError(serverField); markFieldError(serverField);
+16 -4
View File
@@ -13,11 +13,11 @@
<section class="w-full rounded-2xl border border-line bg-white p-5 shadow-panel sm:p-7"> <section class="w-full rounded-2xl border border-line bg-white p-5 shadow-panel sm:p-7">
<span class="material-symbols-outlined text-4xl text-primary">mark_email_read</span> <span class="material-symbols-outlined text-4xl text-primary">mark_email_read</span>
<h1 class="mt-3 text-2xl font-extrabold">{{ title }}</h1> <h1 class="mt-3 text-2xl font-extrabold">{{ title }}</h1>
<p class="mt-2 text-sm leading-6 text-textSub">验证码已发送至 {{ email }},10 分钟内有效。</p> <p class="mt-2 text-sm leading-6 text-textSub">邮箱验证码已发送至 {{ email }},10 分钟内有效。</p>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %} {% for category, message in messages %}
<p class="mt-4 rounded-lg bg-dangerSoft p-3 text-sm text-dangerText">{{ message }}</p> <p class="mt-4 rounded-lg p-3 text-sm {{ 'bg-dangerSoft text-dangerText' if category == 'error' else 'bg-blueSoft text-primaryDeep' }}">{{ message }}</p>
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
@@ -25,7 +25,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input id="verificationCode" type="hidden" name="code" value=""> <input id="verificationCode" type="hidden" name="code" value="">
<fieldset> <fieldset>
<legend class="text-sm font-semibold">请输入 6 位验证码</legend> <legend class="text-sm font-semibold">请输入 6 位邮箱验证码</legend>
<div class="mt-3 grid grid-cols-6 gap-1.5 sm:gap-2" id="codeInputs"> <div class="mt-3 grid grid-cols-6 gap-1.5 sm:gap-2" id="codeInputs">
{% for index in range(6) %} {% for index in range(6) %}
<input <input
@@ -33,7 +33,7 @@
inputmode="numeric" inputmode="numeric"
autocomplete="one-time-code" autocomplete="one-time-code"
maxlength="1" maxlength="1"
aria-label="验证码第 {{ index + 1 }} 位" aria-label="邮箱验证码第 {{ index + 1 }} 位"
class="h-11 min-w-0 rounded-lg border border-line text-center text-lg font-bold tracking-wide focus:border-primary focus:ring-primary sm:h-12 sm:text-xl" class="h-11 min-w-0 rounded-lg border border-line text-center text-lg font-bold tracking-wide focus:border-primary focus:ring-primary sm:h-12 sm:text-xl"
data-code-digit data-code-digit
{% if index == 0 %}autofocus{% endif %} {% if index == 0 %}autofocus{% endif %}
@@ -41,6 +41,18 @@
{% endfor %} {% endfor %}
</div> </div>
</fieldset> </fieldset>
{% if purpose == 'login' %}
<label class="mt-5 flex cursor-pointer items-start gap-3 rounded-xl border border-blue-100 bg-blueSoft px-4 py-3.5 transition hover:border-primary hover:bg-blue-50/50">
<input name="trust_device" type="checkbox" class="mt-1 h-4 w-4 shrink-0 rounded border-slate-300 text-primary focus:ring-primary">
<span class="min-w-0">
<span class="flex items-center gap-2 font-bold text-textMain">
<span class="material-symbols-outlined text-lg text-primary" aria-hidden="true">devices</span>
信任此设备
</span>
<span class="mt-1 block text-sm leading-5 text-textSub">30 天内登录无需邮箱二次认证。</span>
</span>
</label>
{% endif %}
<button id="verifyButton" class="ui-btn ui-btn-primary mt-6 w-full" type="submit">验证并继续</button> <button id="verifyButton" class="ui-btn ui-btn-primary mt-6 w-full" type="submit">验证并继续</button>
</form> </form>
+138 -4
View File
@@ -10,6 +10,7 @@ from flask import session
from app import create_app from app import create_app
from app.config import Config from app.config import Config
from app.email import EmailDeliveryError
from app.extensions import db from app.extensions import db
from app.models import EmailVerificationCode, TrustedDevice, User from app.models import EmailVerificationCode, TrustedDevice, User
from app.routes import grant_fresh_authorization, has_fresh_authorization, valid_password from app.routes import grant_fresh_authorization, has_fresh_authorization, valid_password
@@ -60,6 +61,28 @@ class EmailAuthenticationTest(unittest.TestCase):
).isoformat() ).isoformat()
self.assertFalse(has_fresh_authorization(user, "password_change")) self.assertFalse(has_fresh_authorization(user, "password_change"))
def test_account_security_uses_custom_email_validation_and_password_autofill(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.commit()
user_id = user.id
client = app.test_client()
with app.app_context():
self.login_as(client, db.session.get(User, user_id))
response = client.get("/account/security")
html = response.get_data(as_text=True)
self.assertEqual(response.status_code, 200)
self.assertIn('novalidate data-change-email-form', html)
self.assertIn('autocomplete="current-password"', html)
self.assertIn('autocomplete="email"', html)
self.assertIn('请输入有效的新登录邮箱。', html)
def create_app(self, directory: str): def create_app(self, directory: str):
class TestConfig(Config): class TestConfig(Config):
TESTING = True TESTING = True
@@ -143,7 +166,118 @@ class EmailAuthenticationTest(unittest.TestCase):
verify = client.get("/verify/register") verify = client.get("/verify/register")
response = client.post("/verify/register", data={"csrf_token": self.csrf(verify), "code": "123456"}) response = client.post("/verify/register", data={"csrf_token": self.csrf(verify), "code": "123456"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
with app.app_context(): self.assertTrue(User.query.filter_by(email="alice@example.com").one().is_active_account) self.assertEqual(response.location, "/home")
self.assertEqual(client.get("/home").status_code, 200)
with app.app_context():
self.assertTrue(User.query.filter_by(email="alice@example.com").one().is_active_account)
self.assertEqual(TrustedDevice.query.count(), 0)
@patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456)
def test_login_accepts_username_or_email(self, _random, _send):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.commit()
for identifier in ("Alice", "alice@example.com"):
client = app.test_client()
page = client.get("/login")
with client.session_transaction() as state:
captcha = state["captcha"]
response = client.post("/login", data={
"csrf_token": self.csrf(page), "identifier": identifier,
"password": "Password-1234!", "captcha": captcha,
})
self.assertEqual(response.location, "/verify/login")
with app.app_context():
EmailVerificationCode.query.delete()
db.session.commit()
def test_login_reports_verification_code_cooldown(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.flush()
db.session.add(EmailVerificationCode(
email=user.email,
purpose="login",
code_hash="a" * 64,
expires_at=utc_now() + timedelta(minutes=10),
requested_ip="127.0.0.1",
))
db.session.commit()
client = app.test_client()
page = client.get("/login")
with client.session_transaction() as state:
captcha = state["captcha"]
response = client.post("/login", data={
"csrf_token": self.csrf(page), "identifier": "Alice",
"password": "Password-1234!", "captcha": captcha,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.location, "/verify/login")
with app.app_context():
self.assertEqual(EmailVerificationCode.query.count(), 1)
verify_page = client.get("/verify/login")
html = verify_page.get_data(as_text=True)
self.assertIn("邮箱二次认证", html)
self.assertIn("邮箱验证码已发送,请输入验证码完成二次认证。", html)
self.assertIn('name="trust_device"', html)
def test_invalid_graphic_captcha_does_not_start_email_verification(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.commit()
client = app.test_client()
page = client.get("/login")
response = client.post("/login", data={
"csrf_token": self.csrf(page), "identifier": "Alice",
"password": "Password-1234!", "captcha": "WRONG",
})
self.assertEqual(response.status_code, 400)
self.assertIn("图形验证码错误", response.get_data(as_text=True))
self.assertIn('window.__authErrorField = "captcha"', response.get_data(as_text=True))
with app.app_context():
self.assertEqual(EmailVerificationCode.query.count(), 0)
@patch("app.routes.send_transactional_email", side_effect=EmailDeliveryError("delivery failed"))
def test_email_delivery_failure_does_not_mark_graphic_captcha(self, _send):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.commit()
client = app.test_client()
page = client.get("/login")
with client.session_transaction() as state:
captcha = state["captcha"]
response = client.post("/login", data={
"csrf_token": self.csrf(page), "identifier": "Alice",
"password": "Password-1234!", "captcha": captcha,
})
self.assertEqual(response.status_code, 503)
self.assertIn("验证码发送失败,请稍后重试。", response.get_data(as_text=True))
self.assertIn("window.__authErrorField = null", response.get_data(as_text=True))
@patch("app.routes.send_transactional_email") @patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456) @patch("app.routes.secrets.randbelow", return_value=123456)
@@ -154,10 +288,10 @@ class EmailAuthenticationTest(unittest.TestCase):
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit() user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit()
client = app.test_client(); page = client.get("/login") client = app.test_client(); page = client.get("/login")
with client.session_transaction() as state: captcha = state["captcha"] with client.session_transaction() as state: captcha = state["captcha"]
response = client.post("/login", data={"csrf_token": self.csrf(page), "email": "alice@example.com", "password": "Password-1234!", "captcha": captcha}) response = client.post("/login", data={"csrf_token": self.csrf(page), "identifier": "alice@example.com", "password": "Password-1234!", "captcha": captcha})
self.assertEqual(response.location, "/verify/login") self.assertEqual(response.location, "/verify/login")
verify = client.get("/verify/login") verify = client.get("/verify/login")
response = client.post("/verify/login", data={"csrf_token": self.csrf(verify), "code": "123456"}) response = client.post("/verify/login", data={"csrf_token": self.csrf(verify), "code": "123456", "trust_device": "on"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
with app.app_context(): self.assertEqual(TrustedDevice.query.count(), 1) with app.app_context(): self.assertEqual(TrustedDevice.query.count(), 1)
@@ -190,7 +324,7 @@ class EmailAuthenticationTest(unittest.TestCase):
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit() user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit()
page = client.get("/login") page = client.get("/login")
with client.session_transaction() as state: captcha = state["captcha"] with client.session_transaction() as state: captcha = state["captcha"]
client.post("/login", data={"csrf_token": self.csrf(page), "email": "alice@example.com", "password": "Password-1234!", "captcha": captcha}) client.post("/login", data={"csrf_token": self.csrf(page), "identifier": "alice@example.com", "password": "Password-1234!", "captcha": captcha})
for _ in range(5): for _ in range(5):
page = client.get("/verify/login"); client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "000000"}) page = client.get("/verify/login"); client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "000000"})
page = client.get("/verify/login"); response = client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "123456"}) page = client.get("/verify/login"); response = client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "123456"})