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
+159 -5
View File
@@ -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})
}