Add multi-admin account management

Schema:
- New AdminUser model with bcrypt-hashed password (cost 10)
- Roles: admin (full) / operator (read-only ops)
- Status: active / disabled
- MustChangePassword flag forces first-login password change

Backend:
- store: Add admins [] + CRUD methods (ListAdmins strips PasswordHash)
- service: SeedDefaultAdminIfEmpty (uses env credentials on first run),
  CreateAdmin, ChangePassword, ResetPassword, SetAdminStatus, DeleteAdmin
- middleware: JWT now carries user_id (UUID)
- api: login() uses bcrypt + updates last_login_at/ip, blocks disabled
- api: me() returns role + must_change_password
- api: new endpoints:
    POST /api/me/password       (self password change)
    GET  /api/admins
    POST /api/admins             (create)
    POST /api/admins/:id/password (reset by admin)
    POST /api/admins/:id/status   (enable/disable)
    DELETE /api/admins/:id        (with self/last-admin guard)

Frontend:
- Login: must_change_password=true triggers forced change-password dialog
- Layout: admin dropdown shows role tag + 修改密码 / 退出登录
- New /admins page (admin only) with table + create/reset/status/delete
- Router guard hides /admins from non-admin accounts
- API client: Auth.changePassword, Admins.{list,create,resetPassword,setStatus,delete}

Security:
- PasswordHash stored as bcrypt $2a$10$... in db.json
- ListAdmins always returns PasswordHash=''; never leaks via API
- Login returns 403 for disabled accounts

Verified: 21/21 API tests + browser E2E (first-login forced change,
restart persistence, admin list without hash, role-based menu)
This commit is contained in:
cnbugs
2026-08-09 21:45:41 +08:00
parent 4c8b7b5188
commit 09f6918aeb
12 changed files with 778 additions and 23 deletions
+141
View File
@@ -16,6 +16,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"openvpn-manager/internal/config"
"openvpn-manager/internal/model"
@@ -582,4 +583,144 @@ func RandomToken(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// ---------- Admin 账号管理 ----------
// SeedDefaultAdminIfEmpty 当 db.json 中无任何 admin 账号时,用 env 里的默认账号创建。
// MustChangePassword=true,提示用户首次登录后必须改密。
func (s *Service) SeedDefaultAdminIfEmpty() error {
if s.Store.AdminCount() > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(s.Cfg.AdminPass), bcrypt.DefaultCost)
if err != nil {
return err
}
now := time.Now()
return s.Store.UpsertAdmin(model.AdminUser{
ID: uuid.NewString(),
Username: s.Cfg.AdminUser,
PasswordHash: string(hash),
Role: "admin",
Status: "active",
CreatedAt: now,
UpdatedAt: now,
MustChangePassword: true,
})
}
// CreateAdmin 新建管理员账号。密码由调用方提供,内部 bcrypt 哈希后存。
// 只允许 role=admin 的现有账号调用。
func (s *Service) CreateAdmin(username, password, role string) (*model.AdminUser, error) {
if len(username) < 3 {
return nil, fmt.Errorf("用户名至少 3 个字符")
}
if len(password) < 6 {
return nil, fmt.Errorf("密码至少 6 个字符")
}
if _, err := s.Store.GetAdminByUsername(username); err == nil {
return nil, fmt.Errorf("用户名已存在")
}
if role != "admin" && role != "operator" {
role = "operator"
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
now := time.Now()
a := model.AdminUser{
ID: uuid.NewString(),
Username: username,
PasswordHash: string(hash),
Role: role,
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
if err := s.Store.UpsertAdmin(a); err != nil {
return nil, err
}
return &a, nil
}
// ChangePassword 修改自身密码。校验旧密码后写新密码。
// actorID 是当前登录账号的 id。
func (s *Service) ChangePassword(actorID, oldPwd, newPwd string) error {
if len(newPwd) < 6 {
return fmt.Errorf("新密码至少 6 个字符")
}
a, err := s.Store.GetAdmin(actorID)
if err != nil {
return err
}
if err := bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(oldPwd)); err != nil {
return fmt.Errorf("原密码错误")
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
if err != nil {
return err
}
a.PasswordHash = string(hash)
a.MustChangePassword = false
a.UpdatedAt = time.Now()
return s.Store.UpsertAdmin(*a)
}
// ResetPassword 由管理员重置他人密码(无需知道旧密码)。
func (s *Service) ResetPassword(targetID, newPwd string) error {
if len(newPwd) < 6 {
return fmt.Errorf("新密码至少 6 个字符")
}
a, err := s.Store.GetAdmin(targetID)
if err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
if err != nil {
return err
}
a.PasswordHash = string(hash)
a.MustChangePassword = false
a.UpdatedAt = time.Now()
return s.Store.UpsertAdmin(*a)
}
// SetAdminStatus 启用/禁用账号。
func (s *Service) SetAdminStatus(targetID, status string) error {
if status != "active" && status != "disabled" {
return fmt.Errorf("status 必须是 active 或 disabled")
}
a, err := s.Store.GetAdmin(targetID)
if err != nil {
return err
}
a.Status = status
a.UpdatedAt = time.Now()
return s.Store.UpsertAdmin(*a)
}
// DeleteAdmin 删除账号。保护:不能删除自己、不能删除最后一个 admin。
func (s *Service) DeleteAdmin(actorID, targetID string) error {
if actorID == targetID {
return fmt.Errorf("不能删除自己")
}
target, err := s.Store.GetAdmin(targetID)
if err != nil {
return err
}
if target.Role == "admin" {
// 统计其他 admin 数量
others := 0
for _, a := range s.Store.ListAdmins() {
if a.Role == "admin" && a.ID != targetID {
others++
}
}
if others == 0 {
return fmt.Errorf("不能删除最后一个 admin 账号")
}
}
return s.Store.DeleteAdmin(targetID)
}