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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user