完成公网密码重置安全阻断

关闭公开密码重置流程,统一显示运维协助提示,保留本机受保护重置方向,并更新前端回归测试与整改清单。
This commit is contained in:
xuans 2026-10-04 13:01:52 +08:00
parent e5c713c682
commit 6f702cdc36
10 changed files with 112 additions and 372 deletions

View File

@ -38,7 +38,7 @@ func (s *Server) showPasswordResetGuidance(c *gin.Context) {
c.JSON(http.StatusOK, publicPasswordResetGuidance{
Title: "密码重置",
Description: "请联系系统管理员协助处理密码重置。为保护账户安全,此页面不确认账户是否存在或所属角色。",
Description: "当前方式无法使用,请联系运维人员辅助解决。",
Action: "contact_administrator",
})
}

View File

@ -36,7 +36,7 @@ func TestPublicPasswordResetGuidanceDoesNotEnumerateAccounts(t *testing.T) {
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload) != 3 || payload["action"] != "contact_administrator" || payload["title"] != "密码重置" || payload["description"] != "请联系系统管理员协助处理密码重置。为保护账户安全,此页面不确认账户是否存在或所属角色。" {
if len(payload) != 3 || payload["action"] != "contact_administrator" || payload["title"] != "密码重置" || payload["description"] != "当前方式无法使用,请联系运维人员辅助解决。" {
t.Fatalf("公开响应必须仅包含通用处理提示:%v", payload)
}
if response.Header().Get("Cache-Control") != "no-store" {

View File

@ -1,5 +1,16 @@
# Scert 整改、合并与优化清单
## 2026年10月4日 公网超级管理员密码重置入口安全收口
- [x] Web 端 `/password-reset` 和 `/reset` 页面改为静态安全阻断提示,统一提示“当前方式无法使用,请联系运维人员辅助解决”,不再展示账号、验证码或新密码表单。
- [x] 前端密码重置组件及兼容 composable 不再发起公网重置请求、不创建重置会话、不接收密码;页面明确说明必须由部署主机运维人员通过本机受保护终端处理。
- [x] 公开密码处理接口仅返回通用提示,不查询账户、不发送验证码、不设置 Cookie;后端提示文案已与 Web 页面一致。
- [x] 正式 HTTP 路由继续不注册 `/reset`,即使旧版 `Scert-admin` 状态文件存在,生产路径也不会回退到旧 HTML 重置表单;后端边界回归通过。
- [x] 本地后端 `internal/httpapi` 测试、前端全量测试、前端生产构建及差异检查通过,变更等待复审。
- [~] 仍需在第一台 Linux 部署候选包后,以真实 root 前台交互终端复核 Unix Socket 本机重置、系统邮件验证码送达和新密码登录闭环;未完成前不关闭生产验收门禁。
本项未开放公网端口,未操作第二台服务器,未创建辅助验收脚本,未读取或记录任何密码、验证码或邮件密钥。Windows 环境不在本轮范围。
## 2026年10月4日11:07 权限回归与第一台终态复核
- [✓] 邮件通知配置继续仅允许超管读写;前端直接调用设置入口也拒绝普通管理员和用户,并清除未保存密码。新增角色矩阵、降权、停用和删除会话回归通过,拒绝请求没有配置或秘密仓库副作用。

View File

@ -2,6 +2,26 @@
max-width: 470px;
}
.reset-blocked-message {
display: grid;
gap: 8px;
margin: 4px 0 16px;
padding: 16px;
border: 1px solid #f0d4a8;
border-radius: 14px;
background: #fff8eb;
color: #78531b;
line-height: 1.65;
}
.reset-blocked-message strong {
color: #684511;
}
.reset-blocked-message p {
margin: 0;
}
.reset-resolution {
display: grid;
gap: 8px;
@ -53,4 +73,4 @@
.access-submit:disabled {
cursor: wait;
opacity: 0.65;
}
}

View File

@ -2,50 +2,27 @@ import { ref } from "vue";
export function usePasswordReset() {
const identifier = ref("");
const resolution = ref(null);
const message = ref("");
const resolution = ref({
action: "contact_administrator",
title: "密码重置",
description: "当前方式无法使用,请联系运维人员辅助解决。",
});
const message = ref("当前方式无法使用,请联系运维人员辅助解决。");
const error = ref("");
const submitting = ref(false);
async function submit() {
if (submitting.value) {
return;
}
message.value = "";
error.value = "";
resolution.value = null;
if (!identifier.value.trim()) {
error.value = "请输入用户名或绑定邮箱。";
return;
}
submitting.value = true;
try {
const response = await fetch("/api/v1/password-reset/resolve", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
credentials: "same-origin",
body: JSON.stringify({ identifier: identifier.value.trim() }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || "暂时无法处理密码重置请求,请稍后重试。");
}
resolution.value = result;
message.value = result.description || "已返回密码处理方式。";
} catch (submitError) {
error.value = submitError.message || "查询密码重置方式失败,请稍后重试。";
} finally {
submitting.value = false;
}
function submit() {
error.value = "当前方式无法使用,请联系运维人员辅助解决。";
}
function reset() {
resolution.value = null;
message.value = "";
identifier.value = "";
resolution.value = {
action: "contact_administrator",
title: "密码重置",
description: "当前方式无法使用,请联系运维人员辅助解决。",
};
message.value = "当前方式无法使用,请联系运维人员辅助解决。";
error.value = "";
}
@ -58,4 +35,4 @@ export function usePasswordReset() {
submit,
reset,
};
}
}

View File

@ -3,9 +3,6 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import { baseParse } from "@vue/compiler-dom";
import { parse } from "@vue/compiler-sfc";
import { usePasswordReset } from "./PasswordReset.js";
import { mountComposable } from "../../test-support/mount.js";
function template(path) {
const source = readFileSync(new URL(path, import.meta.url), "utf8");
return baseParse(parse(source).descriptor.template.content);
@ -17,66 +14,13 @@ function elements(node, predicate, matches = []) {
return matches;
}
test("超级管理员查询保留本机处理标识,不生成网页重置链接", async (t) => {
const payload = {
account_type: "super_admin",
action: "contact_administrator",
title: "超级管理员密码重置",
description: "请在部署主机操作。",
};
const { state } = await mountComposable(t, usePasswordReset, async (url, options) => {
assert.equal(url, "/api/v1/password-reset/resolve");
assert.equal(options.credentials, "same-origin");
assert.deepEqual(JSON.parse(options.body), { identifier: "owner" });
return Response.json(payload);
});
state.identifier.value = " owner ";
await state.submit();
assert.equal(state.resolution.value.action, "contact_administrator");
assert.equal("reset_path" in state.resolution.value, false);
assert.doesNotMatch(JSON.stringify(state.resolution.value), /local_admin_command|systemctl|scert-admin/iu);
assert.equal(state.error.value, "");
assert.equal(state.submitting.value, false);
state.reset();
assert.equal(state.resolution.value, null);
});
test("密码重置页面不显示本机管理命令", () => {
test("公开密码重置页面只显示安全阻断提示", () => {
const root = template("./PasswordReset.vue");
const guides = elements(root, (node) => node.tag === "LocalResetGuide");
assert.equal(guides.length, 0);
const source = readFileSync(new URL("./PasswordReset.vue", import.meta.url), "utf8");
assert.doesNotMatch(source, /systemctl|scert-admin|本机管理命令/iu);
const message = elements(root, (node) => node.tag === "p"
&& node.props.some((prop) => prop.name === "if"
&& prop.exp?.content === "message && !resolution"));
assert.equal(message.length, 1);
});
test("本机指引不包含可执行命令", () => {
const root = template("../components/LocalResetGuide/LocalResetGuide.vue");
assert.equal(elements(root, (node) => ["a", "input", "form"].includes(node.tag)).length, 0);
assert.equal(elements(root, (node) => node.tag === "HelpTip").length, 1);
assert.equal(elements(root, (node) => node.tag === "code").length, 0);
const source = readFileSync(new URL("../components/LocalResetGuide/LocalResetGuide.vue", import.meta.url), "utf8");
assert.doesNotMatch(source, /systemctl|scert-admin|<code/iu);
});
test("公开密码重置接受不包含身份字段的通用响应", async (t) => {
const payload = {
title: "密码重置",
description: "请联系系统管理员协助处理密码重置。为保护账户安全,此页面不确认账户是否存在或所属角色。",
action: "contact_administrator",
};
const { state } = await mountComposable(t, usePasswordReset, async () => Response.json(payload));
for (const identifier of ["owner", "owner@example.test", "absent", "absent@example.test"]) {
state.identifier.value = identifier;
await state.submit();
assert.deepEqual(state.resolution.value, payload);
assert.equal(state.error.value, "");
assert.equal(state.submitting.value, false);
for (const field of ["account_type", "account_label", "reset_path", "reset_url", "token", "nonce"]) {
assert.equal(field in state.resolution.value, false);
}
}
assert.match(source, /当前方式无法使用/);
assert.match(source, /请联系运维人员辅助解决/);
assert.doesNotMatch(source, /fetch\(|password-reset\/resolve|systemctl|scert-admin/iu);
assert.equal(elements(root, (node) => node.tag === "form").length, 0);
assert.equal(elements(root, (node) => node.tag === "input").length, 0);
assert.equal(elements(root, (node) => node.tag === "button").length, 0);
});

View File

@ -1,80 +1,33 @@
<script setup>
import AccessShell from "../components/AccessShell/AccessShell.vue";
import { usePasswordReset } from "./PasswordReset.js";
const {
identifier,
resolution,
message,
error,
submitting,
submit,
reset,
} = usePasswordReset();
</script>
<template>
<AccessShell variant="compact" aria-label="Scert 管理员密码重置">
<AccessShell variant="compact" aria-label="Scert 密码重置">
<template #brand>
<img class="access-logo" src="/Scert.png" alt="Scert 标志" />
<p class="access-eyebrow">SCERT 安全中心</p>
<h1 class="access-brand-title">保护管理后台安全</h1>
<p class="access-eyebrow">SCERT 安全边界</p>
<h1 class="access-brand-title">密码重置不可用</h1>
<p class="access-brand-description">
Scert 会根据账户标识返回安全的密码处理方式,不会在此页面直接接收新密码。
超级管理员密码重置不通过普通 Web 页面执行。
</p>
</template>
<section class="access-card password-reset-card">
<header class="access-card-header">
<p class="access-eyebrow">账户安全</p>
<h2 class="access-card-title">密码重置</h2>
<p class="access-eyebrow">安全边界</p>
<h2 class="access-card-title">当前方式无法使用</h2>
<p class="access-card-description">
输入用户名或绑定邮箱,查询该账户可用的重置入口。
请联系运维人员辅助解决。
</p>
</header>
<form class="access-form" @submit.prevent="submit">
<label class="access-field">
<span>管理员账号或邮箱</span>
<input
id="reset-identifier"
name="identifier"
v-model.trim="identifier"
type="text"
autocomplete="username"
placeholder="请输入用户名或绑定邮箱"
required
/>
</label>
<p v-if="message && !resolution" class="access-message">{{ message }}</p>
<p v-if="error" class="access-error">{{ error }}</p>
<div v-if="resolution" class="reset-resolution" aria-live="polite">
<span class="reset-resolution-label">处理方式</span>
<strong>{{ resolution.title }}</strong>
<p>{{ resolution.description }}</p>
<a
v-if="resolution.reset_path"
class="access-submit reset-entry-link"
:href="resolution.reset_path"
>
前往超级管理员重置入口
</a>
<button
v-else
class="access-secondary"
type="button"
@click="reset"
>
重新输入账户
</button>
</div>
<button class="access-submit" type="submit" :disabled="submitting">
{{ submitting ? "正在查询…" : "查询重置方式" }}
</button>
</form>
<div class="reset-blocked-message" role="status">
<strong>超级管理员密码重置只能通过部署主机上的本机受保护终端执行。</strong>
<p>
当前页面不会读取账户、发送验证码、接收新密码或创建公网重置会话。
</p>
</div>
<a class="access-return" href="/login">返回登录</a>
</section>

View File

@ -3,6 +3,26 @@
max-width: 520px;
}
.reset-blocked-message {
display: grid;
gap: 8px;
margin: 4px 0 16px;
padding: 16px;
border: 1px solid #f0d4a8;
border-radius: 14px;
background: #fff8eb;
color: #78531b;
line-height: 1.65;
}
.reset-blocked-message strong {
color: #684511;
}
.reset-blocked-message p {
margin: 0;
}
.reset-success-overlay {
position: fixed;
inset: 0;
@ -49,4 +69,4 @@
color: #607391;
font-size: 13px;
line-height: 1.7;
}
}

View File

@ -1,130 +1,24 @@
import { onBeforeUnmount, onMounted, ref } from "vue";
function readResetMessage(html) {
const documentNode = new DOMParser().parseFromString(html, "text/html");
const notice = documentNode.querySelector(".notice");
return notice?.textContent?.trim() || "重置请求未成功,请检查一次性校验码。";
}
import { ref } from "vue";
export function useSuperAdminReset() {
const verificationCode = ref("");
const password = ref("");
const confirmPassword = ref("");
const csrfToken = ref("");
const message = ref("");
const error = ref("");
const loading = ref(true);
const message = ref("当前方式无法使用,请联系运维人员辅助解决。");
const error = ref("当前方式无法使用,请联系运维人员辅助解决。");
const loading = ref(false);
const submitting = ref(false);
const success = ref(false);
const redirectSeconds = ref(3);
let redirectTimer;
const redirectSeconds = ref(0);
async function loadSession() {
loading.value = true;
error.value = "";
try {
const response = await fetch("/reset", {
credentials: "same-origin",
cache: "no-store",
headers: {
Accept: "application/x-scert-reset-bootstrap",
},
});
const html = await response.text();
if (!response.ok) {
throw new Error("当前重置入口未开启,请先启动 Scert-admin。");
}
const documentNode = new DOMParser().parseFromString(html, "text/html");
csrfToken.value = documentNode.querySelector(
'input[name="csrf_token"]',
)?.value || "";
if (!csrfToken.value) {
throw new Error("未能获取本次重置授权,请重新打开页面。");
}
} catch (loadError) {
error.value = loadError.message || "无法获取重置授权,请稍后重试。";
} finally {
loading.value = false;
}
function loadSession() {
return Promise.resolve();
}
function validate() {
if (!verificationCode.value.trim()) {
return "请输入一次性校验码。";
}
if (password.value.length < 8 || password.value.length > 12) {
return "新密码长度必须为 8 至 12 个字符。";
}
if (password.value !== confirmPassword.value) {
return "两次输入的新密码不一致。";
}
return "";
function submit() {
error.value = "当前方式无法使用,请联系运维人员辅助解决。";
}
async function submit() {
if (submitting.value || success.value) {
return;
}
message.value = "";
error.value = validate();
if (error.value) {
return;
}
if (!csrfToken.value) {
error.value = "本次重置授权已失效,请重新打开页面。";
return;
}
submitting.value = true;
try {
const form = new URLSearchParams({
csrf_token: csrfToken.value,
reset_code: verificationCode.value.trim(),
new_password: password.value,
confirm_password: confirmPassword.value,
});
const response = await fetch("/reset", {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "text/html",
"Content-Type": "application/x-www-form-urlencoded",
},
body: form,
});
const responseBody = await response.text();
if (!response.ok) {
throw new Error(readResetMessage(responseBody));
}
csrfToken.value = "";
verificationCode.value = "";
password.value = "";
confirmPassword.value = "";
success.value = true;
redirectSeconds.value = 3;
redirectTimer = window.setInterval(() => {
redirectSeconds.value -= 1;
if (redirectSeconds.value <= 0) {
window.clearInterval(redirectTimer);
window.location.href = "/login";
}
}, 1000);
} catch (submitError) {
error.value = submitError.message || "密码重置失败,请稍后重试。";
} finally {
submitting.value = false;
}
}
onMounted(loadSession);
onBeforeUnmount(() => {
if (redirectTimer) {
window.clearInterval(redirectTimer);
}
});
return {
verificationCode,
password,

View File

@ -1,115 +1,36 @@
<script setup>
import AccessShell from "../components/AccessShell/AccessShell.vue";
import { useSuperAdminReset } from "./SuperAdminReset.js";
const {
verificationCode,
password,
confirmPassword,
message,
error,
loading,
submitting,
success,
redirectSeconds,
submit,
loadSession,
} = useSuperAdminReset();
</script>
<template>
<AccessShell variant="compact" aria-label="Scert 超级管理员密码重置">
<template #brand>
<img class="access-logo" src="/Scert.png" alt="Scert 标志" />
<p class="access-eyebrow">SCERT-ADMIN 安全入口</p>
<h1 class="access-brand-title">超级管理员密码重置</h1>
<p class="access-eyebrow">SCERT 安全边界</p>
<h1 class="access-brand-title">密码重置不可用</h1>
<p class="access-brand-description">
此入口仅配合部署主机上的临时管理服务使用,不提供公开常驻访问。
超级管理员密码重置不通过普通 Web 页面执行。
</p>
</template>
<section class="access-card reset-card">
<header class="access-card-header">
<p class="access-eyebrow">临时授权</p>
<h2 class="access-card-title">确认安全重置</h2>
<p class="access-eyebrow">安全边界</p>
<h2 class="access-card-title">当前方式无法使用</h2>
<p class="access-card-description">
请输入一次性校验码和新的超级管理员密码。
请联系运维人员辅助解决。
</p>
</header>
<form class="access-form" @submit.prevent="submit">
<label class="access-field">
<span>一次性校验码</span>
<input
id="verification-code"
name="verification_code"
v-model.trim="verificationCode"
type="text"
autocomplete="one-time-code"
placeholder="请输入一次性校验码"
:disabled="loading || submitting || success"
required
/>
</label>
<div class="reset-blocked-message" role="status">
<strong>超级管理员密码重置只能由部署主机上的运维人员通过本机受保护终端执行。</strong>
<p>
此页面不提供验证码输入、密码输入或公网重置会话。
</p>
</div>
<label class="access-field">
<span>新密码</span>
<input
id="super-admin-password"
name="password"
v-model="password"
type="password"
autocomplete="new-password"
placeholder="8-12 个字符"
minlength="8"
maxlength="12"
:disabled="loading || submitting || success"
required
/>
</label>
<label class="access-field">
<span>确认新密码</span>
<input
id="super-admin-confirm-password"
name="confirm_password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
placeholder="请再次输入新密码"
minlength="8"
maxlength="12"
:disabled="loading || submitting || success"
required
/>
</label>
<p v-if="loading" class="access-message">正在验证本次重置入口……</p>
<p v-if="message" class="access-message">{{ message }}</p>
<p v-if="error" class="access-error">{{ error }}</p>
<button
class="access-submit"
type="submit"
:disabled="loading || submitting || success"
>
{{ submitting ? "正在提交" : "确认重置" }}
</button>
</form>
<p class="access-note">
本页面仅在 Scert-admin 临时启用后使用。提交成功后,本次授权会立即销毁且不可再次使用。
</p>
<a class="access-return" href="/login">返回登录</a>
</section>
<div v-if="success" class="reset-success-overlay" role="dialog" aria-modal="true">
<div class="reset-success-dialog">
<div class="reset-success-icon" aria-hidden="true">✓</div>
<h2>密码重置成功</h2>
<p>本次重置授权已销毁。</p>
<p>{{ redirectSeconds }} 秒后返回登录页面。</p>
</div>
</div>
</AccessShell>
</template>