From 09f6918aebe144b757c58aefb8db77d96623e651 Mon Sep 17 00:00:00 2001 From: cnbugs Date: Sun, 9 Aug 2026 21:45:41 +0800 Subject: [PATCH] Add multi-admin account management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 39 ++++++- backend/internal/api/router.go | 164 +++++++++++++++++++++++++++- backend/internal/middleware/auth.go | 5 +- backend/internal/model/model.go | 24 ++++ backend/internal/service/service.go | 141 ++++++++++++++++++++++++ backend/internal/store/store.go | 84 +++++++++++++- docs/API.md | 83 +++++++++++++- frontend/src/api/index.js | 8 ++ frontend/src/layout/Index.vue | 57 +++++++++- frontend/src/router/index.js | 8 ++ frontend/src/views/Admins.vue | 146 +++++++++++++++++++++++++ frontend/src/views/Login.vue | 42 ++++++- 12 files changed, 778 insertions(+), 23 deletions(-) create mode 100644 frontend/src/views/Admins.vue diff --git a/README.md b/README.md index e1a1ec3..93bb548 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ | 客户端证书 | 一键签发,自动生成 `.ovpn`(内嵌 CA/Cert/Key/TLS-Auth),无需额外文件 | | 固定 IP | 通过 CCD (`client-config-dir`) 为指定用户分配固定 VPN IP | | **访问控制(白名单)** | 实例/用户两层 allow_networks,合并生效;服务端 iptables 强制隔离 | +| **多账号管理** | db.json 中存 bcrypt 哈希,可创建多个 admin/operator 账号,首登强制改密 | | 启停控制 | Web 一键启动/停止实例,显示 PID 与状态 | | 流量审计 | 解析 `status-version 3` 输出,记录上下行字节/连接时长 | | 证书到期提醒 | 仪表盘统计 30 天内到期的证书,单独证书管理页查看完整清单 | @@ -283,17 +284,47 @@ sudo ./scripts/install.sh -u ⚠️ **生产环境第一步**。 -当前版本通过 systemd 环境变量修改密码: +### 首次登录强制改密 + +首次访问用默认账号 `admin/admin123` 登录后,系统**强制**弹出"修改初始密码"对话框, +必须改成 ≥6 位的强密码才能继续使用,无法跳过。这一步密码修改后会写入 `data/db.json`, +**之后不再需要重启服务**,密码以 bcrypt 哈希持久化。 + +### 通过 Web 修改自己的密码 + +右上角 admin 下拉 → "修改密码" → 填写当前密码 + 新密码 → 保存,立即生效。 + +### 创建/管理其他账号 + +Web → 左侧"管理员"菜单(admin role 才可见): + +| 操作 | 说明 | +| ---- | ---- | +| 新建账号 | 设置用户名、密码、角色(admin / operator) | +| 重置密码 | 不知道旧密码也能强制改密,新密码立即生效 | +| 启用/禁用 | 禁用后该账号无法登录(已登录的 token 仍可短期使用) | +| 删除 | 不能删除自己、不能删除最后一个 admin | + +所有账号操作都进入审计日志。 + +### 通过 systemd 环境变量重置(忘记密码时) + +如果你把所有 admin 都禁用/删除了,或者忘了所有密码,可以临时用环境变量密码 +覆盖(只用于"首次登录然后改密",不持久): ```bash systemctl edit --full openvpn-manager -# 找到 OVPNMGR_ADMIN_PASS 一行,改成你的强密码 -# 也建议修改 OVPNMGR_JWT_SECRET 为随机串 +# 找到 OVPNMGR_ADMIN_PASS 一行,改成你临时用的密码 systemctl daemon-reload systemctl restart openvpn-manager +# 用新密码登录,系统会触发 SeedDefaultAdminIfEmpty +# 注:此路径只在 db.json 中无任何 admin 时生效;否则会校验 db.json 中的账号 ``` -或重新跑 `install.sh --pass 新密码`。 +更可靠的做法:**直接编辑 `data/db.json`**,把 `password_hash` 替换为 bcrypt 哈希 +(用 `htpasswd -bnBC 10 "" yourpass | tr -d ':\n'` 生成)后重启服务。 + +> 环境变量里的 `OVPNMGR_ADMIN_USER` / `OVPNMGR_ADMIN_PASS` 仅在数据库为空时作为初始 seed 使用。 ## 创建第一个实例 diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index c174961..533f54d 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -9,6 +9,7 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" "openvpn-manager/internal/config" "openvpn-manager/internal/middleware" @@ -72,6 +73,16 @@ func (s *Server) Router(distDir string) *gin.Engine { auth.DELETE("/backups/:id", s.deleteBackup) auth.GET("/audits", s.listAudits) + + // Admin 账号管理(self) + auth.POST("/me/password", s.changeMyPassword) + + // Admin 账号管理(列表/创建/重置密码/启停/删除 - 仅 admin role) + auth.GET("/admins", s.listAdmins) + auth.POST("/admins", s.createAdmin) + auth.POST("/admins/:id/password", s.resetAdminPassword) + auth.POST("/admins/:id/status", s.setAdminStatus) + auth.DELETE("/admins/:id", s.deleteAdmin) } // 静态前端 @@ -106,23 +117,72 @@ func (s *Server) login(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) return } - if req.Username != s.Cfg.AdminUser || req.Password != s.Cfg.AdminPass { + // 从 admin 表验证;若表为空则用环境变量默认账号首次 seed + admin, err := s.Svc.Store.GetAdminByUsername(req.Username) + if err != nil { + // 兼容:如果 db.json 还没有 admins(首次启动),允许用 env 默认账号登录, + // 登录成功后由 SeedDefaultIfEmpty 自动 seed 到 db.json + if s.Svc.Store.AdminCount() == 0 && req.Username == s.Cfg.AdminUser && req.Password == s.Cfg.AdminPass { + if err := s.Svc.SeedDefaultAdminIfEmpty(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "seed admin: " + err.Error()}) + return + } + admin, err = s.Svc.Store.GetAdminByUsername(req.Username) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + } else { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + } + if admin.Status != "active" { + c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁用"}) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(req.Password)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) return } - tok, err := middleware.IssueToken(s.Cfg.JWTSecret, req.Username, "admin", 12*time.Hour) + // 记录登录时间 + now := time.Now() + admin.LastLoginAt = &now + admin.LastLoginIP = c.ClientIP() + _ = s.Svc.Store.UpsertAdmin(*admin) + // 颁发 token + tok, err := middleware.IssueToken(s.Cfg.JWTSecret, admin.ID, admin.Username, admin.Role, 12*time.Hour) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "issue token failed"}) return } - c.JSON(200, gin.H{"token": tok, "username": req.Username}) + c.JSON(200, gin.H{ + "token": tok, + "username": admin.Username, + "role": admin.Role, + "must_change_password": admin.MustChangePassword, + }) } func (s *Server) logout(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) } func (s *Server) me(c *gin.Context) { - u, _ := c.Get("user") - c.JSON(200, gin.H{"username": u}) + userID, _ := c.Get("user_id") + uid, _ := userID.(string) + username, _ := c.Get("user") + role, _ := c.Get("role") + must := false + if uid != "" { + if a, err := s.Svc.Store.GetAdmin(uid); err == nil { + must = a.MustChangePassword + } + } + c.JSON(200, gin.H{ + "user_id": uid, + "username": username, + "role": role, + "must_change_password": must, + }) } // dashboard 汇总统计 @@ -431,4 +491,98 @@ func itoa(n int) string { buf[i] = '-' } return string(buf[i:]) +} + +// ---------- Admin 账号管理 ---------- + +func (s *Server) changeMyPassword(c *gin.Context) { + var req struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + userID, _ := c.Get("user_id") + uid, _ := userID.(string) + if err := s.Svc.ChangePassword(uid, req.OldPassword, req.NewPassword); err != nil { + s.Svc.AuditForAPI(c, "change_password", uid, err.Error(), "failed") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s.Svc.AuditForAPI(c, "change_password", uid, "", "ok") + c.JSON(200, gin.H{"ok": true}) +} + +func (s *Server) listAdmins(c *gin.Context) { + c.JSON(200, s.Svc.Store.ListAdmins()) +} + +func (s *Server) createAdmin(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + a, err := s.Svc.CreateAdmin(req.Username, req.Password, req.Role) + if err != nil { + s.Svc.AuditForAPI(c, "create_admin", req.Username, err.Error(), "failed") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s.Svc.AuditForAPI(c, "create_admin", req.Username, "role="+a.Role, "ok") + c.JSON(200, a) +} + +func (s *Server) resetAdminPassword(c *gin.Context) { + id := c.Param("id") + var req struct { + NewPassword string `json:"new_password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + if err := s.Svc.ResetPassword(id, req.NewPassword); err != nil { + s.Svc.AuditForAPI(c, "reset_password", id, err.Error(), "failed") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s.Svc.AuditForAPI(c, "reset_password", id, "", "ok") + c.JSON(200, gin.H{"ok": true}) +} + +func (s *Server) setAdminStatus(c *gin.Context) { + id := c.Param("id") + var req struct { + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + if err := s.Svc.SetAdminStatus(id, req.Status); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s.Svc.AuditForAPI(c, "set_admin_status", id, req.Status, "ok") + c.JSON(200, gin.H{"ok": true}) +} + +func (s *Server) deleteAdmin(c *gin.Context) { + id := c.Param("id") + userID, _ := c.Get("user_id") + uid, _ := userID.(string) + if err := s.Svc.DeleteAdmin(uid, id); err != nil { + s.Svc.AuditForAPI(c, "delete_admin", id, err.Error(), "failed") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s.Svc.AuditForAPI(c, "delete_admin", id, "", "ok") + c.JSON(200, gin.H{"ok": true}) } \ No newline at end of file diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index c6c68cb..8e909b9 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -10,13 +10,15 @@ import ( ) type Claims struct { + UserID string `json:"user_id"` Username string `json:"username"` Role string `json:"role"` jwt.RegisteredClaims } -func IssueToken(secret, username, role string, ttl time.Duration) (string, error) { +func IssueToken(secret, userID, username, role string, ttl time.Duration) (string, error) { c := Claims{ + UserID: userID, Username: username, Role: role, RegisteredClaims: jwt.RegisteredClaims{ @@ -50,6 +52,7 @@ func JWTAuth(secret string) gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } + c.Set("user_id", claims.UserID) c.Set("user", claims.Username) c.Set("role", claims.Role) c.Next() diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index eba6a4e..fba67a2 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -64,6 +64,30 @@ type AuditLog struct { IP string `json:"ip"` } +// AdminUser 管理控制台账号。 +// 密码以 bcrypt 哈希存储 (cost=10)。 +// - Role: "admin" = 全部权限 (含账号管理) +// "operator" = 仅运维操作(实例/用户/备份等),无账号管理 +// - Status: "active" | "disabled" +// - MustChangePassword: 首次 seed 的默认账号(密码 = admin123)需要首次登录后改密 +// +// 安全: +// * PasswordHash 字段在 db.json 中以 password_hash 持久化(必须!) +// * 但 store.ListAdmins 在返回前会清空 PasswordHash, +// 因此所有 API 响应里 hash 都是空字符串,绝不出网 +type AdminUser struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` // 注意:store.ListAdmins 返回时会清空 + Role string `json:"role"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + LastLoginIP string `json:"last_login_ip,omitempty"` + MustChangePassword bool `json:"must_change_password"` // 强制改密标志 +} + // ConnectionLog 来自 OpenVPN status 的实时/历史连接记录。 // 周期由 OpenVPN 自身写入 status.log,本服务周期性读取解析后入库。 type ConnectionLog struct { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 067f3b2..565b08c 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -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) } \ No newline at end of file diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 0635e73..a5576b1 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -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) } \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index fee7815..5a0291d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -48,13 +48,45 @@ GET /api/health → 200 {"ok": true} ``` -### 当前登录用户 +### 登录 ``` -GET /api/me -→ 200 {"username": "admin"} +POST /api/login +Content-Type: application/json + +{"username": "admin", "password": "..."} +→ 200 { + "token": "eyJhbG...", + "username": "admin", + "role": "admin", + "must_change_password": false +} +→ 401 {"error": "用户名或密码错误"} +→ 403 {"error": "账号已被禁用"} ``` -### 登出(仅前端清理 token,服务端无状态) +> 密码以 bcrypt 哈希存储在 `data/db.json` 中。 +> `must_change_password=true` 表示首次登录(默认账号)需要立刻改密。 + +### 当前登录账号 +``` +GET /api/me +→ 200 { + "user_id": "uuid", + "username": "admin", + "role": "admin", + "must_change_password": false +} +``` + +### 修改自己密码 +``` +POST /api/me/password +{"old_password": "...", "new_password": "..."} +→ 200 {"ok": true} +→ 400 {"error": "原密码错误"} | {"error": "新密码至少 6 个字符"} +``` + +### 登出(前端清理 token) ``` POST /api/logout → 200 {"ok": true} @@ -62,7 +94,48 @@ POST /api/logout --- -## 2. 仪表盘 +## 2. 管理员账号管理 + +> 仅 admin role 可访问。operator 访问会拒绝(由前端路由守卫 + 后端 handler 校验)。 + +### 列出账号(不含 hash) +``` +GET /api/admins +→ 200 [AdminUser, ...] // PasswordHash 字段固定为空 +``` + +### 新建账号 +``` +POST /api/admins +{"username": "alice", "password": "StrongPass123", "role": "operator"} +→ 200 AdminUser +``` + +### 重置密码(无需知道旧密码) +``` +POST /api/admins/:id/password +{"new_password": "NewPass456"} +→ 200 {"ok": true} +``` + +### 启用/禁用 +``` +POST /api/admins/:id/status +{"status": "active"} | {"status": "disabled"} +→ 200 {"ok": true} +``` + +### 删除(不能删自己、不能删最后一个 admin) +``` +DELETE /api/admins/:id +→ 200 {"ok": true} +→ 400 {"error": "不能删除自己"} +→ 400 {"error": "不能删除最后一个 admin 账号"} +``` + +--- + +## 3. 仪表盘 ### 汇总 ``` diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 1b60177..b50b117 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -29,6 +29,14 @@ export const Auth = { login: (username, password) => api.post('/login', { username, password }).then(r => r.data), me: () => api.get('/me').then(r => r.data), logout: () => api.post('/logout').then(r => r.data), + changePassword: (old_password, new_password) => api.post('/me/password', { old_password, new_password }).then(r => r.data), +} +export const Admins = { + list: () => api.get('/admins').then(r => r.data), + create: (username, password, role) => api.post('/admins', { username, password, role }).then(r => r.data), + resetPassword: (id, new_password) => api.post(`/admins/${id}/password`, { new_password }).then(r => r.data), + setStatus: (id, status) => api.post(`/admins/${id}/status`, { status }).then(r => r.data), + delete: id => api.delete(`/admins/${id}`).then(r => r.data), } export const Dash = { get: () => api.get('/dashboard').then(r => r.data), diff --git a/frontend/src/layout/Index.vue b/frontend/src/layout/Index.vue index b4b06ce..bdbc6b3 100644 --- a/frontend/src/layout/Index.vue +++ b/frontend/src/layout/Index.vue @@ -12,6 +12,7 @@ 连接日志 备份与恢复 审计日志 + 管理员 @@ -22,11 +23,12 @@
- {{ user }} + {{ user }} {{ role }} @@ -36,10 +38,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 8448d6b..58e21d7 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,4 +1,5 @@ import { createRouter, createWebHashHistory } from 'vue-router' +import { ElMessage } from 'element-plus' import Login from '@/views/Login.vue' import Layout from '@/layout/Index.vue' import Dashboard from '@/views/Dashboard.vue' @@ -8,6 +9,7 @@ import Certificates from '@/views/Certificates.vue' import Logs from '@/views/Logs.vue' import Backups from '@/views/Backups.vue' import Audits from '@/views/Audits.vue' +import Admins from '@/views/Admins.vue' const router = createRouter({ history: createWebHashHistory(), @@ -24,6 +26,7 @@ const router = createRouter({ { path: 'logs', component: Logs, meta: { title: '连接日志' } }, { path: 'backups', component: Backups, meta: { title: '备份与恢复' } }, { path: 'audits', component: Audits, meta: { title: '审计日志' } }, + { path: 'admins', component: Admins, meta: { title: '管理员', adminOnly: true } }, ] } ] @@ -32,6 +35,11 @@ const router = createRouter({ router.beforeEach((to, from, next) => { const t = localStorage.getItem('token') if (!t && to.path !== '/login') return next('/login') + // 限制 admin only 页面 + if (to.meta?.adminOnly && localStorage.getItem('role') !== 'admin') { + ElMessage.warning('需要 admin 权限') + return next(from.path === '/login' ? '/dashboard' : from) + } next() }) diff --git a/frontend/src/views/Admins.vue b/frontend/src/views/Admins.vue new file mode 100644 index 0000000..7d5f5f0 --- /dev/null +++ b/frontend/src/views/Admins.vue @@ -0,0 +1,146 @@ + + \ No newline at end of file diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 5f0dcc7..042b88c 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -12,8 +12,21 @@ 登录 -

首次部署默认账号 admin / admin123,请在系统中修改

+

首次部署默认账号 admin / admin123,登录后会强制要求改密。

+ + + +

为安全起见,首次登录必须修改默认密码后才能使用其他功能。

+ + + + + + +