feat(auth): add password reset flow

Add admin-generated reset links, reset UI, timezone-aware expiry display, and registration captcha coverage.
This commit is contained in:
2026-07-06 17:55:10 +08:00
parent 2fdbaa8876
commit c7ee2adb82
14 changed files with 1307 additions and 47 deletions
+9
View File
@@ -6,6 +6,12 @@ SECRET_KEY=
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD= ADMIN_PASSWORD=
# Persist the app database in the mounted ./data directory.
DATABASE_URL=sqlite:////app/data/pipe_survival_0331.db
# Timezone used when displaying UTC timestamps.
APP_TIMEZONE=Asia/Shanghai
# Default: 16 MiB # Default: 16 MiB
MAX_UPLOAD_BYTES=16777216 MAX_UPLOAD_BYTES=16777216
@@ -14,3 +20,6 @@ MODEL_PATH=/app/my_survival_forest_model_quxi-10-0331.joblib
# Keep public registration closed by default. # Keep public registration closed by default.
ALLOW_REGISTRATION=false ALLOW_REGISTRATION=false
# Minutes before an admin-generated password reset link expires.
PASSWORD_RESET_TOKEN_MINUTES=30
+5
View File
@@ -10,6 +10,7 @@ from .extensions import db, login_manager
from .models import AppSetting, User from .models import AppSetting, User
from .prediction import FEATURES, load_model from .prediction import FEATURES, load_model
from .security import csrf_token, validate_csrf_token from .security import csrf_token, validate_csrf_token
from .time_utils import format_datetime_for_timezone
def create_app(config_object: type[Config] = Config, *, load_model_on_start: bool = True) -> Flask: def create_app(config_object: type[Config] = Config, *, load_model_on_start: bool = True) -> Flask:
@@ -91,9 +92,13 @@ def register_app_hooks(app: Flask) -> None:
def now_year() -> int: def now_year() -> int:
return datetime.now().year return datetime.now().year
def format_datetime(value) -> str:
return format_datetime_for_timezone(value, app.config["APP_TIMEZONE"])
return { return {
"feature_list": FEATURES, "feature_list": FEATURES,
"now_year": now_year, "now_year": now_year,
"format_datetime": format_datetime,
"csrf_token": csrf_token, "csrf_token": csrf_token,
"allow_registration": AppSetting.get_bool( "allow_registration": AppSetting.get_bool(
"allow_registration", "allow_registration",
+2
View File
@@ -43,6 +43,8 @@ class Config:
ALLOW_REGISTRATION = env_bool("ALLOW_REGISTRATION", False) ALLOW_REGISTRATION = env_bool("ALLOW_REGISTRATION", False)
ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin" ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Asia/Shanghai").strip() or "Asia/Shanghai"
PASSWORD_RESET_TOKEN_MINUTES = env_int("PASSWORD_RESET_TOKEN_MINUTES", 30)
def ensure_dirs() -> None: def ensure_dirs() -> None:
+19
View File
@@ -24,6 +24,25 @@ class User(UserMixin, db.Model):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
class PasswordResetToken(db.Model):
__tablename__ = "password_reset_tokens"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
created_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
expires_at = db.Column(db.DateTime, nullable=False)
used_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
user = db.relationship(
"User",
foreign_keys=[user_id],
backref=db.backref("password_reset_tokens", lazy=True),
)
created_by = db.relationship("User", foreign_keys=[created_by_id])
class UploadRecord(db.Model): class UploadRecord(db.Model):
__tablename__ = "upload_records" __tablename__ = "upload_records"
+153 -25
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import os import os
from datetime import datetime import hashlib
import secrets
from datetime import datetime, timedelta
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -21,9 +23,10 @@ from sqlalchemy.orm import joinedload
from .config import BASE_DIR from .config import BASE_DIR
from .extensions import db from .extensions import db
from .models import AppSetting, UploadRecord, User from .models import AppSetting, PasswordResetToken, UploadRecord, User
from .prediction import PredictionError, run_prediction from .prediction import PredictionError, run_prediction
from .security import new_captcha from .security import new_captcha
from .time_utils import format_datetime_for_timezone
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf" REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf"
@@ -35,10 +38,55 @@ def render_auth_template(mode: str, status_code: int = 200, captcha: str = ""):
return render_template("login.html", mode=mode, captcha=captcha), status_code return render_template("login.html", mode=mode, captcha=captcha), status_code
def render_login_error(message: str, status_code: int = 400): def refresh_captcha() -> str:
flash(message, "error")
session["captcha"] = new_captcha() session["captcha"] = new_captcha()
return render_auth_template("login", status_code, session["captcha"]) return session["captcha"]
def render_auth_error(mode: str, message: str, status_code: int = 400):
flash(message, "error")
return render_auth_template(mode, status_code, refresh_captcha())
def captcha_is_valid() -> bool:
captcha_input = request.form.get("captcha", "").strip().upper()
return bool(captcha_input and captcha_input == session.get("captcha", ""))
def password_reset_token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def password_reset_expiry() -> datetime:
minutes = max(int(current_app.config["PASSWORD_RESET_TOKEN_MINUTES"]), 1)
return datetime.utcnow() + timedelta(minutes=minutes)
def format_app_datetime(value: datetime | None) -> str:
return format_datetime_for_timezone(value, current_app.config["APP_TIMEZONE"])
def active_password_reset_token(token: str) -> PasswordResetToken | None:
reset_token = PasswordResetToken.query.filter_by(
token_hash=password_reset_token_hash(token)
).first()
if (
reset_token is None
or reset_token.used_at is not None
or reset_token.expires_at <= datetime.utcnow()
):
return None
return reset_token
def render_password_reset_unavailable(status_code: int = 400):
flash("重置链接无效或已过期,请联系管理员重新生成。", "error")
return render_template(
"password_reset.html",
reset_token=None,
token="",
token_available=False,
), status_code
def require_admin() -> None: def require_admin() -> None:
@@ -71,6 +119,14 @@ def paginated_uploads(query, endpoint: str):
return pagination, None return pagination, None
def password_reset_users():
return (
User.query.filter(User.is_admin.is_(False))
.order_by(User.username.asc(), User.id.asc())
.all()
)
def prediction_result_payload(artifacts, record: UploadRecord) -> dict: def prediction_result_payload(artifacts, record: UploadRecord) -> dict:
image_url = url_for("static", filename=f"images/{artifacts.image_filename}") image_url = url_for("static", filename=f"images/{artifacts.image_filename}")
importance_url = ( importance_url = (
@@ -104,19 +160,16 @@ def login():
return redirect(url_for("main.home")) return redirect(url_for("main.home"))
if request.method == "GET": if request.method == "GET":
session["captcha"] = new_captcha() return render_auth_template("login", captcha=refresh_captcha())
return render_auth_template("login", captcha=session["captcha"])
username = request.form.get("username", "").strip() username = request.form.get("username", "").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
captcha_input = request.form.get("captcha", "").strip().upper() if not captcha_is_valid():
return render_auth_error("login", "验证码错误")
if captcha_input != session.get("captcha", ""):
return render_login_error("验证码错误")
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password): if not user or not user.check_password(password):
return render_login_error("用户名或密码错误") return render_auth_error("login", "用户名或密码错误")
login_user(user, remember=bool(request.form.get("remember"))) login_user(user, remember=bool(request.form.get("remember")))
return redirect(url_for("main.home")) return redirect(url_for("main.home"))
@@ -125,32 +178,69 @@ def login():
@bp.route("/register", methods=["GET", "POST"]) @bp.route("/register", methods=["GET", "POST"])
def register(): def register():
if request.method == "GET": if request.method == "GET":
return render_auth_template("register") return render_auth_template("register", captcha=refresh_captcha())
if not registration_allowed():
flash("当前未开放自助注册,请联系管理员。", "error")
return render_auth_template("register", 403)
username = request.form.get("username", "").strip() username = request.form.get("username", "").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
if not captcha_is_valid():
return render_auth_error("register", "验证码错误")
if not registration_allowed():
return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403)
if not username: if not username:
flash("用户名不能为空", "error") return render_auth_error("register", "用户名不能为空")
return render_auth_template("register", 400)
if len(password) < 6: if len(password) < 6:
flash("密码至少需要 6 位", "error") return render_auth_error("register", "密码至少需要 6 位")
return render_auth_template("register", 400)
if User.query.filter_by(username=username).first(): if User.query.filter_by(username=username).first():
flash("用户名已存在", "error") return render_auth_error("register", "用户名已存在")
return render_auth_template("register", 400)
user = User(username=username, is_admin=False) user = User(username=username, is_admin=False)
user.set_password(password) user.set_password(password)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
flash("注册成功,请登录", "info") flash("注册成功,请登录", "info")
session["captcha"] = new_captcha() return render_auth_template("login", captcha=refresh_captcha())
return render_auth_template("login", captcha=session["captcha"])
@bp.route("/password-reset/<token>", methods=["GET", "POST"])
def password_reset(token: str):
reset_token = active_password_reset_token(token)
if reset_token is None:
return render_password_reset_unavailable()
if request.method == "GET":
return render_template(
"password_reset.html",
reset_token=reset_token,
token=token,
token_available=True,
)
password = request.form.get("password", "")
password_confirm = request.form.get("password_confirm", "")
if len(password) < 6:
flash("密码至少需要 6 位", "error")
return render_template(
"password_reset.html",
reset_token=reset_token,
token=token,
token_available=True,
), 400
if password != password_confirm:
flash("两次输入的密码不一致", "error")
return render_template(
"password_reset.html",
reset_token=reset_token,
token=token,
token_available=True,
), 400
reset_token.user.set_password(password)
reset_token.used_at = datetime.utcnow()
db.session.commit()
flash("密码已重置,请使用新密码登录", "info")
return render_auth_template("login", captcha=refresh_captcha())
@bp.route("/logout", methods=["POST"]) @bp.route("/logout", methods=["POST"])
@@ -196,6 +286,7 @@ def admin_dashboard():
"admin.html", "admin.html",
pagination=pagination, pagination=pagination,
records=pagination.items, records=pagination.items,
password_reset_users=password_reset_users(),
registration_allowed=registration_allowed(), registration_allowed=registration_allowed(),
) )
@@ -223,6 +314,43 @@ def update_registration_setting():
return redirect(url_for("main.admin_dashboard")) return redirect(url_for("main.admin_dashboard"))
@bp.route("/admin/users/<int:user_id>/password-reset-link", methods=["POST"])
@login_required
def create_password_reset_link(user_id: int):
require_admin()
user = db.session.get(User, user_id)
if user is None:
return jsonify({"error": "用户不存在"}), 404
if user.is_admin:
return jsonify({"error": "管理员账号不支持通过此入口重置密码"}), 403
now = datetime.utcnow()
PasswordResetToken.query.filter_by(user_id=user.id, used_at=None).update(
{"used_at": now}
)
token = secrets.token_urlsafe(32)
reset_token = PasswordResetToken(
user_id=user.id,
created_by_id=current_user.id,
token_hash=password_reset_token_hash(token),
expires_at=password_reset_expiry(),
)
db.session.add(reset_token)
db.session.commit()
return jsonify(
{
"message": f"已生成 {user.username} 的密码重置链接",
"reset_url": url_for("main.password_reset", token=token, _external=True),
"expires_at": format_app_datetime(reset_token.expires_at),
"user_id": user.id,
"username": user.username,
}
)
@bp.route("/download/<int:record_id>/<file_type>") @bp.route("/download/<int:record_id>/<file_type>")
@login_required @login_required
def download_file(record_id: int, file_type: str): def download_file(record_id: int, file_type: str):
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
DEFAULT_TIMEZONE = "Asia/Shanghai"
def timezone_for_name(name: str | None) -> ZoneInfo:
try:
return ZoneInfo((name or DEFAULT_TIMEZONE).strip() or DEFAULT_TIMEZONE)
except ZoneInfoNotFoundError:
return ZoneInfo(DEFAULT_TIMEZONE)
def format_datetime_for_timezone(value: datetime | None, timezone_name: str | None) -> str:
if value is None:
return "-"
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone_for_name(timezone_name)).strftime("%Y-%m-%d %H:%M:%S")
+2
View File
@@ -9,6 +9,8 @@ services:
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env} SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/pipe_survival_0331.db}
APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai}
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216} MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216}
MODEL_PATH: ${MODEL_PATH:-/app/my_survival_forest_model_quxi-10-0331.joblib} MODEL_PATH: ${MODEL_PATH:-/app/my_survival_forest_model_quxi-10-0331.joblib}
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false} ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
+1 -1
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -77,6 +77,14 @@
padding: 0 .75rem; padding: 0 .75rem;
} }
.ui-btn-compact {
min-height: 34px;
height: 34px;
padding: 0 .65rem;
gap: .35rem;
font-size: .8125rem;
}
.ui-btn-field { .ui-btn-field {
min-height: 50px; min-height: 50px;
height: 50px; height: 50px;
+112 -3
View File
@@ -8,7 +8,7 @@
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管理台</h1> <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管理台</h1>
<p class="mt-2 text-sm text-textSub">管理系统注册状态,查看所有用户的上传文件和预测结果。</p> <p class="mt-2 text-sm text-textSub">管理系统注册状态、密码重置链接,查看所有用户的上传文件和预测结果。</p>
</div> </div>
<a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-secondary"> <a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-secondary">
<span class="material-symbols-outlined text-lg">arrow_back</span> <span class="material-symbols-outlined text-lg">arrow_back</span>
@@ -38,7 +38,62 @@
</div> </div>
</section> </section>
<section class="flex min-h-[760px] flex-col overflow-hidden rounded-lg border border-line bg-white shadow-panel"> <section class="mb-6 rounded-lg border border-line bg-white p-5 shadow-panel">
<div class="mb-4 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-lg font-extrabold tracking-tight">用户密码重置</h2>
<p class="mt-1 text-sm text-textSub">为普通用户生成一次性重置链接,旧链接会自动失效。</p>
</div>
<span class="text-sm text-textSub">共 {{ password_reset_users|length }} 位普通用户</span>
</div>
<div id="resetLinkPanel" class="mb-4 hidden rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="mb-2 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-extrabold text-textMain">已生成重置链接</div>
<div id="resetLinkExpires" class="text-xs font-semibold text-textSub"></div>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<input id="resetLinkValue" class="min-w-0 flex-1 rounded-md border border-blue-200 bg-white px-3 py-2 text-sm text-textMain" readonly>
<button id="resetLinkCopy" type="button" class="ui-btn ui-btn-sm ui-btn-secondary">
<span class="material-symbols-outlined text-lg">content_copy</span>
复制
</button>
</div>
</div>
<div class="overflow-x-auto rounded-lg border border-line">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-xs font-bold uppercase tracking-[0.12em] text-textSub">
<tr>
<th class="px-4 py-3 text-left">用户</th>
<th class="px-4 py-3 text-left">创建时间</th>
<th class="px-4 py-3 text-left">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
{% for user in password_reset_users %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-semibold">{{ user.username }}</td>
<td class="px-4 py-3 text-textSub">{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') if user.created_at else '-' }}</td>
<td class="px-4 py-3">
<form method="post" action="{{ url_for('main.create_password_reset_link', user_id=user.id) }}" data-reset-link-form class="inline-flex">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn ui-btn-sm ui-btn-secondary">
<span class="material-symbols-outlined text-lg">link</span>
生成重置链接
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="px-4 py-8 text-center text-textSub">暂无普通用户</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="flex h-[710px] flex-col overflow-hidden rounded-lg border border-line bg-white shadow-panel">
<div class="border-b border-line px-5 py-4"> <div class="border-b border-line px-5 py-4">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-lg font-extrabold">上传记录</h2> <h2 class="text-lg font-extrabold">上传记录</h2>
@@ -47,7 +102,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="flex-1 overflow-x-auto"> <div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full text-sm"> <table class="min-w-full text-sm">
<thead class="bg-slate-50 text-xs font-bold uppercase tracking-[0.12em] text-textSub"> <thead class="bg-slate-50 text-xs font-bold uppercase tracking-[0.12em] text-textSub">
<tr> <tr>
@@ -125,5 +180,59 @@
} }
}); });
})(); })();
(() => {
const panel = document.getElementById('resetLinkPanel');
const value = document.getElementById('resetLinkValue');
const expires = document.getElementById('resetLinkExpires');
const copy = document.getElementById('resetLinkCopy');
if (!panel || !value || !expires || !copy) return;
document.querySelectorAll('[data-reset-link-form]').forEach((form) => {
const submit = form.querySelector('button[type="submit"]');
const submitText = submit?.lastChild;
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (submit) submit.disabled = true;
if (submitText) submitText.textContent = '生成中';
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await response.json();
if (!response.ok) {
window.showAppNotification?.(data.error || '生成失败,请刷新页面后重试。', 'error', '生成失败');
return;
}
value.value = data.reset_url;
expires.textContent = `有效期至 ${data.expires_at}`;
panel.classList.remove('hidden');
window.showAppNotification?.(data.message, 'info');
} catch (error) {
window.showAppNotification?.('请求失败,请检查后端服务是否正常。', 'error', '生成失败');
} finally {
if (submit) submit.disabled = false;
if (submitText) submitText.textContent = '生成重置链接';
}
});
});
copy.addEventListener('click', async () => {
value.select();
try {
await navigator.clipboard.writeText(value.value);
} catch (error) {
document.execCommand('copy');
}
window.showAppNotification?.('重置链接已复制', 'info');
});
})();
</script> </script>
{% endblock %} {% endblock %}
+9 -9
View File
@@ -16,7 +16,7 @@
</a> </a>
</div> </div>
<section class="flex h-[1152px] flex-col rounded-lg border border-line bg-white shadow-panel"> <section class="flex h-[900px] min-w-0 flex-col overflow-hidden rounded-lg border border-line bg-white shadow-panel">
<div class="border-b border-line px-5 py-4"> <div class="border-b border-line px-5 py-4">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-lg font-extrabold">上传记录</h2> <h2 class="text-lg font-extrabold">上传记录</h2>
@@ -25,23 +25,23 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="h-[1040px] shrink-0 divide-y divide-line overflow-y-auto"> <div class="min-h-0 min-w-0 flex-1 divide-y divide-line overflow-y-auto">
{% for record in records %} {% for record in records %}
<div class="flex min-h-[104px] flex-col gap-4 p-5 md:flex-row md:items-center md:justify-between"> <div class="grid min-h-[76px] min-w-0 gap-3 px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div class="min-w-0"> <div class="min-w-0 overflow-hidden">
<div class="truncate font-bold">{{ record.original_filename }}</div> <div class="truncate text-sm font-bold">{{ record.original_filename }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-sm text-textSub"> <div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-textSub">
<span>{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</span> <span>{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</span>
<span class="hidden sm:inline">·</span> <span class="hidden sm:inline">·</span>
<span>记录 #{{ record.id }}</span> <span>记录 #{{ record.id }}</span>
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2 md:flex md:w-auto md:flex-nowrap">
<a class="ui-btn ui-btn-sm ui-btn-secondary" href="{{ url_for('main.download_file', record_id=record.id, file_type='original') }}"> <a class="ui-btn ui-btn-compact ui-btn-secondary w-full px-3 md:w-auto" href="{{ url_for('main.download_file', record_id=record.id, file_type='original') }}">
<span class="material-symbols-outlined text-lg">description</span> <span class="material-symbols-outlined text-lg">description</span>
原始文件 原始文件
</a> </a>
<a class="ui-btn ui-btn-sm ui-btn-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='prediction') }}"> <a class="ui-btn ui-btn-compact ui-btn-primary w-full px-3 md:w-auto" href="{{ url_for('main.download_file', record_id=record.id, file_type='prediction') }}">
<span class="material-symbols-outlined text-lg">download</span> <span class="material-symbols-outlined text-lg">download</span>
预测结果 预测结果
</a> </a>
+20 -3
View File
@@ -150,7 +150,7 @@
<span class="material-symbols-outlined text-[28px]">water_drop</span> <span class="material-symbols-outlined text-[28px]">water_drop</span>
<span>供水管道健康评估系统</span> <span>供水管道健康评估系统</span>
</div> </div>
<h2 class="text-center text-[44px] lg:text-[40px] font-extrabold tracking-tight mb-10">系统门户</h2> <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"> <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.login') }}" class="border-b-2 py-3 {{ 'text-primary border-primary' if mode == 'login' else 'text-slate-500 border-transparent' }}">登录</a>
@@ -171,7 +171,7 @@
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500">密码</label> <label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500">密码</label>
<span class="text-[12px] text-primary font-semibold">找回密码</span> <button id="forgotPasswordBtn" type="button" class="text-[12px] font-semibold text-primary transition hover:text-primaryDeep">找回密码</button>
</div> </div>
<div class="relative"> <div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">lock</span> <span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">lock</span>
@@ -207,7 +207,7 @@
</button> </button>
</form> </form>
{% else %} {% else %}
<form method="post" action="{{ url_for('main.register') }}" class="space-y-5" novalidate data-auth-form> <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() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div> <div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">用户名</label> <label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">用户名</label>
@@ -226,6 +226,19 @@
</button> </button>
</div> </div>
</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="rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic">{{ captcha }}</div>
<a href="{{ url_for('main.register') }}" 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"> <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> <span class="material-symbols-outlined text-lg">person_add</span>
@@ -410,6 +423,10 @@
toggle.setAttribute('aria-pressed', shouldShow ? 'true' : 'false'); toggle.setAttribute('aria-pressed', shouldShow ? 'true' : 'false');
}); });
}); });
document.getElementById('forgotPasswordBtn')?.addEventListener('click', () => {
showAppNotification('请联系管理员获取一次性重置链接后设置新密码。', 'info', '密码重置');
});
})(); })();
</script> </script>
</body> </body>
+623
View File
@@ -0,0 +1,623 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>重置密码 | 供水管道健康评估系统</title>
<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>
.reset-page {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(360px, .82fr) minmax(420px, 1fr);
background: #f3f5f8;
color: #0f172a;
}
.reset-aside {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: space-between;
padding: 56px 64px;
background:
radial-gradient(circle at 1px 1px, rgba(255,255,255,.20) 1px, transparent 0) 0 0 / 34px 34px,
#0f766e;
color: #fff;
}
.reset-brand {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 800;
}
.reset-aside-main {
max-width: 460px;
}
.reset-kicker {
margin-bottom: 14px;
color: rgba(255,255,255,.72);
font-size: 12px;
font-weight: 800;
letter-spacing: .18em;
text-transform: uppercase;
}
.reset-aside h1 {
margin: 0;
font-size: 46px;
line-height: 1.18;
font-weight: 900;
letter-spacing: 0;
}
.reset-aside p {
margin: 22px 0 0;
max-width: 420px;
color: rgba(255,255,255,.78);
font-size: 15px;
line-height: 1.8;
}
.reset-meta {
display: grid;
gap: 12px;
margin-top: 32px;
}
.reset-meta-item {
display: flex;
align-items: center;
gap: 10px;
color: rgba(255,255,255,.82);
font-size: 13px;
font-weight: 700;
}
.reset-aside-foot {
color: rgba(255,255,255,.58);
font-size: 12px;
}
.reset-panel {
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
padding: 48px 28px;
background: #fff;
}
.reset-card {
width: 100%;
max-width: 420px;
}
.reset-mobile-brand {
display: none;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 28px;
color: #0f766e;
font-size: 18px;
font-weight: 900;
}
.reset-icon {
display: inline-flex;
width: 54px;
height: 54px;
align-items: center;
justify-content: center;
border-radius: 14px;
background: #ccfbf1;
color: #0f766e;
}
.reset-card h2 {
margin: 18px 0 8px;
font-size: 34px;
line-height: 1.2;
font-weight: 900;
letter-spacing: 0;
}
.reset-copy {
margin: 0;
color: #64748b;
font-size: 14px;
line-height: 1.75;
}
.reset-copy strong {
color: #0f172a;
font-weight: 900;
}
.auth-input-error {
border-color: #dc2626 !important;
background: #fff7f7 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, .12) !important;
}
.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);
}
.reset-form {
margin-top: 28px;
display: grid;
gap: 20px;
}
.reset-field label {
display: block;
margin-bottom: 8px;
color: #64748b;
font-size: 11px;
font-weight: 700;
letter-spacing: .18em;
text-transform: uppercase;
}
.reset-input-wrap {
position: relative;
}
.reset-input-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
font-size: 20px;
}
.reset-input {
width: 100%;
box-sizing: border-box;
border: 1px solid transparent;
border-radius: 12px;
background: #eceff3;
padding: 14px 48px 14px 44px;
color: #0f172a;
font-size: 16px;
line-height: 1.35;
outline: none;
transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
}
.reset-input:focus {
border-color: #0f766e;
background: #fff;
box-shadow: 0 0 0 3px rgba(15, 118, 110, .14);
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
display: inline-flex;
width: 32px;
height: 32px;
transform: translateY(-50%);
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
padding: 0;
transition: background-color .15s ease, color .15s ease;
}
.password-toggle:hover {
background: rgba(148, 163, 184, .16);
color: #0f766e;
}
.password-toggle:focus-visible {
outline: 2px solid #0f766e;
outline-offset: 2px;
}
.password-toggle .material-symbols-outlined {
font-size: 20px;
line-height: 1;
}
.reset-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 4px;
}
.reset-expiry {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.reset-submit {
display: inline-flex;
min-width: 154px;
height: 48px;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 8px;
background: #0f766e;
color: #fff;
padding: 0 20px;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 16px 30px rgba(15, 118, 110, .20);
transition: background-color .15s ease, box-shadow .15s ease;
}
.reset-submit:hover {
background: #115e59;
box-shadow: 0 12px 24px rgba(15, 118, 110, .20);
}
.reset-submit:focus-visible {
outline: 2px solid #0f766e;
outline-offset: 3px;
}
.reset-login {
display: inline-flex;
width: fit-content;
height: 48px;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 28px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #334155;
padding: 0 18px;
font-size: 14px;
font-weight: 800;
text-decoration: none;
transition: border-color .15s ease, color .15s ease, background-color .15s ease;
}
.reset-login:hover {
border-color: #0f766e;
color: #0f766e;
background: #f8fafc;
}
@media (max-width: 900px) {
.reset-page {
display: block;
background: #fff;
}
.reset-aside {
display: none;
}
.reset-panel {
min-height: 100vh;
padding: 32px 22px;
}
.reset-mobile-brand {
display: flex;
}
.reset-card h2 {
font-size: 30px;
}
.reset-actions {
align-items: stretch;
flex-direction: column-reverse;
}
.reset-submit {
width: 100%;
}
.reset-expiry {
text-align: center;
}
}
@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>
{% with flashed_messages = get_flashed_messages(with_categories=true) %}
<script>
window.__flashMessages = {{ flashed_messages|tojson }};
</script>
<div class="sr-only" aria-hidden="true">
{% for category, message in flashed_messages %}{{ message }}{% endfor %}
</div>
<main class="reset-page">
<aside class="reset-aside" aria-label="系统信息">
<div class="reset-brand">
<span class="material-symbols-outlined">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<div class="reset-aside-main">
<div class="reset-kicker">Password reset</div>
<h1>为账号设置新的访问密码</h1>
<p>使用管理员生成的一次性链接完成密码更新。提交成功后,旧链接会立即失效。</p>
<div class="reset-meta" aria-hidden="true">
<div class="reset-meta-item">
<span class="material-symbols-outlined">link_off</span>
<span>一次性链接</span>
</div>
<div class="reset-meta-item">
<span class="material-symbols-outlined">encrypted</span>
<span>密码本地加密存储</span>
</div>
</div>
</div>
<div class="reset-aside-foot">© {{ now_year() }} 供水管道健康评估系统</div>
</aside>
<section class="reset-panel">
<div id="authCard" class="reset-card relative">
<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="reset-mobile-brand">
<span class="material-symbols-outlined">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<div class="reset-icon">
<span class="material-symbols-outlined text-[28px]">lock_reset</span>
</div>
<h2>重置密码</h2>
<p class="reset-copy">
{% if token_available %}
为账号 <strong>{{ reset_token.user.username }}</strong> 设置新密码。
{% else %}
当前链接不可用,请联系管理员重新生成一次性重置链接。
{% endif %}
</p>
{% if token_available %}
<form method="post" action="{{ url_for('main.password_reset', token=token) }}" class="reset-form" novalidate data-auth-form>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="reset-field">
<label for="resetPassword">新密码</label>
<div class="reset-input-wrap">
<span class="material-symbols-outlined reset-input-icon">lock</span>
<input id="resetPassword" name="password" type="password" minlength="6" required class="reset-input" placeholder="请输入至少 6 位密码" />
<button class="password-toggle" type="button" data-password-toggle="resetPassword" aria-label="显示密码" aria-pressed="false">
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</div>
</div>
<div class="reset-field">
<label for="resetPasswordConfirm">确认密码</label>
<div class="reset-input-wrap">
<span class="material-symbols-outlined reset-input-icon">lock</span>
<input id="resetPasswordConfirm" name="password_confirm" type="password" minlength="6" required class="reset-input" placeholder="请再次输入新密码" />
<button class="password-toggle" type="button" data-password-toggle="resetPasswordConfirm" aria-label="显示密码" aria-pressed="false">
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</div>
</div>
<div class="reset-actions">
<div class="reset-expiry">链接有效期至<br>{{ format_datetime(reset_token.expires_at) }}</div>
<button class="reset-submit" type="submit">
<span class="material-symbols-outlined text-lg">check</span>
保存新密码
</button>
</div>
</form>
{% else %}
<a href="{{ url_for('main.login') }}" class="reset-login">
<span class="material-symbols-outlined text-lg">arrow_back</span>
返回登录
</a>
{% endif %}
</div>
</section>
</main>
{% endwith %}
<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="password_confirm"]');
if (message.includes('密码')) return activeForm.querySelector('input[name="password"]');
return null;
}
const flashedMessages = window.__flashMessages || [];
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);
}
}
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();
showAppNotification('密码不能为空', '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();
return;
}
const password = form.querySelector('input[name="password"]');
const passwordConfirm = form.querySelector('input[name="password_confirm"]');
if (password && passwordConfirm && password.value !== passwordConfirm.value) {
event.preventDefault();
showAppNotification('两次输入的密码不一致', 'error', undefined, passwordConfirm);
markFieldError(passwordConfirm);
passwordConfirm.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');
});
});
})();
</script>
</body>
</html>
+322 -6
View File
@@ -4,11 +4,12 @@ import re
import unittest import unittest
from datetime import datetime, timedelta from datetime import datetime, timedelta
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from urllib.parse import urlparse
from app import create_app from app import create_app
from app.config import Config from app.config import Config
from app.extensions import db from app.extensions import db
from app.models import AppSetting, UploadRecord, User from app.models import AppSetting, PasswordResetToken, UploadRecord, User
class RegistrationRoutesTest(unittest.TestCase): class RegistrationRoutesTest(unittest.TestCase):
@@ -28,12 +29,13 @@ class RegistrationRoutesTest(unittest.TestCase):
self.assertIsNotNone(match) self.assertIsNotNone(match)
return match.group(1).decode() return match.group(1).decode()
def create_user(self, app, username: str, password: str, *, is_admin: bool = False) -> None: def create_user(self, app, username: str, password: str, *, is_admin: bool = False) -> int:
with app.app_context(): with app.app_context():
user = User(username=username, is_admin=is_admin) user = User(username=username, is_admin=is_admin)
user.set_password(password) user.set_password(password)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user.id
def add_upload_records(self, app, username: str, count: int) -> None: def add_upload_records(self, app, username: str, count: int) -> None:
with app.app_context(): with app.app_context():
@@ -69,6 +71,31 @@ class RegistrationRoutesTest(unittest.TestCase):
) )
self.assertEqual(login_response.status_code, 302) self.assertEqual(login_response.status_code, 302)
def login_attempt(self, client, username: str, password: str):
response = client.get("/login")
token = self.csrf_token_from(response.data)
with client.session_transaction() as session:
captcha = session["captcha"]
return client.post(
"/login",
data={
"csrf_token": token,
"username": username,
"password": password,
"captcha": captcha,
},
)
def create_reset_link(self, app, client, user_id: int) -> str:
token = self.csrf_token_from(client.get("/admin").data)
response = client.post(
f"/admin/users/{user_id}/password-reset-link",
data={"csrf_token": token},
)
self.assertEqual(response.status_code, 200)
return urlparse(response.get_json()["reset_url"]).path
def test_login_page_always_shows_register_entry_when_registration_is_closed(self) -> None: def test_login_page_always_shows_register_entry_when_registration_is_closed(self) -> None:
with TemporaryDirectory() as temp_dir: with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False) app = self.create_test_app(temp_dir, allow_registration=False)
@@ -89,7 +116,44 @@ class RegistrationRoutesTest(unittest.TestCase):
self.assertIn("当前未开放自助注册", html) self.assertIn("当前未开放自助注册", html)
self.assertIn("disabled", html) self.assertIn("disabled", html)
def test_register_page_shows_captcha(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=True)
client = app.test_client()
response = client.get("/register")
html = response.get_data(as_text=True)
with client.session_transaction() as session:
captcha = session["captcha"]
self.assertEqual(response.status_code, 200)
self.assertIn('name="captcha"', html)
self.assertIn(captcha, html)
def test_register_post_does_not_create_user_when_registration_is_closed(self) -> None: def test_register_post_does_not_create_user_when_registration_is_closed(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
client = app.test_client()
token = self.csrf_token_from(client.get("/register").data)
with client.session_transaction() as session:
captcha = session["captcha"]
response = client.post(
"/register",
data={
"csrf_token": token,
"username": "new-user",
"password": "secret123",
"captcha": captcha,
},
)
self.assertEqual(response.status_code, 403)
self.assertIn("当前未开放自助注册", response.get_data(as_text=True))
with app.app_context():
self.assertIsNone(User.query.filter_by(username="new-user").first())
def test_register_post_checks_captcha_before_registration_setting(self) -> None:
with TemporaryDirectory() as temp_dir: with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False) app = self.create_test_app(temp_dir, allow_registration=False)
client = app.test_client() client = app.test_client()
@@ -97,11 +161,37 @@ class RegistrationRoutesTest(unittest.TestCase):
response = client.post( response = client.post(
"/register", "/register",
data={"csrf_token": token, "username": "new-user", "password": "secret123"}, data={
"csrf_token": token,
"username": "new-user",
"password": "secret123",
"captcha": "WRONG",
},
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 400)
self.assertIn("当前未开放自助注册", response.get_data(as_text=True)) self.assertIn("验证码错误", response.get_data(as_text=True))
with app.app_context():
self.assertIsNone(User.query.filter_by(username="new-user").first())
def test_register_post_rejects_wrong_captcha(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=True)
client = app.test_client()
token = self.csrf_token_from(client.get("/register").data)
response = client.post(
"/register",
data={
"csrf_token": token,
"username": "new-user",
"password": "secret123",
"captcha": "WRONG",
},
)
self.assertEqual(response.status_code, 400)
self.assertIn("验证码错误", response.get_data(as_text=True))
with app.app_context(): with app.app_context():
self.assertIsNone(User.query.filter_by(username="new-user").first()) self.assertIsNone(User.query.filter_by(username="new-user").first())
@@ -110,10 +200,17 @@ class RegistrationRoutesTest(unittest.TestCase):
app = self.create_test_app(temp_dir, allow_registration=True) app = self.create_test_app(temp_dir, allow_registration=True)
client = app.test_client() client = app.test_client()
token = self.csrf_token_from(client.get("/register").data) token = self.csrf_token_from(client.get("/register").data)
with client.session_transaction() as session:
captcha = session["captcha"]
response = client.post( response = client.post(
"/register", "/register",
data={"csrf_token": token, "username": "new-user", "password": "secret123"}, data={
"csrf_token": token,
"username": "new-user",
"password": "secret123",
"captcha": captcha,
},
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -164,6 +261,225 @@ class RegistrationRoutesTest(unittest.TestCase):
with app.app_context(): with app.app_context():
self.assertFalse(AppSetting.get_bool("allow_registration", True)) self.assertFalse(AppSetting.get_bool("allow_registration", True))
def test_admin_can_create_password_reset_link_for_regular_user(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
client = app.test_client()
self.login(client, "admin", "secret123")
token = self.csrf_token_from(client.get("/admin").data)
response = client.post(
f"/admin/users/{user_id}/password-reset-link",
data={"csrf_token": token},
)
data = response.get_json()
self.assertEqual(response.status_code, 200)
self.assertIn("/password-reset/", data["reset_url"])
self.assertEqual(data["username"], "alice")
with app.app_context():
self.assertEqual(PasswordResetToken.query.count(), 1)
def test_admin_password_reset_section_lists_registered_users_without_uploads(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=True)
self.create_user(app, "admin", "secret123", is_admin=True)
client = app.test_client()
register_page = client.get("/register")
token = self.csrf_token_from(register_page.data)
with client.session_transaction() as session:
captcha = session["captcha"]
register_response = client.post(
"/register",
data={
"csrf_token": token,
"username": "registered-user",
"password": "secret123",
"captcha": captcha,
},
)
self.assertEqual(register_response.status_code, 200)
self.login(client, "admin", "secret123")
admin_page = client.get("/admin").get_data(as_text=True)
self.assertIn("用户密码重置", admin_page)
self.assertIn("registered-user", admin_page)
self.assertIn("生成重置链接", admin_page)
def test_password_reset_link_requires_admin(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
user_id = self.create_user(app, "alice", "oldpass")
client = app.test_client()
token = self.csrf_token_from(client.get("/login").data)
anonymous_response = client.post(
f"/admin/users/{user_id}/password-reset-link",
data={"csrf_token": token},
)
self.assertEqual(anonymous_response.status_code, 302)
self.login(client, "alice", "oldpass")
token = self.csrf_token_from(client.get("/home").data)
user_response = client.post(
f"/admin/users/{user_id}/password-reset-link",
data={"csrf_token": token},
)
self.assertEqual(user_response.status_code, 403)
def test_admin_cannot_create_password_reset_link_for_admin_user(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
admin_id = self.create_user(app, "admin", "secret123", is_admin=True)
client = app.test_client()
self.login(client, "admin", "secret123")
token = self.csrf_token_from(client.get("/admin").data)
response = client.post(
f"/admin/users/{admin_id}/password-reset-link",
data={"csrf_token": token},
)
self.assertEqual(response.status_code, 403)
self.assertIn("管理员账号", response.get_json()["error"])
def test_password_reset_changes_password_and_consumes_link(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
admin_client = app.test_client()
self.login(admin_client, "admin", "secret123")
reset_path = self.create_reset_link(app, admin_client, user_id)
client = app.test_client()
reset_page = client.get(reset_path)
token = self.csrf_token_from(reset_page.data)
response = client.post(
reset_path,
data={
"csrf_token": token,
"password": "newpass123",
"password_confirm": "newpass123",
},
)
self.assertEqual(response.status_code, 200)
self.assertIn("密码已重置", response.get_data(as_text=True))
self.assertEqual(self.login_attempt(client, "alice", "oldpass").status_code, 400)
self.assertEqual(self.login_attempt(client, "alice", "newpass123").status_code, 302)
self.assertEqual(client.get(reset_path).status_code, 400)
with app.app_context():
reset_token = PasswordResetToken.query.one()
self.assertIsNotNone(reset_token.used_at)
def test_password_reset_page_displays_expiry_in_configured_timezone(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
admin_client = app.test_client()
self.login(admin_client, "admin", "secret123")
reset_path = self.create_reset_link(app, admin_client, user_id)
with app.app_context():
reset_token = PasswordResetToken.query.one()
reset_token.expires_at = datetime(2027, 1, 1, 0, 0, 0)
db.session.commit()
response = app.test_client().get(reset_path)
self.assertEqual(response.status_code, 200)
self.assertIn("2027-01-01 08:00:00", response.get_data(as_text=True))
def test_expired_password_reset_link_cannot_change_password(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
admin_client = app.test_client()
self.login(admin_client, "admin", "secret123")
reset_path = self.create_reset_link(app, admin_client, user_id)
with app.app_context():
reset_token = PasswordResetToken.query.one()
reset_token.expires_at = datetime.utcnow() - timedelta(minutes=1)
db.session.commit()
client = app.test_client()
token = self.csrf_token_from(client.get("/login").data)
response = client.post(
reset_path,
data={
"csrf_token": token,
"password": "newpass123",
"password_confirm": "newpass123",
},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(self.login_attempt(client, "alice", "oldpass").status_code, 302)
def test_new_password_reset_link_invalidates_previous_link(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
client = app.test_client()
self.login(client, "admin", "secret123")
first_path = self.create_reset_link(app, client, user_id)
second_path = self.create_reset_link(app, client, user_id)
self.assertEqual(client.get(first_path).status_code, 400)
self.assertEqual(client.get(second_path).status_code, 200)
def test_password_reset_validation_does_not_consume_link(self) -> None:
with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False)
self.create_user(app, "admin", "secret123", is_admin=True)
user_id = self.create_user(app, "alice", "oldpass")
admin_client = app.test_client()
self.login(admin_client, "admin", "secret123")
reset_path = self.create_reset_link(app, admin_client, user_id)
client = app.test_client()
reset_page = client.get(reset_path)
token = self.csrf_token_from(reset_page.data)
short_response = client.post(
reset_path,
data={
"csrf_token": token,
"password": "short",
"password_confirm": "short",
},
)
self.assertEqual(short_response.status_code, 400)
reset_page = client.get(reset_path)
token = self.csrf_token_from(reset_page.data)
mismatch_response = client.post(
reset_path,
data={
"csrf_token": token,
"password": "newpass123",
"password_confirm": "different",
},
)
self.assertEqual(mismatch_response.status_code, 400)
self.assertEqual(client.get(reset_path).status_code, 200)
with app.app_context():
reset_token = PasswordResetToken.query.one()
self.assertIsNone(reset_token.used_at)
def test_history_page_paginates_upload_records(self) -> None: def test_history_page_paginates_upload_records(self) -> None:
with TemporaryDirectory() as temp_dir: with TemporaryDirectory() as temp_dir:
app = self.create_test_app(temp_dir, allow_registration=False) app = self.create_test_app(temp_dir, allow_registration=False)