完善登录账号自助修改密码

This commit is contained in:
xuans 2026-08-31 01:33:24 +08:00
parent 01dfda888a
commit ac27c2dad0
8 changed files with 564 additions and 1 deletions

View File

@ -74,6 +74,34 @@ func (s *Server) requireAdmin(c *gin.Context) {
c.Next()
}
func (s *Server) requireAuthenticated(c *gin.Context) {
s.configMu.RLock()
setupReady := s.config.SetupReady
s.configMu.RUnlock()
if !setupReady {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"error": "请先完成初始化配置",
"request_id": requestIDFromContext(c),
})
return
}
if _, ok := s.currentAuthSession(c); !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "请先登录管理后台",
"request_id": requestIDFromContext(c),
})
return
}
if !s.sameOriginForAPI(c.Request) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "请求来源校验失败",
"request_id": requestIDFromContext(c),
})
return
}
c.Next()
}
func (s *Server) sameOriginForAPI(request *http.Request) bool {
if request == nil {
return false

View File

@ -0,0 +1,142 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"git.pcux.cn/xuans/Scert/backend/internal/auth"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/passwordpolicy"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
ConfirmPassword string `json:"confirm_password"`
}
// changePassword 允许当前登录账号修改自己的密码,并在成功后使所有旧会话失效。
func (s *Server) changePassword(c *gin.Context) {
request := changePasswordRequest{}
if err := bindJSON(c, &request, authRequestBodyLimit); err != nil {
s.writeError(c, http.StatusBadRequest, "修改密码请求参数无效", err)
return
}
if request.CurrentPassword == "" {
s.writeError(c, http.StatusBadRequest, "请输入当前密码", nil)
return
}
if request.NewPassword != request.ConfirmPassword {
s.writeError(c, http.StatusBadRequest, "两次输入的新密码不一致", nil)
return
}
session, ok := s.currentAuthSession(c)
if !ok {
s.writeError(c, http.StatusUnauthorized, "登录状态已失效,请重新登录", nil)
return
}
s.configMu.RLock()
cfg := s.config
s.configMu.RUnlock()
if session.UserID == "primary-super-admin" {
s.changePrimarySuperAdminPassword(c, session, cfg, request)
return
}
s.changeDatabaseUserPassword(c, session, request)
}
func (s *Server) changePrimarySuperAdminPassword(c *gin.Context, session authSession, cfg config.Config, request changePasswordRequest) {
if _, err := auth.AuthenticateAdmin(session.Username, request.CurrentPassword, cfg.Admin.Username, cfg.Admin.Email, cfg.Admin.PasswordHash); err != nil {
s.writeError(c, http.StatusUnauthorized, "当前密码错误", nil)
return
}
if err := passwordpolicy.Validate(request.NewPassword, cfg.Admin.Username); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
return
}
if strings.TrimSpace(s.configPath) == "" {
s.writeError(c, http.StatusInternalServerError, "服务配置路径未就绪,无法保存新密码", errors.New("配置路径未配置"))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
if err != nil {
s.writeError(c, http.StatusInternalServerError, "生成新密码摘要失败", err)
return
}
persistedConfig, err := config.Load(s.configPath)
if err != nil {
s.writeError(c, http.StatusInternalServerError, "读取服务配置失败,无法保存新密码", err)
return
}
previousConfig := persistedConfig
persistedConfig.Admin.PasswordHash = string(hash)
if err := s.syncPrimarySuperAdmin(c.Request.Context(), persistedConfig); err != nil {
s.writeError(c, http.StatusInternalServerError, "同步超级管理员账户失败", err)
return
}
if err := persistedConfig.Save(s.configPath); err != nil {
if restoreErr := s.syncPrimarySuperAdmin(c.Request.Context(), previousConfig); restoreErr != nil && s.logger != nil {
s.logger.Error("保存新密码失败后恢复超级管理员摘要失败", "error", restoreErr)
}
s.writeError(c, http.StatusInternalServerError, "保存新密码失败", err)
return
}
cfg.Admin.PasswordHash = string(hash)
s.configMu.Lock()
s.config = cfg
s.configMu.Unlock()
s.finishPasswordChange(c, session.UserID, "超级管理员密码修改成功,请使用新密码重新登录")
}
func (s *Server) changeDatabaseUserPassword(c *gin.Context, session authSession, request changePasswordRequest) {
if s.users == nil {
s.writeError(c, http.StatusNotImplemented, "用户仓储尚未配置", errors.New("用户仓储未配置"))
return
}
user, err := s.users.GetUserByID(c.Request.Context(), session.UserID)
if err != nil {
s.writeError(c, http.StatusUnauthorized, "当前登录账号已不可用,请重新登录", err)
return
}
if user.Status != repository.UserStatusActive {
s.writeError(c, http.StatusForbidden, "当前账号已被禁用", nil)
return
}
if _, err := auth.AuthenticateAccount(session.Username, request.CurrentPassword, user.Username, user.Email, user.PasswordHash, user.Role); err != nil {
s.writeError(c, http.StatusUnauthorized, "当前密码错误", nil)
return
}
if err := passwordpolicy.ValidateAccountPassword(request.NewPassword, user.Username); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
if err != nil {
s.writeError(c, http.StatusInternalServerError, "生成新密码摘要失败", err)
return
}
user.PasswordHash = string(hash)
if err := s.users.UpdateUser(c.Request.Context(), user); err != nil {
s.writeError(c, http.StatusInternalServerError, "保存新密码失败", err)
return
}
s.finishPasswordChange(c, session.UserID, "密码修改成功,请使用新密码重新登录")
}
func (s *Server) finishPasswordChange(c *gin.Context, userID, message string) {
s.invalidateUserSessions(userID)
s.clearAuthCookie(c)
s.recordAuthAudit(c, "修改密码", "success", "当前登录账号修改密码成功")
c.JSON(http.StatusOK, gin.H{
"authenticated": false,
"requires_login": true,
"message": message,
})
}

View File

@ -0,0 +1,160 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func TestChangePasswordUpdatesPrimarySuperAdminAndInvalidatesSession(t *testing.T) {
directory := t.TempDir()
cfg := config.Default(directory)
cfg.SetupReady = true
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
cfg.DNS.VerificationState = "verified"
cfg.Web.Hostname = "scert.example.test"
configPath := filepath.Join(directory, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存改密测试配置失败:%v", err)
}
t.Setenv("SCERT_WEB_ENABLED", "false")
server := New(Dependencies{Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}})
login := loginAuthTest(t, server, "admin", "AdminPass123", false)
cookie := login.Result().Cookies()[0]
response := performChangePasswordRequest(t, server, cookie, `{"current_password":"AdminPass123","new_password":"NewAdmin123","confirm_password":"NewAdmin123"}`)
if response.Code != http.StatusOK {
t.Fatalf("超级管理员改密失败:%d %s", response.Code, response.Body.String())
}
var payload struct {
Authenticated bool `json:"authenticated"`
RequiresLogin bool `json:"requires_login"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("解析改密响应失败:%v", err)
}
if payload.Authenticated || !payload.RequiresLogin {
t.Fatalf("改密响应未要求重新登录:%+v", payload)
}
oldSession := authSessionRequest(server, cookie)
if oldSession.Code != http.StatusUnauthorized {
t.Fatalf("改密后旧会话应失效,实际为 %d:%s", oldSession.Code, oldSession.Body.String())
}
oldLogin := performLoginRequest(t, server, "admin", "AdminPass123")
if oldLogin.Code != http.StatusUnauthorized {
t.Fatalf("改密后旧密码不应登录成功,实际为 %d", oldLogin.Code)
}
newLogin := loginAuthTest(t, server, "admin", "NewAdmin123", false)
if newLogin.Code != http.StatusOK {
t.Fatalf("改密后新密码登录失败:%d %s", newLogin.Code, newLogin.Body.String())
}
saved, err := config.Load(configPath)
if err != nil {
t.Fatalf("读取改密后的配置失败:%v", err)
}
if saved.Admin.PasswordHash == cfg.Admin.PasswordHash || saved.Admin.PasswordHash == "" {
t.Fatal("改密后的配置没有保存新的密码摘要")
}
if saved.Web.Enabled != cfg.Web.Enabled {
t.Fatal("修改密码不应把环境变量覆盖值写回配置文件")
}
}
func TestChangePasswordUpdatesDatabaseUser(t *testing.T) {
directory := t.TempDir()
cfg := config.Default(directory)
cfg.SetupReady = true
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
cfg.DNS.VerificationState = "verified"
cfg.Web.Hostname = "scert.example.test"
cfg.Database.DSN = filepath.Join(directory, "data", "change-password.db")
db, err := database.Open(t.Context(), cfg.Database)
if err != nil {
t.Fatalf("打开数据库改密测试数据库失败:%v", err)
}
defer db.Close()
if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil {
t.Fatalf("迁移数据库改密测试数据库失败:%v", err)
}
users := repository.NewSQLUserRepository(db.DB, "sqlite")
if err := users.CreateUser(t.Context(), repository.User{
ID: "operator-1", Username: "operator", Email: "operator@example.test",
PasswordHash: httpUserPasswordHash(t, "Operator123"), Role: repository.UserRoleAdmin, Status: repository.UserStatusActive,
}); err != nil {
t.Fatalf("创建数据库改密测试用户失败:%v", err)
}
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, Users: users})
login := loginAuthTest(t, server, "operator", "Operator123", false)
if login.Code != http.StatusOK {
t.Fatalf("数据库用户登录失败:%d %s", login.Code, login.Body.String())
}
cookie := login.Result().Cookies()[0]
response := performChangePasswordRequest(t, server, cookie, `{"current_password":"Operator123","new_password":"NewOper123","confirm_password":"NewOper123"}`)
if response.Code != http.StatusOK {
t.Fatalf("数据库用户改密失败:%d %s", response.Code, response.Body.String())
}
if oldSession := authSessionRequest(server, cookie); oldSession.Code != http.StatusUnauthorized {
t.Fatalf("数据库用户改密后旧会话应失效,实际为 %d", oldSession.Code)
}
if newLogin := loginAuthTest(t, server, "operator@example.test", "NewOper123", false); newLogin.Code != http.StatusOK {
t.Fatalf("数据库用户改密后新密码登录失败:%d %s", newLogin.Code, newLogin.Body.String())
}
}
func TestChangePasswordRejectsInvalidInput(t *testing.T) {
directory := t.TempDir()
cfg := config.Default(directory)
cfg.SetupReady = true
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
cfg.DNS.VerificationState = "verified"
cfg.Web.Hostname = "scert.example.test"
cfgPath := filepath.Join(directory, "config", "scert.json")
if err := cfg.Save(cfgPath); err != nil {
t.Fatalf("保存非法改密测试配置失败:%v", err)
}
server := New(Dependencies{Config: cfg, ConfigPath: cfgPath, Repository: &stubDomainRepository{}})
login := loginAuthTest(t, server, "admin", "AdminPass123", false)
cookie := login.Result().Cookies()[0]
response := performChangePasswordRequest(t, server, cookie, `{"current_password":"wrong-password","new_password":"short","confirm_password":"different"}`)
if response.Code != http.StatusBadRequest {
t.Fatalf("非法改密请求应先返回参数错误,实际为 %d:%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "两次输入的新密码不一致") {
t.Fatalf("非法改密响应未说明确认密码错误:%s", response.Body.String())
}
}
func performChangePasswordRequest(t *testing.T, server *Server, cookie *http.Cookie, body string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
return response
}
func performLoginRequest(t *testing.T, server *Server, identifier, password string) *httptest.ResponseRecorder {
t.Helper()
body := `{"identifier":"` + identifier + `","password":"` + password + `","remember":false}`
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
return response
}

View File

@ -147,6 +147,7 @@ func (s *Server) registerRoutes() {
s.router.GET("/api/v1/auth/session", s.authSession)
s.router.POST("/api/v1/auth/login", s.authLogin)
s.router.POST("/api/v1/auth/logout", s.authLogout)
s.router.POST("/api/v1/auth/password", s.requireAuthenticated, s.changePassword)
s.router.POST("/api/v1/password-reset/resolve", s.resolvePasswordResetAccount)
adminAPI := s.router.Group("/api/v1", s.requireAdmin)
userAPI := s.router.Group("/api/v1/users", s.requireSuperAdmin)

View File

@ -3024,4 +3024,22 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无
### 76.2 验证状态
- `[✓]` `backend/service` 环境配置专项测试通过。
- `[!]` 真实 Wails 桌面打包、Windows Service 和 Linux systemd 实机验收仍需在授权目标环境执行。
- `[!]` 真实 Wails 桌面打包、Windows Service 和 Linux systemd 实机验收仍需在授权目标环境执行。
## 七十七、登录账号自助修改密码(2026-08-30)
### 77.1 本轮需求清单
- `[✓]` 增加已登录账号修改自身密码的 API,接口受登录状态和同源请求保护。
- `[✓]` 修改密码前校验当前密码、两次新密码一致性和 8 到 12 个字符的密码策略。
- `[✓]` 支持配置中的超级管理员密码持久化到配置文件,并同步数据库中的主超级管理员摘要。
- `[✓]` 支持数据库用户修改自身密码,并在保存后使该用户的旧会话全部失效。
- `[✓]` 修改成功后清除当前会话 Cookie,要求使用新密码重新登录,避免继续使用旧会话。
- `[✓]` Web 管理页面增加“修改密码”弹窗入口,成功后自动返回登录页面。
### 77.2 接口与验证状态
- `[✓]` 新增 `POST /api/v1/auth/password`,请求字段为 `current_password`、`new_password` 和 `confirm_password`。
- `[✓]` 后端专项测试覆盖超级管理员、数据库用户、旧会话失效、错误输入和新密码登录。
- `[✓]` 后端 `go test ./...` 和网页端 `npm.cmd run build` 已通过。
- `[!]` 真实部署环境的浏览器视觉复核、Wails 打包产物和远程 HTTPS 入口仍需目标环境验收。

View File

@ -1,4 +1,5 @@
<script setup>
import ChangePassword from "../components/ChangePassword/ChangePassword.vue";
import UserManagement from "../UserManagement/UserManagement.vue";
import { useDashboard } from "./Dashboard.js";
@ -115,6 +116,7 @@ const {
<h1>证书管理总览</h1>
</div>
<div class="topbar-actions">
<ChangePassword />
<button class="secondary-button" type="button" @click="loadData">
刷新状态
</button>

View File

@ -0,0 +1,41 @@
.change-password-modal {
width: min(100%, 560px);
}
.change-password-form {
display: grid;
gap: 16px;
}
.change-password-form small {
color: #8793a7;
font-size: 11px;
font-weight: 500;
}
.change-password-notice,
.change-password-error {
margin: 0;
padding: 11px 13px;
border-radius: 10px;
font-size: 13px;
line-height: 1.6;
}
.change-password-notice {
border: 1px solid #bfe7d1;
color: #24734b;
background: #effbf4;
}
.change-password-error {
border: 1px solid #f0c4c4;
color: #9c3b3b;
background: #fff2f2;
}
@media (max-width: 720px) {
.change-password-modal {
width: min(100%, calc(100vw - 28px));
}
}

View File

@ -0,0 +1,171 @@
<script setup>
import { onBeforeUnmount, ref } from "vue";
const visible = ref(false);
const submitting = ref(false);
const success = ref(false);
const error = ref("");
const notice = ref("");
const form = ref({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
let redirectTimer = 0;
function open() {
visible.value = true;
success.value = false;
error.value = "";
notice.value = "";
}
function close() {
if (submitting.value || success.value) {
return;
}
visible.value = false;
error.value = "";
notice.value = "";
form.value = {
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
}
async function submit() {
if (submitting.value) {
return;
}
error.value = "";
notice.value = "";
if (form.value.newPassword !== form.value.confirmPassword) {
error.value = "两次输入的新密码不一致。";
return;
}
submitting.value = true;
try {
const response = await fetch("/api/v1/auth/password", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
credentials: "include",
body: JSON.stringify({
current_password: form.value.currentPassword,
new_password: form.value.newPassword,
confirm_password: form.value.confirmPassword,
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || "修改密码失败,请稍后重试。");
}
success.value = true;
notice.value = result.message || "密码修改成功,请重新登录。";
redirectTimer = window.setTimeout(() => {
window.location.href = "/login";
}, 1600);
} catch (submitError) {
error.value = submitError.message || "修改密码失败,请稍后重试。";
} finally {
submitting.value = false;
}
}
onBeforeUnmount(() => {
if (redirectTimer) {
window.clearTimeout(redirectTimer);
}
});
</script>
<template>
<button class="secondary-button" type="button" @click="open">
修改密码
</button>
<div v-if="visible" class="modal-backdrop" @click.self="close">
<section
class="modal-card change-password-modal"
role="dialog"
aria-modal="true"
aria-labelledby="change-password-title"
>
<div class="modal-heading">
<div>
<p class="eyebrow">账户安全</p>
<h2 id="change-password-title">修改登录密码</h2>
</div>
<button
class="close-button"
type="button"
aria-label="关闭"
:disabled="submitting || success"
@click="close"
>
×
</button>
</div>
<form class="change-password-form" @submit.prevent="submit">
<label class="field">
<span>当前密码</span>
<input
v-model="form.currentPassword"
type="password"
autocomplete="current-password"
placeholder="请输入当前密码"
:disabled="submitting || success"
required
/>
</label>
<label class="field">
<span>新密码 <small>8 到 12 个字符,需同时包含字母和数字</small></span>
<input
v-model="form.newPassword"
type="password"
autocomplete="new-password"
minlength="8"
maxlength="12"
placeholder="请输入新密码"
:disabled="submitting || success"
required
/>
</label>
<label class="field">
<span>确认新密码</span>
<input
v-model="form.confirmPassword"
type="password"
autocomplete="new-password"
minlength="8"
maxlength="12"
placeholder="请再次输入新密码"
:disabled="submitting || success"
required
/>
</label>
<p v-if="notice" class="change-password-notice">{{ notice }}</p>
<p v-if="error" class="change-password-error">{{ error }}</p>
<div class="modal-actions">
<button
class="secondary-button"
type="button"
:disabled="submitting || success"
@click="close"
>
取消
</button>
<button class="primary-button" type="submit" :disabled="submitting || success">
{{ submitting ? "正在保存" : success ? "已完成" : "保存新密码" }}
</button>
</div>
</form>
</section>
</div>
</template>
<style src="./ChangePassword.css"></style>