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:
@@ -21,11 +21,12 @@ type Store struct {
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
Instances []model.Instance `json:"instances"`
|
||||
Users []model.VPNUser `json:"users"`
|
||||
Audits []model.AuditLog `json:"audits"`
|
||||
Instances []model.Instance `json:"instances"`
|
||||
Users []model.VPNUser `json:"users"`
|
||||
Audits []model.AuditLog `json:"audits"`
|
||||
ConnLogs []model.ConnectionLog `json:"conn_logs"`
|
||||
Backups []model.Backup `json:"backups"`
|
||||
Backups []model.Backup `json:"backups"`
|
||||
Admins []model.AdminUser `json:"admins"`
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
@@ -317,4 +318,79 @@ func (s *Store) DeleteBackup(id string) error {
|
||||
}
|
||||
s.data.Backups = append(s.data.Backups[:idx], s.data.Backups[idx+1:]...)
|
||||
return s.flush()
|
||||
}
|
||||
|
||||
// ---- Admins ----
|
||||
|
||||
func (s *Store) ListAdmins() []model.AdminUser {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]model.AdminUser, len(s.data.Admins))
|
||||
copy(out, s.data.Admins)
|
||||
// 不返回哈希,调用方负责清空 PasswordHash
|
||||
for i := range out {
|
||||
out[i].PasswordHash = ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) GetAdmin(id string) (*model.AdminUser, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := range s.data.Admins {
|
||||
if s.data.Admins[i].ID == id {
|
||||
a := s.data.Admins[i]
|
||||
return &a, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("admin %s not found", id)
|
||||
}
|
||||
|
||||
// GetAdminByUsername 包含哈希的内部副本。仅供登录校验使用。
|
||||
func (s *Store) GetAdminByUsername(username string) (*model.AdminUser, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := range s.data.Admins {
|
||||
if s.data.Admins[i].Username == username {
|
||||
a := s.data.Admins[i]
|
||||
return &a, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("admin %s not found", username)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertAdmin(a model.AdminUser) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.data.Admins {
|
||||
if s.data.Admins[i].ID == a.ID {
|
||||
s.data.Admins[i] = a
|
||||
return s.flush()
|
||||
}
|
||||
}
|
||||
s.data.Admins = append(s.data.Admins, a)
|
||||
return s.flush()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAdmin(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx := -1
|
||||
for i := range s.data.Admins {
|
||||
if s.data.Admins[i].ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("admin %s not found", id)
|
||||
}
|
||||
s.data.Admins = append(s.data.Admins[:idx], s.data.Admins[idx+1:]...)
|
||||
return s.flush()
|
||||
}
|
||||
|
||||
func (s *Store) AdminCount() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.data.Admins)
|
||||
}
|
||||
Reference in New Issue
Block a user