diff --git a/backend/internal/httpapi/auth.go b/backend/internal/httpapi/auth.go index 0f349bf..db35bf8 100644 --- a/backend/internal/httpapi/auth.go +++ b/backend/internal/httpapi/auth.go @@ -142,7 +142,7 @@ func (s *Server) authenticateAccount(ctx context.Context, request authLoginReque return auth.AdminAccount{}, "", "", auth.ErrInvalidCredentials } user, lookupErr := s.users.FindByIdentifier(ctx, request.Identifier) - if lookupErr != nil || user.Status != repository.UserStatusActive { + if lookupErr != nil || user.Status != repository.UserStatusActive || user.ID == "primary-super-admin" { return auth.AdminAccount{}, "", "", auth.ErrInvalidCredentials } account, err = auth.AuthenticateAccount(request.Identifier, request.Password, user.Username, user.Email, user.PasswordHash, user.Role) diff --git a/backend/internal/httpapi/users_api.go b/backend/internal/httpapi/users_api.go index 163e053..293ceec 100644 --- a/backend/internal/httpapi/users_api.go +++ b/backend/internal/httpapi/users_api.go @@ -93,6 +93,10 @@ func (s *Server) createUser(c *gin.Context) { s.writeError(c, http.StatusBadRequest, "用户参数无效", err) return } + if item.Role == repository.UserRoleSuperAdmin { + s.writeError(c, http.StatusBadRequest, "系统只允许初始化时创建一个超级管理员", nil) + return + } if err := s.users.CreateUser(c.Request.Context(), item); err != nil { s.writeError(c, userErrorStatus(err), "创建用户失败", err) return @@ -107,6 +111,10 @@ func (s *Server) updateUser(c *gin.Context) { return } id := strings.TrimSpace(c.Param("id")) + if id == "primary-super-admin" { + s.writeError(c, http.StatusBadRequest, "主超级管理员不能通过用户管理修改", nil) + return + } existing, err := s.users.GetUserByID(c.Request.Context(), id) if err != nil { if repository.IsNotFound(err) { @@ -135,6 +143,10 @@ func (s *Server) updateUser(c *gin.Context) { if request.Status == "" { item.Status = existing.Status } + if existing.Role != repository.UserRoleSuperAdmin && item.Role == repository.UserRoleSuperAdmin { + s.writeError(c, http.StatusBadRequest, "不能将用户提升为超级管理员", nil) + return + } if existing.Role == repository.UserRoleSuperAdmin && item.Role != repository.UserRoleSuperAdmin { s.writeError(c, http.StatusBadRequest, "不能降低超级管理员权限", nil) return diff --git a/backend/internal/httpapi/users_api_test.go b/backend/internal/httpapi/users_api_test.go index 2d385d3..0ef4a8c 100644 --- a/backend/internal/httpapi/users_api_test.go +++ b/backend/internal/httpapi/users_api_test.go @@ -89,16 +89,12 @@ func TestUserManagementAuthorizationAndCRUD(t *testing.T) { } secondSuperAdmin := serveUserRequest(server, adminCookie, http.MethodPost, "/api/v1/users", `{"username":"owner","password":"OwnerPass123","role":"super_admin","status":"active"}`) - if secondSuperAdmin.Code != http.StatusCreated { - t.Fatalf("创建第二个超级管理员失败:%d %s", secondSuperAdmin.Code, secondSuperAdmin.Body.String()) + if secondSuperAdmin.Code != http.StatusBadRequest { + t.Fatalf("新增第二个超级管理员应被拒绝:%d %s", secondSuperAdmin.Code, secondSuperAdmin.Body.String()) } - var second repository.User - if err := json.Unmarshal(secondSuperAdmin.Body.Bytes(), &second); err != nil || second.ID == "" { - t.Fatalf("解析第二个超级管理员响应失败:%v %s", err, secondSuperAdmin.Body.String()) - } - deleted := serveUserRequest(server, adminCookie, http.MethodDelete, "/api/v1/users/"+second.ID, "") + deleted := serveUserRequest(server, adminCookie, http.MethodDelete, "/api/v1/users/"+createdUser.ID, "") if deleted.Code != http.StatusNoContent { - t.Fatalf("删除第二个超级管理员失败:%d %s", deleted.Code, deleted.Body.String()) + t.Fatalf("删除普通管理员失败:%d %s", deleted.Code, deleted.Body.String()) } protected := serveUserRequest(server, adminCookie, http.MethodDelete, "/api/v1/users/primary-super-admin", "") if protected.Code != http.StatusBadRequest { diff --git a/backend/internal/httpapi/users_protection_test.go b/backend/internal/httpapi/users_protection_test.go new file mode 100644 index 0000000..5e92914 --- /dev/null +++ b/backend/internal/httpapi/users_protection_test.go @@ -0,0 +1,170 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "path/filepath" + "reflect" + "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 protectedUserServer(t *testing.T) (*Server, *repository.SQLUserRepository, string) { + t.Helper() + directory := t.TempDir() + cfg := config.Default(directory) + cfg.SetupReady = true + cfg.DNS.VerificationState = "verified" + cfg.Web.Hostname = "scert.example.test" + cfg.Admin.Username = "admin" + cfg.Admin.Email = "admin@example.test" + cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123") + cfg.Database.DSN = filepath.Join(directory, "data", "users.db") + configPath := filepath.Join(directory, "config", "scert.json") + if err := cfg.Save(configPath); err != nil { + t.Fatal(err) + } + db, err := database.Open(t.Context(), cfg.Database) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + users := repository.NewSQLUserRepository(db.DB, "sqlite") + if err := users.EnsureSuperAdmin(t.Context(), repository.User{ + ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, + PasswordHash: cfg.Admin.PasswordHash, + }); err != nil { + t.Fatal(err) + } + return New(Dependencies{Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}, Users: users}), users, configPath +} + +func TestUserManagementProtectsPrimaryAccount(t *testing.T) { + for _, change := range []struct{ name, field, value string }{ + {"username", "username", "renamed"}, + {"email", "email", "changed@example.test"}, + {"password", "password", "Changed123"}, + {"status", "status", "disabled"}, + {"role", "role", "admin"}, + } { + t.Run(change.name, func(t *testing.T) { + server, users, _ := protectedUserServer(t) + before, err := users.GetUserByID(t.Context(), "primary-super-admin") + if err != nil { + t.Fatal(err) + } + cookie := loginAuthTest(t, server, "admin", "AdminPass123", false).Result().Cookies()[0] + payload := map[string]string{"username": before.Username, "email": before.Email, "role": before.Role, "status": before.Status} + payload[change.field] = change.value + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + response := serveUserRequest(server, cookie, http.MethodPut, "/api/v1/users/primary-super-admin", string(body)) + if response.Code != http.StatusBadRequest { + t.Fatalf("用户管理不应修改主超级管理员:%d %s", response.Code, response.Body.String()) + } + after, err := users.GetUserByID(t.Context(), before.ID) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("被拒绝的修改不应改变数据库:%v", err) + } + if response := authSessionRequest(server, cookie); response.Code != http.StatusOK { + t.Fatalf("被拒绝的修改不应注销现有会话:%d", response.Code) + } + loginAuthTest(t, server, "admin@example.test", "AdminPass123", false) + }) + } +} + +func TestUserManagementRejectsSuperAdminCreationAndPromotion(t *testing.T) { + server, users, _ := protectedUserServer(t) + cookie := loginAuthTest(t, server, "admin", "AdminPass123", false).Result().Cookies()[0] + for _, role := range []string{"super_admin", " SUPER_ADMIN "} { + body, _ := json.Marshal(userRequest{Username: "owner", Password: "OwnerPass123", Role: role}) + response := serveUserRequest(server, cookie, http.MethodPost, "/api/v1/users", string(body)) + if response.Code != http.StatusBadRequest { + t.Fatalf("不应新增超级管理员:%d %s", response.Code, response.Body.String()) + } + } + created := serveUserRequest(server, cookie, http.MethodPost, "/api/v1/users", `{"username":"operator","password":"OperPass123","role":"admin"}`) + if created.Code != http.StatusCreated { + t.Fatalf("应允许新增管理员:%d %s", created.Code, created.Body.String()) + } + var item repository.User + if err := json.Unmarshal(created.Body.Bytes(), &item); err != nil { + t.Fatal(err) + } + for _, role := range []string{"super_admin", " SUPER_ADMIN "} { + body, _ := json.Marshal(userRequest{Username: item.Username, Role: role}) + response := serveUserRequest(server, cookie, http.MethodPut, "/api/v1/users/"+item.ID, string(body)) + if response.Code != http.StatusBadRequest { + t.Fatalf("不应提权为超级管理员:%d %s", response.Code, response.Body.String()) + } + } + stored, err := users.GetUserByID(t.Context(), item.ID) + if err != nil || stored.Role != repository.UserRoleAdmin { + t.Fatalf("拒绝提权后应保留原角色:%v", err) + } + items, err := users.ListUsers(t.Context()) + if err != nil || len(items) != 2 { + t.Fatalf("拒绝新增后不应产生额外用户:%v", err) + } +} + +func TestPrimaryPasswordChangePersistsWithDatabase(t *testing.T) { + server, users, configPath := protectedUserServer(t) + cookie := loginAuthTest(t, server, "admin", "AdminPass123", false).Result().Cookies()[0] + response := performChangePasswordRequest(t, server, cookie, `{"current_password":"AdminPass123","new_password":"Changed123","confirm_password":"Changed123"}`) + if response.Code != http.StatusOK { + t.Fatalf("持当前密码的自助改密应成功:%d %s", response.Code, response.Body.String()) + } + if response := authSessionRequest(server, cookie); response.Code != http.StatusUnauthorized { + t.Fatalf("改密后旧会话应失效:%d", response.Code) + } + saved, err := config.Load(configPath) + if err != nil { + t.Fatal(err) + } + stored, err := users.GetUserByID(t.Context(), "primary-super-admin") + if err != nil || stored.PasswordHash != saved.Admin.PasswordHash { + t.Fatalf("改密后配置和数据库应一致:%v", err) + } + restarted := New(Dependencies{Config: saved, ConfigPath: configPath, Repository: &stubDomainRepository{}, Users: users}) + for _, candidate := range []*Server{server, restarted} { + if old := performLoginRequest(t, candidate, "admin", "AdminPass123"); old.Code != http.StatusUnauthorized { + t.Fatalf("旧密码不应登录成功:%d", old.Code) + } + login := loginAuthTest(t, candidate, "admin@example.test", "Changed123", false) + if session := authSessionRequest(candidate, login.Result().Cookies()[0]); session.Code != http.StatusOK { + t.Fatalf("新密码登录后会话应有效:%d", session.Code) + } + } +} + +func TestPrimaryLoginRejectsStaleDatabaseCredentials(t *testing.T) { + server, users, _ := protectedUserServer(t) + stored, err := users.GetUserByID(t.Context(), "primary-super-admin") + if err != nil { + t.Fatal(err) + } + stored.PasswordHash = httpUserPasswordHash(t, "StalePass123") + if err := users.UpdateUser(t.Context(), stored); err != nil { + t.Fatal(err) + } + for _, identifier := range []string{stored.Username, stored.Email} { + response := performLoginRequest(t, server, identifier, "StalePass123") + if response.Code != http.StatusUnauthorized || len(response.Result().Cookies()) != 0 { + t.Fatalf("数据库残留凭据不应取得登录会话:%d", response.Code) + } + } + login := loginAuthTest(t, server, "admin", "AdminPass123", false) + if session := authSessionRequest(server, login.Result().Cookies()[0]); session.Code != http.StatusOK { + t.Fatalf("配置中的主账号凭据应仍然有效:%d", session.Code) + } +} diff --git a/frontend/package.json b/frontend/package.json index 5b9089a..85aafec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { - "test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js", + "test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/UserManagement/UserManagement.test.js", "dev": "vite", "build": "vite build", "preview": "vite preview" diff --git a/frontend/src/views/UserManagement/UserManagement.js b/frontend/src/views/UserManagement/UserManagement.js index 13bc833..ea29612 100644 --- a/frontend/src/views/UserManagement/UserManagement.js +++ b/frontend/src/views/UserManagement/UserManagement.js @@ -20,6 +20,7 @@ export function useUserManagement() { const notice = ref(""); const showForm = ref(false); const editingId = ref(""); + const editingRole = ref(""); const formError = ref(""); const form = ref(createEmptyForm()); const isEditing = computed(() => Boolean(editingId.value)); @@ -75,13 +76,16 @@ export function useUserManagement() { function openCreate() { editingId.value = ""; + editingRole.value = ""; form.value = createEmptyForm(); formError.value = ""; showForm.value = true; } function openEdit(item) { + if (isPrimaryAccount(item)) return; editingId.value = item.id; + editingRole.value = item.role; form.value = { username: item.username || "", email: item.email || "", @@ -99,7 +103,15 @@ export function useUserManagement() { async function saveUser() { formError.value = ""; + if (editingId.value === "primary-super-admin") { + formError.value = "主超级管理员不能通过用户管理修改"; + return; + } const payload = { ...form.value }; + if (payload.role.trim().toLowerCase() === "super_admin" && editingRole.value !== "super_admin") { + formError.value = "不能新增或提升为超级管理员"; + return; + } if (!payload.password) delete payload.password; if (!payload.username.trim()) { formError.value = "请输入用户名"; @@ -131,6 +143,7 @@ export function useUserManagement() { } async function toggleStatus(item) { + if (isPrimaryAccount(item)) return; const nextStatus = item.status === "active" ? "disabled" : "active"; if (item.role === "super_admin" && nextStatus === "disabled") { notice.value = "最后一个超级管理员不能被禁用"; @@ -156,6 +169,7 @@ export function useUserManagement() { } async function deleteUser(item) { + if (isPrimaryAccount(item)) return; if (!window.confirm(`确定删除用户“${item.username}”吗?`)) return; try { const response = await request(`/api/v1/users/${item.id}`, { @@ -170,6 +184,10 @@ export function useUserManagement() { } } + function isPrimaryAccount(item) { + return item.id === "primary-super-admin"; + } + onMounted(loadUsers); return { @@ -186,6 +204,7 @@ export function useUserManagement() { isEditing, roleLabels, statusLabels, + isPrimaryAccount, openCreate, openEdit, closeForm, @@ -194,4 +213,4 @@ export function useUserManagement() { deleteUser, loadUsers, }; -} \ No newline at end of file +} diff --git a/frontend/src/views/UserManagement/UserManagement.test.js b/frontend/src/views/UserManagement/UserManagement.test.js new file mode 100644 index 0000000..729efaa --- /dev/null +++ b/frontend/src/views/UserManagement/UserManagement.test.js @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { useUserManagement } from "./UserManagement.js"; +import { mountComposable } from "../../test-support/mount.js"; + +async function setup(t) { + const calls = []; + const mounted = await mountComposable(t, useUserManagement, async (path, options) => { + calls.push({ path, options }); + return Response.json({ items: [] }); + }); + calls.length = 0; + return { ...mounted, calls }; +} + +test("主超级管理员不开放用户管理修改操作", async (t) => { + const { state, calls } = await setup(t); + const item = { id: "primary-super-admin", role: "super_admin", status: "active" }; + assert.equal(state.isPrimaryAccount(item), true); + state.openEdit(item); + assert.equal(state.showForm.value, false); + await state.toggleStatus(item); + await state.deleteUser(item); + assert.equal(calls.length, 0); +}); + +test("禁止新增或提升为超级管理员,包括非标准大小写", async (t) => { + const { state, calls } = await setup(t); + for (const editing of [false, true]) { + for (const role of ["super_admin", " SUPER_ADMIN "]) { + if (editing) state.openEdit({ id: "operator", username: "operator", role: "admin" }); + else state.openCreate(); + Object.assign(state.form.value, { username: "operator", password: "OperPass123", role }); + await state.saveUser(); + assert.equal(state.formError.value, "不能新增或提升为超级管理员"); + } + } + assert.equal(calls.length, 0); +}); + +test("管理员和普通用户仍能正常创建及编辑", async (t) => { + const { state, calls } = await setup(t); + for (const role of ["admin", "user"]) { + state.openCreate(); + Object.assign(state.form.value, { username: "operator", password: "OperPass123", role }); + await state.saveUser(); + state.openEdit({ id: "operator", username: "operator", role, status: "active" }); + await state.saveUser(); + } + const writes = calls.filter(({ options }) => options.method); + assert.deepEqual(writes.map(({ options }) => options.method), ["POST", "PUT", "POST", "PUT"]); + assert.equal(state.formError.value, ""); +}); diff --git a/frontend/src/views/UserManagement/UserManagement.vue b/frontend/src/views/UserManagement/UserManagement.vue index 906b858..16b6761 100644 --- a/frontend/src/views/UserManagement/UserManagement.vue +++ b/frontend/src/views/UserManagement/UserManagement.vue @@ -15,6 +15,7 @@ const { isEditing, roleLabels, statusLabels, + isPrimaryAccount, openCreate, openEdit, closeForm, @@ -68,7 +69,8 @@ const {