feat: harden account security flows

This commit is contained in:
2026-08-04 14:29:51 +08:00
parent 630252e2ff
commit c71b1351d6
14 changed files with 892 additions and 56 deletions
+2
View File
@@ -33,6 +33,8 @@ RESEND_FROM_EMAIL=no-reply@waternetwork.cn
EMAIL_CODE_MINUTES=10
EMAIL_CODE_RESEND_SECONDS=60
EMAIL_CODE_MAX_ATTEMPTS=5
# 邮箱验证码通过后,用于改密和找回密码的短时授权(分钟)。
FRESH_AUTH_MINUTES=5
# 受信设备的有效期(天)。
TRUSTED_DEVICE_DAYS=30
+20 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import os
import secrets
from datetime import timedelta
from pathlib import Path
from dotenv import dotenv_values
@@ -45,20 +46,33 @@ def app_env() -> str:
return os.environ.get("APP_ENV", "development").strip().lower() or "development"
def database_url() -> str:
"""返回数据库地址,并将相对 SQLite 路径固定到项目目录。"""
value = os.environ.get("DATABASE_URL", "").strip()
if not value:
return f"sqlite:///{DATA_DIR / 'pipe_survival_0331.db'}"
prefix = "sqlite:///"
if value.startswith(prefix) and not value.startswith("sqlite:////"):
return f"sqlite:///{BASE_DIR / value.removeprefix(prefix)}"
return value
class Config:
APP_ENV = app_env()
DEBUG = env_bool("DEBUG", APP_ENV in {"dev", "development", "local"})
SECRET_KEY = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
SECRET_KEY_GENERATED = not bool(os.environ.get("SECRET_KEY"))
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL",
f"sqlite:///{DATA_DIR / 'pipe_survival_0331.db'}",
)
SQLALCHEMY_DATABASE_URI = database_url()
SQLALCHEMY_TRACK_MODIFICATIONS = False
MAX_CONTENT_LENGTH = env_int("MAX_UPLOAD_BYTES", 16 * 1024 * 1024)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", APP_ENV == "production")
# 仅在用户主动勾选“保持登录状态”时使用。
REMEMBER_COOKIE_DURATION = timedelta(days=7)
REMEMBER_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SECURE = SESSION_COOKIE_SECURE
REMEMBER_COOKIE_SAMESITE = "Lax"
FUSION_MODEL_CORE_DIR = os.environ.get(
"FUSION_MODEL_CORE_DIR",
str(BASE_DIR / "model_core"),
@@ -74,6 +88,8 @@ class Config:
EMAIL_CODE_MINUTES = env_int("EMAIL_CODE_MINUTES", 10)
EMAIL_CODE_RESEND_SECONDS = env_int("EMAIL_CODE_RESEND_SECONDS", 60)
EMAIL_CODE_MAX_ATTEMPTS = env_int("EMAIL_CODE_MAX_ATTEMPTS", 5)
# 邮箱验证码通过后,可用于高风险操作的短时授权。
FRESH_AUTH_MINUTES = env_int("FRESH_AUTH_MINUTES", 5)
TRUSTED_DEVICE_DAYS = env_int("TRUSTED_DEVICE_DAYS", 30)
+34
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
from html import escape
from typing import Any
import resend
@@ -15,6 +16,39 @@ class EmailDeliveryError(RuntimeError):
"""Raised when Resend rejects or cannot deliver an email request."""
def render_transactional_email(*, title: str, content: str) -> str:
"""Render a conservative table-based email layout for broad client support."""
return f"""<!doctype html>
<html lang="zh-CN"><body style="margin:0;background:#f3f5f8;color:#172033;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="padding:32px 16px;background:#f3f5f8;"><tr><td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;">
<tr><td style="padding:24px 32px;background:#005eb8;color:#ffffff;font-size:18px;font-weight:700;">供水管道健康评估系统</td></tr>
<tr><td style="padding:32px;"><h1 style="margin:0 0 12px;font-size:22px;line-height:1.4;color:#172033;">{escape(title)}</h1>{content}</td></tr>
<tr><td style="padding:18px 32px;border-top:1px solid #e2e8f0;color:#64748b;font-size:12px;line-height:1.7;">此邮件由系统自动发送,请勿直接回复。<br>如非本人操作,请忽略此邮件并及时检查账户安全。</td></tr>
</table>
</td></tr></table>
</body></html>"""
def verification_code_email(*, code: str, minutes: int, purpose: str) -> str:
"""Render the verification email without exposing dynamic HTML to callers."""
content = f"""
<p style="margin:0;color:#475569;font-size:15px;line-height:1.8;">你正在进行{escape(purpose)}。请在 {minutes} 分钟内输入下方验证码。</p>
<div style="margin:24px 0;padding:18px;border-radius:8px;background:#eaf3ff;color:#005eb8;text-align:center;font-size:30px;font-weight:700;letter-spacing:8px;">{escape(code)}</div>
<p style="margin:0;color:#64748b;font-size:13px;line-height:1.8;">验证码仅可使用一次,请勿向任何人透露。</p>"""
return render_transactional_email(title="邮箱验证码", content=content)
def password_reset_notice_email(*, username: str, reset_url: str) -> str:
"""Render an administrator-initiated password reset notification."""
safe_url = escape(reset_url, quote=True)
content = f"""
<p style="margin:0;color:#475569;font-size:15px;line-height:1.8;">{escape(username)},管理员已要求你重置密码。</p>
<p style="margin:16px 0 24px;color:#475569;font-size:15px;line-height:1.8;">请通过下方按钮进入找回密码流程,系统会向本邮箱发送一次性验证码。</p>
<p style="margin:0;"><a href="{safe_url}" style="display:inline-block;padding:12px 20px;border-radius:6px;background:#005eb8;color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;">重置密码</a></p>"""
return render_transactional_email(title="请重置密码", content=content)
def send_transactional_email(*, to: str, subject: str, html: str) -> dict[str, Any]:
"""Send one application-generated email through Resend."""
api_key = current_app.config["RESEND_API_KEY"]
+93 -20
View File
@@ -4,14 +4,20 @@ import hashlib
import os
import re
import secrets
from datetime import timedelta
from datetime import datetime, timedelta
from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, send_file, session, url_for
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy.orm import joinedload
from .config import BASE_DIR
from .email import EmailConfigurationError, EmailDeliveryError, send_transactional_email
from .email import (
EmailConfigurationError,
EmailDeliveryError,
password_reset_notice_email,
send_transactional_email,
verification_code_email,
)
from .extensions import db
from .models import AppSetting, EmailVerificationCode, TrustedDevice, UploadRecord, User
from .prediction import PredictionError, run_prediction
@@ -52,7 +58,47 @@ def valid_email(value: str) -> bool:
def valid_password(password: str) -> bool:
return 12 <= len(password) <= 128
return (
12 <= len(password) <= 128
and not any(character.isspace() for character in password)
and any(character.islower() for character in password)
and any(character.isupper() for character in password)
and any(character.isdigit() for character in password)
and any(not character.isalnum() for character in password)
)
PASSWORD_RULE_MESSAGE = "密码须为12至128位,包含大写字母、小写字母、数字和特殊字符,且不能含空格。"
def grant_fresh_authorization(user: User, purpose: str) -> None:
"""Grant a short, session-bound authorization after successful email MFA."""
session[f"fresh_auth_{purpose}"] = {
"user_id": user.id,
"auth_version": user.auth_version,
"expires_at": (
utc_now() + timedelta(minutes=current_app.config["FRESH_AUTH_MINUTES"])
).isoformat(),
}
def has_fresh_authorization(user: User, purpose: str) -> bool:
authorization = session.get(f"fresh_auth_{purpose}")
if not isinstance(authorization, dict):
return False
try:
expires_at = datetime.fromisoformat(authorization["expires_at"])
except (KeyError, TypeError, ValueError):
return False
return (
authorization.get("user_id") == user.id
and authorization.get("auth_version") == user.auth_version
and expires_at > utc_now()
)
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 = ""):
@@ -136,10 +182,10 @@ def issue_email_code(email: str, purpose: str) -> bool:
send_transactional_email(
to=email,
subject=f"供水管道健康评估系统:{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码",
html=(
"<p>你的验证码是:</p>"
f"<p style='font-size:28px;letter-spacing:6px'><strong>{code}</strong></p>"
f"<p>验证码 {current_app.config['EMAIL_CODE_MINUTES']} 分钟内有效,请勿向他人透露。</p>"
html=verification_code_email(
code=code,
minutes=current_app.config["EMAIL_CODE_MINUTES"],
purpose=EMAIL_CODE_LABELS.get(purpose, "身份验证"),
),
)
except (EmailConfigurationError, EmailDeliveryError):
@@ -212,7 +258,13 @@ def login_response(user: User, remember: bool, trust_device: bool = False):
def verification_page(title: str, purpose: str):
return render_template("verification.html", title=title, purpose=purpose, email=session.get("pending_email", ""))
return render_template(
"verification.html",
title=title,
purpose=purpose,
email=session.get("pending_email", ""),
resend_seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"],
)
@bp.route("/")
@@ -248,7 +300,7 @@ def register():
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", "密码长度应为12至128位")
if not valid_password(password): return render_auth_error("register", PASSWORD_RULE_MESSAGE)
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()
@@ -271,7 +323,9 @@ def verify_email(purpose: str):
user.email_verified_at = utc_now(); user.is_active_account = True; db.session.commit(); session.clear(); flash("邮箱验证成功,请登录。", "info"); return redirect(url_for("main.login"))
if purpose == "login":
remember = bool(session.get("pending_remember")); session.clear(); return login_response(user, remember, trust_device=True)
if purpose == "reset": session["reset_verified_user_id"] = user.id; return redirect(url_for("main.set_password"))
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")
@@ -282,7 +336,8 @@ def verify_email(purpose: str):
if User.query.filter(User.email == email, User.id != user.id).first():
flash("该邮箱已被使用。", "error"); return redirect(url_for("main.account_security"))
user.email = email; user.email_verified_at = utc_now(); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); logout_user(); session.clear(); flash("邮箱已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
session["password_change_verified"] = True; return redirect(url_for("main.account_security"))
grant_fresh_authorization(user, "password_change")
return redirect(url_for("main.account_security"))
@bp.route("/verify/<purpose>/resend", methods=["POST"])
@@ -307,20 +362,33 @@ def forgot_password():
@bp.route("/set-password", methods=["GET", "POST"])
def set_password():
user = db.session.get(User, session.get("reset_verified_user_id"))
if not user: return redirect(url_for("main.forgot_password"))
authorization = session.get("fresh_auth_password_reset")
user = db.session.get(User, authorization.get("user_id")) if isinstance(authorization, dict) else None
if not user or not has_fresh_authorization(user, "password_reset"):
consume_fresh_authorization("password_reset")
return redirect(url_for("main.forgot_password"))
if request.method == "GET": return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password"))
password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "")
if not valid_password(password) or password != confirm: flash("密码应为12至128位,且两次输入一致。", "error"); return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")), 400
user.set_password(password); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); session.clear(); flash("密码已重置,请重新登录。", "info"); return redirect(url_for("main.login"))
if not valid_password(password) or password != confirm: flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致。", "error"); return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")), 400
user.set_password(password); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); consume_fresh_authorization("password_reset"); session.clear(); flash("密码已重置,请重新登录。", "info"); return redirect(url_for("main.login"))
@bp.route("/account/security", methods=["GET", "POST"])
@login_required
def account_security():
if request.method == "GET": return render_template("account_security.html")
if request.method == "GET":
return render_template(
"account_security.html",
password_change_authorized=has_fresh_authorization(
current_user,
"password_change",
),
)
action = request.form.get("action")
if action == "revoke_devices":
if not current_user.check_password(request.form.get("current_password", "")):
flash("当前密码不正确,未撤销受信设备。", "error")
return redirect(url_for("main.account_security"))
current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); logout_user(); flash("所有受信设备已撤销,请重新登录。", "info"); return redirect(url_for("main.login"))
if not current_user.check_password(request.form.get("current_password", "")):
flash("当前密码不正确。", "error"); return redirect(url_for("main.account_security"))
@@ -339,10 +407,12 @@ def account_security():
@bp.route("/account/change-password", methods=["POST"])
@login_required
def change_password():
if not session.pop("password_change_verified", False): abort(403)
if not has_fresh_authorization(current_user, "password_change"):
consume_fresh_authorization("password_change")
abort(403)
password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "")
if not valid_password(password) or password != confirm or current_user.check_password(password): flash("密码应为12至128位、两次一致且不能与当前密码相同。", "error"); return redirect(url_for("main.account_security"))
current_user.set_password(password); current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); logout_user(); flash("密码已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
if not valid_password(password) or password != confirm or current_user.check_password(password): flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致且不能与当前密码相同。", "error"); return redirect(url_for("main.account_security"))
current_user.set_password(password); current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); consume_fresh_authorization("password_change"); logout_user(); flash("密码已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
@bp.route("/logout", methods=["POST"])
@@ -379,7 +449,10 @@ def admin_password_reset(user_id: int):
send_transactional_email(
to=user.email,
subject="供水管道健康评估系统:请重置密码",
html=f"<p>{user.username},管理员已要求你重置密码。</p><p>请访问 <a href='{url_for('main.forgot_password', _external=True)}'>找回密码</a>,系统会将一次性验证码发送到本邮箱。</p>",
html=password_reset_notice_email(
username=user.username,
reset_url=url_for("main.forgot_password", _external=True),
),
)
except (EmailConfigurationError, EmailDeliveryError):
return jsonify({"error": "邮件发送失败"}), 503
+1
View File
@@ -23,6 +23,7 @@ services:
EMAIL_CODE_MINUTES: ${EMAIL_CODE_MINUTES:-10}
EMAIL_CODE_RESEND_SECONDS: ${EMAIL_CODE_RESEND_SECONDS:-60}
EMAIL_CODE_MAX_ATTEMPTS: ${EMAIL_CODE_MAX_ATTEMPTS:-5}
FRESH_AUTH_MINUTES: ${FRESH_AUTH_MINUTES:-5}
TRUSTED_DEVICE_DAYS: ${TRUSTED_DEVICE_DAYS:-30}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
ports:
+1 -1
View File
File diff suppressed because one or more lines are too long
+74 -8
View File
@@ -1,12 +1,78 @@
{% extends "base.html" %}
{% set active_page = "security" %}
{% block title %}账户安全 | 供水管道健康评估系统{% endblock %}
{% block content %}
<section class="mx-auto max-w-xl rounded-xl border border-line bg-white p-6 shadow-panel">
<h1 class="text-2xl font-extrabold">账户安全</h1>
<p class="mt-2 text-sm text-textSub">登录邮箱:{{ current_user.email }}</p>
{% with messages=get_flashed_messages(with_categories=true) %}{% for c,m in messages %}<p class="mt-3 text-sm text-dangerText">{{ m }}</p>{% endfor %}{% endwith %}
<form method="post" class="mt-6 space-y-3"><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" placeholder="当前密码"><button class="ui-btn ui-btn-primary">发送修改密码验证码</button></form>
<form method="post" class="mt-6 space-y-3 border-t border-line pt-5"><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="新邮箱"><button class="ui-btn ui-btn-secondary">验证并更换邮箱</button></form>
<form method="post" class="mt-6 border-t border-line pt-5"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="action" value="revoke_devices"><button class="ui-btn ui-btn-secondary">撤销所有受信设备</button></form>
{% if session.get('password_change_verified') %}<form method="post" action="{{ url_for('main.change_password') }}" class="mt-6 space-y-3 border-t border-line pt-5"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="password" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新密码"><input name="password_confirm" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="确认新密码"><button class="ui-btn ui-btn-primary">更新密码</button></form>{% endif %}
<section class="mx-auto max-w-3xl rounded-xl border border-line bg-white shadow-panel">
<header class="border-b border-line px-6 py-6 sm:px-8">
<h1 class="text-2xl font-extrabold">账户安全</h1>
<p class="mt-2 text-sm text-textSub">登录邮箱:{{ current_user.email }}</p>
</header>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<p class="mx-6 mt-5 rounded-lg p-3 text-sm {{ 'bg-dangerSoft text-dangerText' if category == 'error' else 'bg-blueSoft text-primary' }} sm:mx-8">{{ message }}</p>
{% endfor %}
{% endwith %}
<div class="divide-y divide-line">
<section class="px-6 py-7 sm:px-8">
<div class="flex items-start gap-3">
<span class="material-symbols-outlined mt-0.5 text-primary">password</span>
<div>
<h2 class="font-extrabold">修改密码</h2>
<p class="mt-1 text-sm leading-6 text-textSub">需验证当前密码,并向登录邮箱发送验证码。新密码须包含大小写字母、数字和特殊字符。</p>
</div>
</div>
{% if password_change_authorized %}
<form method="post" action="{{ url_for('main.change_password') }}" class="mt-5 grid gap-3 sm:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="password" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新密码">
<input name="password_confirm" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入新密码">
<button class="ui-btn ui-btn-primary sm:col-span-2 sm:w-fit">确认更新密码</button>
</form>
{% 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="输入当前密码">
<button class="ui-btn ui-btn-primary">发送密码验证码</button>
</form>
{% endif %}
</section>
<section class="px-6 py-7 sm:px-8">
<div class="flex items-start gap-3">
<span class="material-symbols-outlined mt-0.5 text-primary">alternate_email</span>
<div>
<h2 class="font-extrabold">更换登录邮箱</h2>
<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">
<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="新登录邮箱">
<button class="ui-btn ui-btn-secondary sm:col-span-2 sm:w-fit">验证并更换邮箱</button>
</form>
</section>
<section class="px-6 py-7 sm:px-8">
<div class="flex items-start gap-3">
<span class="material-symbols-outlined mt-0.5 text-primary">devices</span>
<div>
<h2 class="font-extrabold">受信设备</h2>
<p class="mt-1 text-sm leading-6 text-textSub">需输入当前密码确认。撤销后,所有设备下次登录都需要重新完成邮箱二次认证。</p>
</div>
</div>
<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="输入当前密码以确认">
<button class="ui-btn ui-btn-secondary">撤销所有受信设备</button>
</form>
</section>
</div>
</section>
{% endblock %}
+2 -1
View File
@@ -20,7 +20,7 @@
<a href="{{ url_for('main.result_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'result' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">结果</a>
<a href="{{ url_for('main.history_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'history' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">历史</a>
<a href="{{ url_for('main.reference_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'reference' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">文档</a>
<a href="{{ url_for('main.account_security') }}" class="flex h-full items-center px-3 text-slate-500 border-b-2 border-transparent hover:text-primary">安全</a>
<a href="{{ url_for('main.account_security') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'security' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">安全</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_dashboard') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'admin' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">管理</a>
{% endif %}
@@ -43,6 +43,7 @@
<a href="{{ url_for('main.result_page') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'result' else 'text-slate-500' }}">结果</a>
<a href="{{ url_for('main.history_page') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'history' else 'text-slate-500' }}">历史</a>
<a href="{{ url_for('main.reference_page') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'reference' else 'text-slate-500' }}">文档</a>
<a href="{{ url_for('main.account_security') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'security' else 'text-slate-500' }}">安全</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_dashboard') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'admin' else 'text-slate-500' }}">管理</a>
{% endif %}
+455 -11
View File
@@ -1,18 +1,462 @@
<!doctype html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ '注册' if mode == 'register' else '登录' }} | 供水管道健康评估系统</title>
<link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet" />
<style>
.password-toggle {
position: absolute;
right: .75rem;
top: 50%;
display: inline-flex;
width: 32px;
height: 32px;
transform: translateY(-50%);
align-items: center;
justify-content: center;
border: 0;
border-radius: .5rem;
background: transparent;
color: #64748b;
padding: 0;
transition: background-color .15s ease, color .15s ease;
}
.password-toggle:hover {
background: rgba(148, 163, 184, .16);
color: #005EB8;
}
.password-toggle:focus-visible {
outline: 2px solid #005EB8;
outline-offset: 2px;
}
.password-toggle:disabled {
cursor: not-allowed;
opacity: .45;
}
.password-toggle .material-symbols-outlined {
font-size: 20px;
line-height: 1;
}
.auth-input-error {
border-color: #dc2626 !important;
background: #fff7f7 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, .12) !important;
}
.captcha-token {
-webkit-user-select: none;
user-select: none;
}
.dot-grid {
background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0);
background-size: 42px 42px;
}
.panel-frame {
box-shadow: none;
border: none;
}
.gradient-board {
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%);
}
.auth-alert {
pointer-events: none;
opacity: 0;
transform: translateY(.5rem);
}
.auth-alert::before {
content: '';
position: absolute;
left: 24px;
top: -7px;
width: 14px;
height: 14px;
transform: rotate(45deg);
border-left: 1px solid currentColor;
border-top: 1px solid currentColor;
background: #fff;
color: #bfdbfe;
}
.auth-alert.is-error::before {
color: #fecaca;
}
.auth-alert.is-visible {
pointer-events: auto;
opacity: 1;
transform: translateY(0);
}
@media (min-width: 1280px) {
.auth-alert {
transform: translateX(.75rem);
}
.auth-alert::before {
left: -7px;
top: var(--auth-alert-arrow-top, 44px);
border: 0;
border-left: 1px solid currentColor;
border-bottom: 1px solid currentColor;
}
.auth-alert.is-visible {
transform: translateX(0);
}
}
</style>
</head>
<body class="min-h-screen bg-page text-textMain">
<main class="mx-auto flex min-h-screen max-w-md items-center px-5">
<section class="w-full rounded-2xl border border-line bg-white p-7 shadow-panel">
<header class="mb-7 text-center"><span class="material-symbols-outlined text-4xl text-primary">water_drop</span><h1 class="mt-2 text-2xl font-extrabold">供水管道健康评估系统</h1><p class="mt-2 text-sm text-textSub">{{ '使用邮箱创建账号' if mode == 'register' else '使用邮箱安全登录' }}</p></header>
{% with messages = get_flashed_messages(with_categories=true) %}{% for category, message in messages %}<p class="mb-4 rounded-lg p-3 text-sm {{ 'bg-dangerSoft text-dangerText' if category == 'error' else 'bg-blueSoft text-primary' }}">{{ message }}</p>{% endfor %}{% endwith %}
<nav class="mb-5 flex gap-5 border-b border-line text-sm font-bold"><a class="pb-3 {{ 'border-b-2 border-primary text-primary' if mode == 'login' else 'text-textSub' }}" href="{{ url_for('main.login') }}">登录</a><a class="pb-3 {{ 'border-b-2 border-primary text-primary' if mode == 'register' else 'text-textSub' }}" href="{{ url_for('main.register') }}">注册</a></nav>
{% if mode == 'login' %}{% include "_login_form.html" %}{% else %}{% include "_register_form.html" %}{% endif %}
<body class="bg-page min-h-screen text-textMain">
{% set page_notice = "当前未开放自助注册。系统仅支持管理员分配账号,请联系管理员完成账号开通后再登录。" if mode == 'register' and not allow_registration else none %}
{% with flashed_messages = get_flashed_messages(with_categories=true) %}
<script>
window.__flashMessages = {{ flashed_messages|tojson }};
window.__pageNotice = {{ page_notice|tojson }};
</script>
<div class="sr-only" aria-hidden="true">
{% for category, message in flashed_messages %}{{ message }}{% endfor %}
{% if page_notice %}{{ page_notice }}{% endif %}
</div>
{% endwith %}
<div class="min-h-screen grid lg:grid-cols-[1.4fr_1fr]">
<section class="gradient-board hidden lg:flex flex-col justify-between px-16 py-14 text-white">
<div></div>
<div>
<div class="mb-8 flex items-center gap-3 text-[26px] font-extrabold tracking-tight text-white">
<span class="material-symbols-outlined text-[32px]">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<h1 class="text-[52px] leading-[1.5] font-extrabold tracking-tight">
供水管道健康状态与<br>
剩余寿命评估系统<br>
</h1>
<p class="mt-6 text-white/75 text-[15px] max-w-[440px] leading-7">上传管网数据,自动生成健康状态评估、剩余寿命预测与电子表格报告。</p>
</div>
<div class="text-white/50 text-[12px]">© {{ now_year() }} 供水管道健康评估系统</div>
</section>
</main>
<section id="authPanel" class="relative bg-white flex flex-col justify-between min-h-screen">
<div class="flex-1 flex items-center px-7 sm:px-12 md:px-14 py-12">
<div id="authCard" class="relative w-full max-w-[360px] min-h-[620px] mx-auto">
<div id="alertBox" class="auth-alert fixed left-7 right-7 top-6 z-50 hidden rounded-lg border bg-white p-3.5 shadow-[0_18px_45px_rgba(15,23,42,.16)] transition-all duration-200 ease-out sm:left-12 sm:right-12 xl:left-auto xl:right-auto xl:w-[320px]" role="status" aria-live="polite">
<div class="flex items-start gap-3">
<span id="alertIconWrap" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<span id="alertIcon" class="material-symbols-outlined text-lg">priority_high</span>
</span>
<div class="min-w-0 flex-1 pt-0.5">
<div id="alertTitle" class="text-sm font-extrabold text-textMain"></div>
<div id="alertMessage" class="mt-1 text-sm leading-5 text-textSub"></div>
</div>
<button id="alertClose" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700" type="button" aria-label="关闭通知">
<span class="material-symbols-outlined text-base">close</span>
</button>
</div>
</div>
<div class="mb-8 flex items-center justify-center gap-2 text-center text-[18px] font-extrabold text-primary lg:hidden">
<span class="material-symbols-outlined text-[28px]">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<h2 class="text-center text-[44px] lg:text-[40px] font-extrabold tracking-tight {{ 'mb-7' if mode == 'register' else 'mb-10' }}">系统门户</h2>
<div class="flex items-center gap-8 text-[13px] font-semibold border-b border-slate-200 mb-7">
<a href="{{ url_for('main.login') }}" class="border-b-2 py-3 {{ 'text-primary border-primary' if mode == 'login' else 'text-slate-500 border-transparent' }}">登录</a>
<a href="{{ url_for('main.register') }}" class="border-b-2 py-3 {{ 'text-primary border-primary' if mode == 'register' else 'text-slate-500 border-transparent' }}">注册</a>
</div>
{% if mode == 'login' %}
<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>
<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="请输入邮箱" />
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500">密码</label>
<a href="{{ url_for('main.forgot_password') }}" tabindex="-1" class="text-[12px] font-semibold text-primary transition hover:text-primaryDeep">找回密码</a>
</div>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">lock</span>
<input id="loginPassword" name="password" type="password" required data-field-label="密码" class="w-full pl-11 pr-12 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入密码" />
<button class="password-toggle" type="button" data-password-toggle="loginPassword" aria-label="显示密码" aria-pressed="false" tabindex="-1">
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</div>
</div>
<div>
<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="请输入验证码" />
</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="刷新验证码">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</div>
<label class="inline-flex items-center gap-2 text-sm text-slate-500">
<input type="checkbox" name="remember" class="rounded border-slate-300 text-primary focus:ring-primary" />
保持登录状态 7 天
</label>
<button class="ui-btn ui-btn-lg ui-btn-primary w-full mt-2">
登录
<span class="material-symbols-outlined text-lg">arrow_forward</span>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.register') }}" class="space-y-4" 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>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">badge</span>
<input name="username" 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>
<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" {{ '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>
<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">lock</span>
<input id="registerPassword" name="password" type="password" minlength="12" required data-field-label="密码" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-12 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="12位以上,含大小写、数字和特殊字符" />
<button class="password-toggle" type="button" data-password-toggle="registerPassword" aria-label="显示密码" aria-pressed="false" tabindex="-1" {{ 'disabled' if not allow_registration }}>
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</div>
</div>
<div>
<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="请输入验证码" />
</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' }}">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</div>
<button {{ 'disabled' if not allow_registration }} class="ui-btn ui-btn-lg ui-btn-primary w-full mt-2">
注册
<span class="material-symbols-outlined text-lg">person_add</span>
</button>
</form>
{% endif %}
</div>
</div>
</section>
</div>
<script>
(() => {
const alertBox = document.getElementById('alertBox');
const alertIconWrap = document.getElementById('alertIconWrap');
const alertIcon = document.getElementById('alertIcon');
const alertTitle = document.getElementById('alertTitle');
const alertMessage = document.getElementById('alertMessage');
const alertClose = document.getElementById('alertClose');
const authCard = document.getElementById('authCard');
let alertTimer = null;
let alertHideTimer = null;
let alertAnchor = null;
function hideAlert() {
clearTimeout(alertTimer);
clearTimeout(alertHideTimer);
if (!alertBox) return;
alertBox.classList.remove('is-visible');
alertHideTimer = setTimeout(() => {
alertBox.classList.add('hidden');
}, 200);
}
function positionAlert(anchor) {
if (!alertBox || !authCard) return;
const cardRect = authCard.getBoundingClientRect();
const anchorRect = anchor?.getBoundingClientRect();
const useSidePopover = window.matchMedia('(min-width: 1280px)').matches;
if (!useSidePopover) {
alertBox.style.left = '';
alertBox.style.right = '';
alertBox.style.top = '';
alertBox.style.setProperty('--auth-alert-arrow-top', '28px');
return;
}
const width = 320;
const gap = 18;
const viewportPadding = 16;
const desiredLeft = cardRect.right + gap;
const left = Math.min(desiredLeft, window.innerWidth - width - viewportPadding);
const targetCenter = anchorRect ? anchorRect.top + (anchorRect.height / 2) : cardRect.top + 116;
const top = Math.max(viewportPadding, Math.min(targetCenter - 42, window.innerHeight - 140));
const arrowTop = Math.max(22, Math.min(targetCenter - top - 7, 92));
alertBox.style.left = `${left}px`;
alertBox.style.right = 'auto';
alertBox.style.top = `${top}px`;
alertBox.style.setProperty('--auth-alert-arrow-top', `${arrowTop}px`);
}
function showAppNotification(message, type = 'info', title, anchor) {
if (!alertBox || !alertIconWrap || !alertIcon || !alertTitle || !alertMessage) return;
clearTimeout(alertTimer);
clearTimeout(alertHideTimer);
alertAnchor = anchor || null;
positionAlert(anchor);
alertBox.classList.remove('hidden', 'border-red-200', 'border-blue-200', 'is-error');
alertIconWrap.classList.remove('bg-dangerSoft', 'text-dangerText', 'bg-blueSoft', 'text-primary');
if (type === 'error') {
alertBox.classList.add('border-red-200', 'is-error');
alertIconWrap.classList.add('bg-dangerSoft', 'text-dangerText');
alertIcon.textContent = 'priority_high';
alertTitle.textContent = title || '操作未完成';
} else {
alertBox.classList.add('border-blue-200');
alertIconWrap.classList.add('bg-blueSoft', 'text-primary');
alertIcon.textContent = 'info';
alertTitle.textContent = title || '提示';
}
alertMessage.textContent = message;
requestAnimationFrame(() => {
alertBox.classList.add('is-visible');
});
alertTimer = setTimeout(hideAlert, 10000);
}
if (alertClose) {
alertClose.addEventListener('click', hideAlert);
}
window.addEventListener('resize', () => {
if (alertBox && alertBox.classList.contains('is-visible')) {
positionAlert(alertAnchor);
}
});
function markFieldError(field) {
if (!field) return;
field.classList.add('auth-input-error');
field.setAttribute('aria-invalid', 'true');
}
function clearFieldError(field) {
field.classList.remove('auth-input-error');
field.removeAttribute('aria-invalid');
}
function clearFormErrors(form) {
form.querySelectorAll('.auth-input-error').forEach(clearFieldError);
}
function fieldForServerMessage(message) {
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;
}
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;
showAppNotification(message, category === 'error' ? 'error' : 'info', undefined, serverField);
if (category === 'error') {
markFieldError(serverField);
}
} else if (pageNotice) {
showAppNotification(pageNotice);
}
document.querySelectorAll('[data-auth-form]').forEach((form) => {
form.querySelectorAll('input').forEach((field) => {
field.addEventListener('input', () => clearFieldError(field));
});
form.addEventListener('submit', (event) => {
clearFormErrors(form);
const fields = Array.from(form.querySelectorAll('input[required]:not(:disabled)'));
const emptyField = fields.find((field) => !field.value.trim());
if (emptyField) {
event.preventDefault();
const label = emptyField.dataset.fieldLabel || '必填项';
showAppNotification(`${label}不能为空`, 'error', undefined, emptyField);
markFieldError(emptyField);
emptyField.focus();
return;
}
const shortPassword = fields.find((field) => {
const minLength = Number(field.getAttribute('minlength'));
return minLength > 0 && field.value.length < minLength;
});
if (shortPassword) {
event.preventDefault();
const minLength = shortPassword.getAttribute('minlength');
showAppNotification(`密码至少需要 ${minLength}`, 'error', undefined, shortPassword);
markFieldError(shortPassword);
shortPassword.focus();
}
});
});
document.querySelectorAll('[data-password-toggle]').forEach((toggle) => {
const input = document.getElementById(toggle.dataset.passwordToggle);
const icon = toggle.querySelector('.material-symbols-outlined');
if (!input || !icon) return;
toggle.addEventListener('click', () => {
const shouldShow = input.type === 'password';
input.type = shouldShow ? 'text' : 'password';
icon.textContent = shouldShow ? 'visibility_off' : 'visibility';
toggle.setAttribute('aria-label', shouldShow ? '隐藏密码' : '显示密码');
toggle.setAttribute('aria-pressed', shouldShow ? 'true' : 'false');
});
});
document.querySelectorAll('[data-captcha-token]').forEach((token) => {
token.addEventListener('contextmenu', (event) => event.preventDefault());
token.addEventListener('selectstart', (event) => event.preventDefault());
});
document.addEventListener('copy', (event) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const includesCaptcha = Array.from(document.querySelectorAll('[data-captcha-token]')).some((token) => {
return range.intersectsNode(token);
});
if (includesCaptcha) {
event.preventDefault();
}
});
document.getElementById('forgotPasswordBtn')?.addEventListener('click', () => {
showAppNotification('请联系管理员获取一次性重置链接后设置新密码。', 'info', '密码重置');
});
})();
</script>
</body>
</html>
+1 -1
View File
@@ -1 +1 @@
{% extends "base.html" %}{% block content %}<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-6 shadow-panel"><h1 class="text-2xl font-extrabold">{{ title }}</h1>{% with messages=get_flashed_messages(with_categories=true) %}{% for c,m in messages %}<p class="mt-3 text-sm text-dangerText">{{ m }}</p>{% endfor %}{% endwith %}<form method="post" action="{{ action }}" class="mt-5 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="password" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新密码12 至 128 位)"><input name="password_confirm" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入新密码"><button class="ui-btn ui-btn-primary w-full">保存密码</button></form></section>{% endblock %}
{% extends "base.html" %}{% block content %}<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-6 shadow-panel"><h1 class="text-2xl font-extrabold">{{ title }}</h1><p class="mt-2 text-sm text-textSub">12 至 128 位,须包含大写字母、小写字母、数字和特殊字符,且不能含空格。</p>{% with messages=get_flashed_messages(with_categories=true) %}{% for c,m in messages %}<p class="mt-3 text-sm text-dangerText">{{ m }}</p>{% endfor %}{% endwith %}<form method="post" action="{{ action }}" class="mt-5 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="password" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新密码"><input name="password_confirm" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入新密码"><button class="ui-btn ui-btn-primary w-full">保存密码</button></form></section>{% endblock %}
+120 -1
View File
@@ -1 +1,120 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title }}</title><link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet"></head><body class="min-h-screen bg-page"><main class="mx-auto flex min-h-screen max-w-md items-center px-5"><section class="w-full rounded-2xl border border-line bg-white p-7 shadow-panel"><h1 class="text-2xl font-extrabold">{{ title }}</h1><p class="mt-2 text-sm text-textSub">验证码已发送至 {{ email }},10 分钟内有效。</p>{% with messages=get_flashed_messages(with_categories=true) %}{% for c,m in messages %}<p class="mt-4 text-sm text-dangerText">{{ m }}</p>{% endfor %}{% endwith %}<form method="post" class="mt-6 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="block text-sm font-semibold">6 位验证码<input name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus class="mt-1 w-full rounded-lg border border-line px-3 py-2 text-center text-xl tracking-[.5em]"></label><button class="ui-btn ui-btn-primary w-full">验证</button></form><form method="post" action="{{ url_for('main.resend_code', purpose=purpose) }}" class="mt-3"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-sm font-semibold text-primary">重新发送验证码</button></form></section></main></body></html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} | 供水管道健康评估系统</title>
<link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet">
</head>
<body class="min-h-screen bg-page text-textMain">
<main class="mx-auto flex min-h-screen max-w-md items-center px-5">
<section class="w-full rounded-2xl border border-line bg-white p-7 shadow-panel">
<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>
{% 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>
{% endfor %}
{% endwith %}
<form method="post" class="mt-7" id="verificationForm">
<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>
<div class="mt-3 grid grid-cols-6 gap-2" id="codeInputs">
{% for index in range(6) %}
<input
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
aria-label="验证码第 {{ index + 1 }} 位"
class="h-12 min-w-0 rounded-lg border border-line text-center text-xl font-bold tracking-wide focus:border-primary focus:ring-primary"
data-code-digit
{% if index == 0 %}autofocus{% endif %}
>
{% endfor %}
</div>
</fieldset>
<button id="verifyButton" class="ui-btn ui-btn-primary mt-6 w-full" type="submit">验证并继续</button>
</form>
<form method="post" action="{{ url_for('main.resend_code', purpose=purpose) }}" class="mt-4 text-center">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button id="resendButton" class="text-sm font-semibold text-primary disabled:cursor-not-allowed disabled:text-slate-400" type="submit" disabled>
<span id="resendText">{{ resend_seconds }} 秒后可重新发送</span>
</button>
</form>
</section>
</main>
<script>
(() => {
const inputs = Array.from(document.querySelectorAll('[data-code-digit]'));
const hiddenCode = document.getElementById('verificationCode');
const form = document.getElementById('verificationForm');
const resendButton = document.getElementById('resendButton');
const resendText = document.getElementById('resendText');
let remainingSeconds = {{ resend_seconds|int }};
function updateCode() {
hiddenCode.value = inputs.map((input) => input.value).join('');
}
function fillDigits(value, startIndex = 0) {
const digits = value.replace(/\D/g, '').slice(0, inputs.length - startIndex);
for (let index = 0; index < digits.length; index += 1) {
inputs[startIndex + index].value = digits[index];
}
updateCode();
const nextIndex = Math.min(startIndex + digits.length, inputs.length - 1);
inputs[nextIndex].focus();
}
inputs.forEach((input, index) => {
input.addEventListener('input', () => {
if (input.value.length > 1) {
fillDigits(input.value, index);
return;
}
input.value = input.value.replace(/\D/g, '');
updateCode();
if (input.value && index < inputs.length - 1) inputs[index + 1].focus();
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Backspace' && !input.value && index > 0) {
inputs[index - 1].focus();
}
});
input.addEventListener('paste', (event) => {
event.preventDefault();
fillDigits(event.clipboardData.getData('text'), index);
});
});
form.addEventListener('submit', (event) => {
updateCode();
if (hiddenCode.value.length !== inputs.length) {
event.preventDefault();
inputs.find((input) => !input.value)?.focus();
}
});
const timer = window.setInterval(() => {
remainingSeconds -= 1;
if (remainingSeconds <= 0) {
window.clearInterval(timer);
resendButton.disabled = false;
resendText.textContent = '重新发送验证码';
return;
}
resendText.textContent = `${remainingSeconds} 秒后可重新发送`;
}, 1000);
})();
</script>
</body>
</html>
+56 -8
View File
@@ -2,16 +2,64 @@ from __future__ import annotations
import re
import unittest
from datetime import timedelta
from tempfile import TemporaryDirectory
from unittest.mock import patch
from flask import session
from app import create_app
from app.config import Config
from app.extensions import db
from app.models import EmailVerificationCode, TrustedDevice, User
from app.routes import grant_fresh_authorization, has_fresh_authorization, valid_password
from app.time_utils import utc_now
class EmailAuthenticationTest(unittest.TestCase):
def test_password_policy_requires_all_character_categories(self):
self.assertTrue(valid_password("Strong-password-123!"))
self.assertFalse(valid_password("lowercase-password-123!"))
self.assertFalse(valid_password("UPPERCASE-PASSWORD-123!"))
self.assertFalse(valid_password("NoSpecialPassword123"))
self.assertFalse(valid_password("NoWhitespace-123 !"))
def test_verification_page_uses_six_code_inputs_and_initial_resend_delay(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
client = app.test_client()
with client.session_transaction() as state:
state["pending_email"] = "alice@example.com"
state["pending_purpose"] = "login"
response = client.get("/verify/login")
html = response.get_data(as_text=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(html.count("data-code-digit\n"), 6)
self.assertIn("60 秒后可重新发送", html)
self.assertIn('id="resendButton"', html)
def test_fresh_authorization_is_bound_to_user_and_expires(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
with app.test_request_context():
user = db.session.get(User, user_id)
grant_fresh_authorization(user, "password_change")
self.assertTrue(has_fresh_authorization(user, "password_change"))
session["fresh_auth_password_change"]["expires_at"] = (
utc_now() - timedelta(seconds=1)
).isoformat()
self.assertFalse(has_fresh_authorization(user, "password_change"))
def create_app(self, directory: str):
class TestConfig(Config):
TESTING = True
@@ -41,7 +89,7 @@ class EmailAuthenticationTest(unittest.TestCase):
app = self.create_app(directory); client = app.test_client()
page = client.get("/register")
with client.session_transaction() as state: captcha = state["captcha"]
response = client.post("/register", data={"csrf_token": self.csrf(page), "username": "Alice", "email": "Alice@example.com", "password": "password-1234", "captcha": captcha})
response = client.post("/register", data={"csrf_token": self.csrf(page), "username": "Alice", "email": "Alice@example.com", "password": "Password-1234!", "captcha": captcha})
self.assertEqual(response.status_code, 302)
with app.app_context():
user = User.query.filter_by(email="alice@example.com").one()
@@ -58,10 +106,10 @@ class EmailAuthenticationTest(unittest.TestCase):
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 = 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), "email": "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"})
@@ -74,7 +122,7 @@ class EmailAuthenticationTest(unittest.TestCase):
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(); db.session.add(TrustedDevice(user_id=user.id, token_hash="a" * 64, auth_version=1, expires_at=__import__('app.time_utils', fromlist=['utc_now']).utc_now())); 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(); db.session.add(TrustedDevice(user_id=user.id, token_hash="a" * 64, auth_version=1, expires_at=__import__('app.time_utils', fromlist=['utc_now']).utc_now())); db.session.commit()
client = app.test_client()
response = self.form(client, "/forgot-password", email="alice@example.com")
self.assertEqual(response.status_code, 302)
@@ -82,11 +130,11 @@ class EmailAuthenticationTest(unittest.TestCase):
response = client.post("/verify/reset", data={"csrf_token": self.csrf(verify), "code": "123456"})
self.assertEqual(response.location, "/set-password")
page = client.get("/set-password")
response = client.post("/set-password", data={"csrf_token": self.csrf(page), "password": "new-password-1234", "password_confirm": "new-password-1234"})
response = client.post("/set-password", data={"csrf_token": self.csrf(page), "password": "New-password-1234!", "password_confirm": "New-password-1234!"})
self.assertEqual(response.status_code, 302)
with app.app_context():
user = User.query.filter_by(email="alice@example.com").one()
self.assertTrue(user.check_password("new-password-1234")); self.assertEqual(TrustedDevice.query.count(), 0); self.assertEqual(user.auth_version, 2)
self.assertTrue(user.check_password("New-password-1234!")); self.assertEqual(TrustedDevice.query.count(), 0); self.assertEqual(user.auth_version, 2)
@patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456)
@@ -94,10 +142,10 @@ class EmailAuthenticationTest(unittest.TestCase):
with TemporaryDirectory() as directory:
app = self.create_app(directory); client = app.test_client()
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 = 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), "email": "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"})
+7
View File
@@ -0,0 +1,7 @@
from datetime import timedelta
from app.config import Config
def test_remember_login_duration_is_seven_days():
assert Config.REMEMBER_COOKIE_DURATION == timedelta(days=7)
+26 -1
View File
@@ -6,10 +6,35 @@ from unittest.mock import patch
from app import create_app
from app.config import Config
from app.email import EmailConfigurationError, send_transactional_email
from app.email import (
EmailConfigurationError,
password_reset_notice_email,
send_transactional_email,
verification_code_email,
)
class TransactionalEmailTest(unittest.TestCase):
def test_verification_email_has_branded_code_and_escapes_purpose(self):
html = verification_code_email(
code="123456",
minutes=10,
purpose="登录<script>",
)
self.assertIn("供水管道健康评估系统", html)
self.assertIn("123456", html)
self.assertIn("登录&lt;script&gt;", html)
self.assertNotIn("登录<script>", html)
def test_reset_notice_escapes_username_and_url(self):
html = password_reset_notice_email(
username="<管理员>",
reset_url="https://example.com/reset?x=1&y=2",
)
self.assertIn("&lt;管理员&gt;", html)
self.assertIn("x=1&amp;y=2", html)
def create_test_app(self, temp_dir: str, *, configured: bool):
class TestConfig(Config):
TESTING = True