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