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})
}
+4 -1
View File
@@ -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()
+24
View File
@@ -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 {
+141
View File
@@ -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)
}
+80 -4
View File
@@ -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)
}