修复主超级管理员配置与用户管理边界并补充登录回归

This commit is contained in:
xuans 2026-09-17 12:37:01 +08:00
parent a70aa5fe2d
commit 60b00f1bad
8 changed files with 266 additions and 14 deletions

View File

@ -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)

View File

@ -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

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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"

View File

@ -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,
};
}
}

View File

@ -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, "");
});

View File

@ -15,6 +15,7 @@ const {
isEditing,
roleLabels,
statusLabels,
isPrimaryAccount,
openCreate,
openEdit,
closeForm,
@ -68,7 +69,8 @@ const {
<td><span class="user-status" :class="item.status">{{ statusLabels[item.status] || item.status }}</span></td>
<td>{{ new Date(item.created_at).toLocaleString("zh-CN") }}</td>
<td>
<div class="user-row-actions">
<span v-if="isPrimaryAccount(item)" class="user-badge">受保护账号</span>
<div v-else class="user-row-actions">
<button class="text-button" type="button" @click="openEdit(item)">编辑</button>
<button class="text-button" type="button" @click="toggleStatus(item)">
{{ item.status === "active" ? "禁用" : "启用" }}
@ -111,7 +113,7 @@ const {
<select v-model="form.role">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
<option value="super_admin">超级管理员</option>
<option v-if="isEditing && form.role === 'super_admin'" value="super_admin" disabled>超级管理员</option>
</select>
</label>
<label class="field">
@ -135,4 +137,4 @@ const {
<style scoped>
@import "./UserManagement.css";
</style>
</style>