feat: refine email verification login flow
This commit is contained in:
+14
-15
@@ -1,48 +1,47 @@
|
||||
# 环境变量模板:复制为 .env 后填写实际值。不要提交 .env 或 .env.local。
|
||||
# 优先级:系统环境变量 > .env.local > .env。
|
||||
# 配置优先级:系统环境变量 > .env.local > .env。
|
||||
|
||||
# ==================== 运行模式 ====================
|
||||
# APP_ENV:production(生产)/ development(开发)。
|
||||
# DEBUG:是否开启 Flask 调试与热重载;生产必须为 false。
|
||||
APP_ENV=production
|
||||
# 是否开启 Flask 调试与热重载。生产必须为 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
|
||||
|
||||
# ==================== 管理员初始化 ====================
|
||||
# 首次启动时创建管理员;之后不会用这些值覆盖管理员密码。
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=replace-with-a-strong-password
|
||||
ADMIN_EMAIL=admin@waternetwork.cn
|
||||
ADMIN_EMAIL=
|
||||
|
||||
# ==================== 数据与文件 ====================
|
||||
# DATABASE_URL:数据库连接。默认使用项目 data 目录中的 SQLite 文件。
|
||||
# DATABASE_URL:Docker 环境下使用 /app/data 中的 SQLite 文件。
|
||||
DATABASE_URL=sqlite:////app/data/pipe_survival.db
|
||||
# 页面展示的时区。
|
||||
# APP_TIMEZONE:页面与导出文件显示时间使用的时区。
|
||||
APP_TIMEZONE=Asia/Shanghai
|
||||
|
||||
# 单个上传文件最大字节数,16 MiB = 16777216。
|
||||
# MAX_UPLOAD_BYTES:单次上传文件最大字节数,16 MiB = 16777216。
|
||||
MAX_UPLOAD_BYTES=16777216
|
||||
# 模型核心目录。
|
||||
# FUSION_MODEL_CORE_DIR:Docker 中的模型核心目录。
|
||||
FUSION_MODEL_CORE_DIR=/app/model_core
|
||||
|
||||
# ==================== 注册与邮件服务 ====================
|
||||
# ALLOW_REGISTRATION:是否允许公开自助注册。
|
||||
ALLOW_REGISTRATION=false
|
||||
|
||||
# Resend 邮件服务。发件地址必须已在 Resend 验证。
|
||||
# Resend 邮件服务:发件地址必须已在 Resend 验证。
|
||||
RESEND_API_KEY=re_xxxxxxxxx
|
||||
RESEND_FROM_EMAIL=no-reply@waternetwork.cn
|
||||
|
||||
# ==================== 登录认证与会话期限 ====================
|
||||
# 邮件验证码:有效期(分钟)、重发间隔(秒)、最大尝试次数。
|
||||
# 邮件验证码:有效期(分钟)、重发间隔(秒)和最大尝试次数。
|
||||
EMAIL_CODE_MINUTES=10
|
||||
EMAIL_CODE_RESEND_SECONDS=60
|
||||
EMAIL_CODE_MAX_ATTEMPTS=5
|
||||
# 邮箱验证码通过后,用于改密和找回密码的短时授权(分钟)。
|
||||
# FRESH_AUTH_MINUTES:改密、重置密码等敏感操作的短时授权有效期(分钟)。
|
||||
FRESH_AUTH_MINUTES=5
|
||||
# 受信设备的有效期(天)。
|
||||
# TRUSTED_DEVICE_DAYS:受信设备有效期(天)。
|
||||
TRUSTED_DEVICE_DAYS=30
|
||||
|
||||
# SESSION_COOKIE_SECURE:HTTPS 下必须为 true;本地 HTTP 开发环境设为 false。
|
||||
SESSION_COOKIE_SECURE=true
|
||||
|
||||
+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),
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -35,7 +35,7 @@
|
||||
{% else %}
|
||||
<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 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>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -49,11 +49,12 @@
|
||||
<p class="mt-1 text-sm leading-6 text-textSub">需验证当前密码、原邮箱和新邮箱。完成后将退出所有设备,并用新邮箱登录。</p>
|
||||
</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="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="new_email" type="email" 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" 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>
|
||||
</form>
|
||||
</section>
|
||||
@@ -69,10 +70,49 @@
|
||||
<form method="post" class="mt-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</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 %}
|
||||
|
||||
+17
-18
@@ -14,6 +14,7 @@
|
||||
<script>
|
||||
window.__flashMessages = {{ flashed_messages|tojson }};
|
||||
window.__pageNotice = {{ page_notice|tojson }};
|
||||
window.__authErrorField = {{ auth_error_field|tojson }};
|
||||
</script>
|
||||
<div class="sr-only" aria-hidden="true">
|
||||
{% 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>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<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">
|
||||
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">mail</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="请输入邮箱" />
|
||||
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">person</span>
|
||||
<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>
|
||||
|
||||
@@ -92,14 +93,14 @@
|
||||
</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="relative">
|
||||
<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 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="刷新验证码">
|
||||
<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="刷新图形验证码">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -143,14 +144,14 @@
|
||||
</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="relative">
|
||||
<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 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' }}">
|
||||
<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' }}">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -277,21 +278,19 @@
|
||||
form.querySelectorAll('.auth-input-error').forEach(clearFieldError);
|
||||
}
|
||||
|
||||
function fieldForServerMessage(message) {
|
||||
function fieldForServerMessage() {
|
||||
const activeForm = document.querySelector('[data-auth-form]');
|
||||
if (!activeForm) return null;
|
||||
if (message.includes('验证码')) return activeForm.querySelector('input[name="captcha"]');
|
||||
if (message.includes('密码')) return activeForm.querySelector('input[name="password"]');
|
||||
if (message.includes('邮箱')) return activeForm.querySelector('input[name="email"]');
|
||||
if (message.includes('用户名') || message.includes('显示名')) return activeForm.querySelector('input[name="username"]');
|
||||
return null;
|
||||
return window.__authErrorField
|
||||
? activeForm.querySelector(`[name="${window.__authErrorField}"]`)
|
||||
: null;
|
||||
}
|
||||
|
||||
const flashedMessages = window.__flashMessages || [];
|
||||
const pageNotice = window.__pageNotice;
|
||||
if (flashedMessages.length) {
|
||||
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);
|
||||
if (category === 'error') {
|
||||
markFieldError(serverField);
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
<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>
|
||||
<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) %}
|
||||
{% 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 %}
|
||||
{% endwith %}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input id="verificationCode" type="hidden" name="code" value="">
|
||||
<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">
|
||||
{% for index in range(6) %}
|
||||
<input
|
||||
@@ -33,7 +33,7 @@
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
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"
|
||||
data-code-digit
|
||||
{% if index == 0 %}autofocus{% endif %}
|
||||
@@ -41,6 +41,18 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</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>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from flask import session
|
||||
|
||||
from app import create_app
|
||||
from app.config import Config
|
||||
from app.email import EmailDeliveryError
|
||||
from app.extensions import db
|
||||
from app.models import EmailVerificationCode, TrustedDevice, User
|
||||
from app.routes import grant_fresh_authorization, has_fresh_authorization, valid_password
|
||||
@@ -60,6 +61,28 @@ class EmailAuthenticationTest(unittest.TestCase):
|
||||
).isoformat()
|
||||
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):
|
||||
class TestConfig(Config):
|
||||
TESTING = True
|
||||
@@ -143,7 +166,118 @@ class EmailAuthenticationTest(unittest.TestCase):
|
||||
verify = client.get("/verify/register")
|
||||
response = client.post("/verify/register", data={"csrf_token": self.csrf(verify), "code": "123456"})
|
||||
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.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()
|
||||
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), "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")
|
||||
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)
|
||||
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()
|
||||
page = client.get("/login")
|
||||
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):
|
||||
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"})
|
||||
|
||||
Reference in New Issue
Block a user