修复主超级管理员配置与用户管理边界并补充登录回归
This commit is contained in:
parent
a70aa5fe2d
commit
60b00f1bad
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
170
backend/internal/httpapi/users_protection_test.go
Normal file
170
backend/internal/httpapi/users_protection_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
53
frontend/src/views/UserManagement/UserManagement.test.js
Normal file
53
frontend/src/views/UserManagement/UserManagement.test.js
Normal 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, "");
|
||||
});
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user