diff --git a/backend/internal/httpapi/middleware.go b/backend/internal/httpapi/middleware.go index 98c43ed..5f4339f 100644 --- a/backend/internal/httpapi/middleware.go +++ b/backend/internal/httpapi/middleware.go @@ -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 diff --git a/backend/internal/httpapi/password_api.go b/backend/internal/httpapi/password_api.go new file mode 100644 index 0000000..0712546 --- /dev/null +++ b/backend/internal/httpapi/password_api.go @@ -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, + }) +} diff --git a/backend/internal/httpapi/password_api_test.go b/backend/internal/httpapi/password_api_test.go new file mode 100644 index 0000000..0e84492 --- /dev/null +++ b/backend/internal/httpapi/password_api_test.go @@ -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 +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 25ef6e4..b700c7f 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -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) diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 2ba26c1..8bdc0c1 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3024,4 +3024,22 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 ### 76.2 验证状态 - `[✓]` `backend/service` 环境配置专项测试通过。 -- `[!]` 真实 Wails 桌面打包、Windows Service 和 Linux systemd 实机验收仍需在授权目标环境执行。 \ No newline at end of file +- `[!]` 真实 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 入口仍需目标环境验收。 diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 34aa151..7fad4f6 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -1,4 +1,5 @@ + + + + 修改密码 + + + + + + + 账户安全 + 修改登录密码 + + + × + + + + + + 当前密码 + + + + 新密码 8 到 12 个字符,需同时包含字母和数字 + + + + 确认新密码 + + + {{ notice }} + {{ error }} + + + 取消 + + + {{ submitting ? "正在保存" : success ? "已完成" : "保存新密码" }} + + + + + + + +
账户安全
{{ notice }}
{{ error }}