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
+35 -4
View File
@@ -50,6 +50,7 @@
| 客户端证书 | 一键签发,自动生成 `.ovpn`(内嵌 CA/Cert/Key/TLS-Auth),无需额外文件 | | 客户端证书 | 一键签发,自动生成 `.ovpn`(内嵌 CA/Cert/Key/TLS-Auth),无需额外文件 |
| 固定 IP | 通过 CCD (`client-config-dir`) 为指定用户分配固定 VPN IP | | 固定 IP | 通过 CCD (`client-config-dir`) 为指定用户分配固定 VPN IP |
| **访问控制(白名单)** | 实例/用户两层 allow_networks,合并生效;服务端 iptables 强制隔离 | | **访问控制(白名单)** | 实例/用户两层 allow_networks,合并生效;服务端 iptables 强制隔离 |
| **多账号管理** | db.json 中存 bcrypt 哈希,可创建多个 admin/operator 账号,首登强制改密 |
| 启停控制 | Web 一键启动/停止实例,显示 PID 与状态 | | 启停控制 | Web 一键启动/停止实例,显示 PID 与状态 |
| 流量审计 | 解析 `status-version 3` 输出,记录上下行字节/连接时长 | | 流量审计 | 解析 `status-version 3` 输出,记录上下行字节/连接时长 |
| 证书到期提醒 | 仪表盘统计 30 天内到期的证书,单独证书管理页查看完整清单 | | 证书到期提醒 | 仪表盘统计 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 ```bash
systemctl edit --full openvpn-manager systemctl edit --full openvpn-manager
# 找到 OVPNMGR_ADMIN_PASS 一行,改成你的密码 # 找到 OVPNMGR_ADMIN_PASS 一行,改成你临时用的密码
# 也建议修改 OVPNMGR_JWT_SECRET 为随机串
systemctl daemon-reload systemctl daemon-reload
systemctl restart openvpn-manager 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 使用。
## 创建第一个实例 ## 创建第一个实例
+159 -5
View File
@@ -9,6 +9,7 @@ import (
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"openvpn-manager/internal/config" "openvpn-manager/internal/config"
"openvpn-manager/internal/middleware" "openvpn-manager/internal/middleware"
@@ -72,6 +73,16 @@ func (s *Server) Router(distDir string) *gin.Engine {
auth.DELETE("/backups/:id", s.deleteBackup) auth.DELETE("/backups/:id", s.deleteBackup)
auth.GET("/audits", s.listAudits) 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return 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": "用户名或密码错误"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "issue token failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "issue token failed"})
return 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) logout(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
func (s *Server) me(c *gin.Context) { func (s *Server) me(c *gin.Context) {
u, _ := c.Get("user") userID, _ := c.Get("user_id")
c.JSON(200, gin.H{"username": u}) 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 汇总统计 // dashboard 汇总统计
@@ -431,4 +491,98 @@ func itoa(n int) string {
buf[i] = '-' buf[i] = '-'
} }
return 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 { type Claims struct {
UserID string `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Role string `json:"role"` Role string `json:"role"`
jwt.RegisteredClaims 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{ c := Claims{
UserID: userID,
Username: username, Username: username,
Role: role, Role: role,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
@@ -50,6 +52,7 @@ func JWTAuth(secret string) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return return
} }
c.Set("user_id", claims.UserID)
c.Set("user", claims.Username) c.Set("user", claims.Username)
c.Set("role", claims.Role) c.Set("role", claims.Role)
c.Next() c.Next()
+24
View File
@@ -64,6 +64,30 @@ type AuditLog struct {
IP string `json:"ip"` 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 的实时/历史连接记录。 // ConnectionLog 来自 OpenVPN status 的实时/历史连接记录。
// 周期由 OpenVPN 自身写入 status.log,本服务周期性读取解析后入库。 // 周期由 OpenVPN 自身写入 status.log,本服务周期性读取解析后入库。
type ConnectionLog struct { type ConnectionLog struct {
+141
View File
@@ -16,6 +16,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"openvpn-manager/internal/config" "openvpn-manager/internal/config"
"openvpn-manager/internal/model" "openvpn-manager/internal/model"
@@ -582,4 +583,144 @@ func RandomToken(n int) string {
b := make([]byte, n) b := make([]byte, n)
_, _ = rand.Read(b) _, _ = rand.Read(b)
return hex.EncodeToString(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 { type Data struct {
Instances []model.Instance `json:"instances"` Instances []model.Instance `json:"instances"`
Users []model.VPNUser `json:"users"` Users []model.VPNUser `json:"users"`
Audits []model.AuditLog `json:"audits"` Audits []model.AuditLog `json:"audits"`
ConnLogs []model.ConnectionLog `json:"conn_logs"` 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) { 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:]...) s.data.Backups = append(s.data.Backups[:idx], s.data.Backups[idx+1:]...)
return s.flush() 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)
} }
+78 -5
View File
@@ -48,13 +48,45 @@ GET /api/health
→ 200 {"ok": true} → 200 {"ok": true}
``` ```
### 当前登录用户 ### 登录
``` ```
GET /api/me POST /api/login
→ 200 {"username": "admin"} 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 POST /api/logout
→ 200 {"ok": true} → 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. 仪表盘
### 汇总 ### 汇总
``` ```
+8
View File
@@ -29,6 +29,14 @@ export const Auth = {
login: (username, password) => api.post('/login', { username, password }).then(r => r.data), login: (username, password) => api.post('/login', { username, password }).then(r => r.data),
me: () => api.get('/me').then(r => r.data), me: () => api.get('/me').then(r => r.data),
logout: () => api.post('/logout').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 = { export const Dash = {
get: () => api.get('/dashboard').then(r => r.data), get: () => api.get('/dashboard').then(r => r.data),
+54 -3
View File
@@ -12,6 +12,7 @@
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>连接日志</span></el-menu-item> <el-menu-item index="/logs"><el-icon><Document /></el-icon><span>连接日志</span></el-menu-item>
<el-menu-item index="/backups"><el-icon><FolderOpened /></el-icon><span>备份与恢复</span></el-menu-item> <el-menu-item index="/backups"><el-icon><FolderOpened /></el-icon><span>备份与恢复</span></el-menu-item>
<el-menu-item index="/audits"><el-icon><Tickets /></el-icon><span>审计日志</span></el-menu-item> <el-menu-item index="/audits"><el-icon><Tickets /></el-icon><span>审计日志</span></el-menu-item>
<el-menu-item v-if="isAdmin" index="/admins"><el-icon><Avatar /></el-icon><span>管理员</span></el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-container> <el-container>
@@ -22,11 +23,12 @@
<div> <div>
<el-dropdown @command="cmd"> <el-dropdown @command="cmd">
<span style="color:#fff;cursor:pointer"> <span style="color:#fff;cursor:pointer">
<el-icon><UserFilled /></el-icon> {{ user }} <el-icon><ArrowDown /></el-icon> <el-icon><UserFilled /></el-icon> {{ user }} <el-tag v-if="role" size="small" :type="role==='admin'?'danger':'info'" style="margin-left:6px;vertical-align:middle">{{ role }}</el-tag> <el-icon><ArrowDown /></el-icon>
</span> </span>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item> <el-dropdown-item command="password"><el-icon><Lock /></el-icon>修改密码</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@@ -36,10 +38,23 @@
<router-view /> <router-view />
</el-main> </el-main>
</el-container> </el-container>
<!-- 修改密码弹窗 -->
<el-dialog v-model="pwdDlg" title="修改密码" width="440px">
<el-form :model="form" label-width="100px">
<el-form-item label="当前密码"><el-input v-model="form.old_password" type="password" show-password /></el-form-item>
<el-form-item label="新密码"><el-input v-model="form.new_password" type="password" show-password placeholder="至少 6 位" /></el-form-item>
<el-form-item label="确认新密码"><el-input v-model="form.confirm" type="password" show-password /></el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDlg=false">取消</el-button>
<el-button type="primary" :loading="loading" @click="submitPwd">保存</el-button>
</template>
</el-dialog>
</el-container> </el-container>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Auth } from '@/api' import { Auth } from '@/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -47,13 +62,49 @@ import { ElMessage } from 'element-plus'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const user = ref(localStorage.getItem('username') || 'admin') const user = ref(localStorage.getItem('username') || 'admin')
const role = ref(localStorage.getItem('role') || '')
const isAdmin = ref(role.value === 'admin')
// 启动时拉一次 me 以拿到最新 role/must_change_password
onMounted(async () => {
try {
const m = await Auth.me()
if (m.role) { role.value = m.role; localStorage.setItem('role', m.role); isAdmin.value = m.role === 'admin' }
if (m.username) { user.value = m.username; localStorage.setItem('username', m.username) }
} catch (e) {}
})
const pwdDlg = ref(false)
const loading = ref(false)
const form = reactive({ old_password: '', new_password: '', confirm: '' })
async function cmd(c) { async function cmd(c) {
if (c === 'logout') { if (c === 'logout') {
await Auth.logout() await Auth.logout()
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('role')
ElMessage.success('已退出登录') ElMessage.success('已退出登录')
router.replace('/login') router.replace('/login')
} else if (c === 'password') {
form.old_password = ''
form.new_password = ''
form.confirm = ''
pwdDlg.value = true
}
}
async function submitPwd() {
if (!form.old_password || !form.new_password) return ElMessage.warning('请填写完整')
if (form.new_password !== form.confirm) return ElMessage.error('两次输入的新密码不一致')
if (form.new_password.length < 6) return ElMessage.warning('新密码至少 6 位')
loading.value = true
try {
await Auth.changePassword(form.old_password, form.new_password)
ElMessage.success('密码已修改')
pwdDlg.value = false
} finally {
loading.value = false
} }
} }
</script> </script>
+8
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import { ElMessage } from 'element-plus'
import Login from '@/views/Login.vue' import Login from '@/views/Login.vue'
import Layout from '@/layout/Index.vue' import Layout from '@/layout/Index.vue'
import Dashboard from '@/views/Dashboard.vue' import Dashboard from '@/views/Dashboard.vue'
@@ -8,6 +9,7 @@ import Certificates from '@/views/Certificates.vue'
import Logs from '@/views/Logs.vue' import Logs from '@/views/Logs.vue'
import Backups from '@/views/Backups.vue' import Backups from '@/views/Backups.vue'
import Audits from '@/views/Audits.vue' import Audits from '@/views/Audits.vue'
import Admins from '@/views/Admins.vue'
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
@@ -24,6 +26,7 @@ const router = createRouter({
{ path: 'logs', component: Logs, meta: { title: '连接日志' } }, { path: 'logs', component: Logs, meta: { title: '连接日志' } },
{ path: 'backups', component: Backups, meta: { title: '备份与恢复' } }, { path: 'backups', component: Backups, meta: { title: '备份与恢复' } },
{ path: 'audits', component: Audits, 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) => { router.beforeEach((to, from, next) => {
const t = localStorage.getItem('token') const t = localStorage.getItem('token')
if (!t && to.path !== '/login') return next('/login') 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() next()
}) })
+146
View File
@@ -0,0 +1,146 @@
<template>
<div>
<h2 style="margin-top:0">管理员账号</h2>
<p class="muted"> role=admin 的账号可以管理其他账号可创建 operator(只读运维) admin(全部权限)</p>
<div style="margin-bottom:12px">
<el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon>新建账号</el-button>
<el-button @click="load"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<el-table :data="list" stripe>
<el-table-column prop="username" label="用户名" />
<el-table-column label="角色" width="100">
<template #default="{row}">
<el-tag :type="row.role==='admin'?'danger':'info'" size="small">{{ row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{row}">
<el-tag :type="row.status==='active'?'success':'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="强制改密" width="100">
<template #default="{row}">
<el-tag v-if="row.must_change_password" type="warning" size="small"></el-tag>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column prop="last_login_at" label="最近登录" width="180">
<template #default="{row}">{{ row.last_login_at?.slice(0,19) || '-' }}</template>
</el-table-column>
<el-table-column prop="last_login_ip" label="登录 IP" width="140" />
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{row}">{{ row.created_at?.slice(0,19) }}</template>
</el-table-column>
<el-table-column label="操作" width="280">
<template #default="{row}">
<el-button size="small" @click="openResetPwd(row)">重置密码</el-button>
<el-button v-if="row.status==='active'" size="small" type="warning" @click="setStatus(row,'disabled')">禁用</el-button>
<el-button v-else size="small" type="success" @click="setStatus(row,'active')">启用</el-button>
<el-popconfirm title="确定删除此账号?删除后该账号无法再登录。" @confirm="del(row)">
<template #reference><el-button size="small" type="danger">删除</el-button></template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 新建账号 -->
<el-dialog v-model="createDlg" title="新建管理员账号" width="480px">
<el-form :model="cform" label-width="100px">
<el-form-item label="用户名"><el-input v-model="cform.username" placeholder="≥3 字符"/></el-form-item>
<el-form-item label="密码"><el-input v-model="cform.password" type="password" show-password placeholder="≥6 位"/></el-form-item>
<el-form-item label="角色">
<el-select v-model="cform.role" style="width:200px">
<el-option label="admin(全部权限)" value="admin"/>
<el-option label="operator(运维)" value="operator"/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDlg=false">取消</el-button>
<el-button type="primary" :loading="loading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
<!-- 重置密码 -->
<el-dialog v-model="resetDlg" title="重置密码" width="440px">
<p class="muted">为账号 <b>{{ target?.username }}</b> 设置新密码,新密码会立即生效</p>
<el-form :model="rform" label-width="100px">
<el-form-item label="新密码"><el-input v-model="rform.new_password" type="password" show-password placeholder="≥6 位"/></el-form-item>
<el-form-item label="确认"><el-input v-model="rform.confirm" type="password" show-password /></el-form-item>
</el-form>
<template #footer>
<el-button @click="resetDlg=false">取消</el-button>
<el-button type="primary" :loading="loading" @click="submitReset">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Admins } from '@/api'
const list = ref([])
const loading = ref(false)
async function load() { list.value = await Admins.list() }
onMounted(load)
// 新建
const createDlg = ref(false)
const cform = reactive({ username:'', password:'', role:'operator' })
function openCreate() {
cform.username = ''; cform.password = ''; cform.role = 'operator'
createDlg.value = true
}
async function submitCreate() {
if (!cform.username || !cform.password) return ElMessage.warning('请填写完整')
loading.value = true
try {
await Admins.create(cform.username, cform.password, cform.role)
ElMessage.success('已创建')
createDlg.value = false
load()
} finally { loading.value = false }
}
// 重置密码
const resetDlg = ref(false)
const target = ref(null)
const rform = reactive({ new_password:'', confirm:'' })
function openResetPwd(row) {
target.value = row
rform.new_password = ''; rform.confirm = ''
resetDlg.value = true
}
async function submitReset() {
if (!rform.new_password) return ElMessage.warning('请填写新密码')
if (rform.new_password !== rform.confirm) return ElMessage.error('两次输入不一致')
if (rform.new_password.length < 6) return ElMessage.warning('至少 6 位')
loading.value = true
try {
await Admins.resetPassword(target.value.id, rform.new_password)
ElMessage.success('已重置')
resetDlg.value = false
load()
} finally { loading.value = false }
}
// 启停
async function setStatus(row, status) {
await Admins.setStatus(row.id, status)
ElMessage.success(status === 'active' ? '已启用' : '已禁用')
load()
}
// 删除
async function del(row) {
try {
await Admins.delete(row.id)
ElMessage.success('已删除')
load()
} catch (e) {}
}
</script>
+41 -1
View File
@@ -12,8 +12,21 @@
</el-form-item> </el-form-item>
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button> <el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
</el-form> </el-form>
<p class="muted">首次部署默认账号 admin / admin123请在系统中修改</p> <p class="muted">首次部署默认账号 admin / admin123,登录后会强制要求改密</p>
</div> </div>
<!-- 首次登录强制改密 -->
<el-dialog v-model="mustChange" title="请修改初始密码" width="440px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<p class="muted" style="margin-top:0">为安全起见,首次登录必须修改默认密码后才能使用其他功能</p>
<el-form :model="cp" label-width="100px">
<el-form-item label="当前密码"><el-input v-model="cp.old_password" type="password" show-password /></el-form-item>
<el-form-item label="新密码"><el-input v-model="cp.new_password" type="password" show-password placeholder="至少 6 位" /></el-form-item>
<el-form-item label="确认新密码"><el-input v-model="cp.confirm" type="password" show-password /></el-form-item>
</el-form>
<template #footer>
<el-button type="primary" :loading="cpLoading" @click="submitChange">提交</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
@@ -26,17 +39,44 @@ const router = useRouter()
const form = reactive({ username: 'admin', password: 'admin123' }) const form = reactive({ username: 'admin', password: 'admin123' })
const loading = ref(false) const loading = ref(false)
const mustChange = ref(false)
const cpLoading = ref(false)
const cp = reactive({ old_password: '', new_password: '', confirm: '' })
async function submit() { async function submit() {
loading.value = true loading.value = true
try { try {
const r = await Auth.login(form.username, form.password) const r = await Auth.login(form.username, form.password)
localStorage.setItem('token', r.token) localStorage.setItem('token', r.token)
if (r.must_change_password) {
// 把当前密码作为旧密码预填,方便用户
cp.old_password = form.password
cp.new_password = ''
cp.confirm = ''
mustChange.value = true
return
}
ElMessage.success('登录成功') ElMessage.success('登录成功')
router.replace('/dashboard') router.replace('/dashboard')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function submitChange() {
if (!cp.old_password || !cp.new_password) return ElMessage.warning('请填写完整')
if (cp.new_password !== cp.confirm) return ElMessage.error('两次输入的新密码不一致')
if (cp.new_password.length < 6) return ElMessage.warning('新密码至少 6 位')
cpLoading.value = true
try {
await Auth.changePassword(cp.old_password, cp.new_password)
ElMessage.success('密码已修改')
mustChange.value = false
router.replace('/dashboard')
} finally {
cpLoading.value = false
}
}
</script> </script>
<style scoped> <style scoped>
.login-page { min-height:100vh; background:linear-gradient(135deg,#1d4ed8,#0ea5e9); display:flex; align-items:center; justify-content:center; } .login-page { min-height:100vh; background:linear-gradient(135deg,#1d4ed8,#0ea5e9); display:flex; align-items:center; justify-content:center; }