09f6918aeb
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)
726 lines
19 KiB
Go
726 lines
19 KiB
Go
package service
|
||
|
||
import (
|
||
"archive/tar"
|
||
"compress/gzip"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/google/uuid"
|
||
"golang.org/x/crypto/bcrypt"
|
||
|
||
"openvpn-manager/internal/config"
|
||
"openvpn-manager/internal/model"
|
||
"openvpn-manager/internal/store"
|
||
"openvpn-manager/pkg/openvpn"
|
||
)
|
||
|
||
// Service 业务逻辑聚合,供 API 层调用。
|
||
// 任何对实例/用户/证书/备份的变更都应经过这里,从而写入审计日志。
|
||
type Service struct {
|
||
Cfg *config.Config
|
||
Store *store.Store
|
||
Ovm *openvpn.Manager
|
||
}
|
||
|
||
func New(cfg *config.Config, st *store.Store, ovm *openvpn.Manager) *Service {
|
||
return &Service{Cfg: cfg, Store: st, Ovm: ovm}
|
||
}
|
||
|
||
func (s *Service) audit(c context.Context, action, target, detail, result, ip string) {
|
||
username, _ := c.Value("user").(string)
|
||
if username == "" {
|
||
username = "system"
|
||
}
|
||
_ = s.Store.AppendAudit(model.AuditLog{
|
||
ID: uuid.NewString(),
|
||
Time: time.Now(),
|
||
User: username,
|
||
Action: action,
|
||
Target: target,
|
||
Result: result,
|
||
Detail: detail,
|
||
IP: ip,
|
||
})
|
||
}
|
||
|
||
// AuditForAPI 在 API 层被调用时手动写入(因为 gin context 转为 context.Context)。
|
||
func (s *Service) AuditForAPI(c *gin.Context, action, target, detail, result string) {
|
||
username, _ := c.Get("user")
|
||
un, _ := username.(string)
|
||
if un == "" {
|
||
un = "system"
|
||
}
|
||
_ = s.Store.AppendAudit(model.AuditLog{
|
||
ID: uuid.NewString(),
|
||
Time: time.Now(),
|
||
User: un,
|
||
Action: action,
|
||
Target: target,
|
||
Result: result,
|
||
Detail: detail,
|
||
IP: c.ClientIP(),
|
||
})
|
||
}
|
||
|
||
// CreateInstance 新建一个 OpenVPN 实例,并签发服务端证书、生成 server.conf。
|
||
func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) {
|
||
if in.Name == "" {
|
||
return nil, fmt.Errorf("name required")
|
||
}
|
||
if in.Port == 0 {
|
||
return nil, fmt.Errorf("port required")
|
||
}
|
||
if in.Proto == "" {
|
||
in.Proto = "udp"
|
||
}
|
||
if in.Dev == "" {
|
||
in.Dev = "tun"
|
||
}
|
||
if in.Subnet == "" {
|
||
in.Subnet = "10.8.0.0/24"
|
||
}
|
||
if in.AccessMode == "" {
|
||
in.AccessMode = model.AccessOpen
|
||
}
|
||
// 校验白名单 CIDR
|
||
for _, c := range in.AllowNetworks {
|
||
if err := openvpn.ValidateCIDR(c); err != nil {
|
||
return nil, fmt.Errorf("instance allow_networks: %w", err)
|
||
}
|
||
}
|
||
// 名称查重
|
||
if _, err := s.Store.GetInstanceByName(in.Name); err == nil {
|
||
return nil, fmt.Errorf("instance %s already exists", in.Name)
|
||
}
|
||
// 确保 CA
|
||
if err := s.Ovm.EnsureCA(); err != nil {
|
||
return nil, err
|
||
}
|
||
in.ID = uuid.NewString()
|
||
in.Status = "stopped"
|
||
in.CreatedAt = time.Now()
|
||
in.UpdatedAt = time.Now()
|
||
// 创建实例目录
|
||
if err := os.MkdirAll(s.Cfg.InstanceDir(in.Name), 0o755); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.MkdirAll(filepath.Join(s.Cfg.InstanceDir(in.Name), "logs"), 0o755); err != nil {
|
||
return nil, err
|
||
}
|
||
// 签发服务端证书
|
||
if err := s.Ovm.IssueServerCert(in.Name); err != nil {
|
||
return nil, fmt.Errorf("issue server cert: %w", err)
|
||
}
|
||
// 写 server.conf + 白名单脚本
|
||
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
|
||
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
|
||
return nil, fmt.Errorf("write conf: %w", err)
|
||
}
|
||
if in.AccessMode == model.AccessWhitelist {
|
||
if _, err := s.Ovm.AllowNetworksScript(in.Name, in.AllowNetworks); err != nil {
|
||
return nil, fmt.Errorf("gen whitelist scripts: %w", err)
|
||
}
|
||
}
|
||
if err := s.Store.UpsertInstance(in); err != nil {
|
||
return nil, err
|
||
}
|
||
return &in, nil
|
||
}
|
||
|
||
// UpdateInstance 仅更新可热改字段(端口/子网需要重启生效)。
|
||
func (s *Service) UpdateInstance(in model.Instance) error {
|
||
old, err := s.Store.GetInstance(in.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if in.AccessMode == "" {
|
||
in.AccessMode = old.AccessMode
|
||
}
|
||
if in.Cipher == "" {
|
||
in.Cipher = old.Cipher
|
||
}
|
||
if in.AuthDigest == "" {
|
||
in.AuthDigest = old.AuthDigest
|
||
}
|
||
if in.Dev == "" {
|
||
in.Dev = old.Dev
|
||
}
|
||
if in.Subnet == "" {
|
||
in.Subnet = old.Subnet
|
||
}
|
||
for _, c := range in.AllowNetworks {
|
||
if err := openvpn.ValidateCIDR(c); err != nil {
|
||
return fmt.Errorf("instance allow_networks: %w", err)
|
||
}
|
||
}
|
||
in.CreatedAt = old.CreatedAt
|
||
in.UpdatedAt = time.Now()
|
||
in.Status = old.Status
|
||
in.PID = old.PID
|
||
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
|
||
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
|
||
return err
|
||
}
|
||
if in.AccessMode == model.AccessWhitelist {
|
||
if _, err := s.Ovm.AllowNetworksScript(in.Name, in.AllowNetworks); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.Store.UpsertInstance(in); err != nil {
|
||
return err
|
||
}
|
||
// 实例级 allow_networks 变更后,刷新所有用户的 ccd
|
||
inst := in
|
||
for _, u := range s.Store.ListUsers(inst.ID) {
|
||
u := u
|
||
merged := openvpn.MergeAllowNetworks(inst.AllowNetworks, u.AllowNetworks)
|
||
if err := s.writeUserCCD(&inst, &u, merged); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteInstance 移除实例及其 PKI/配置。
|
||
func (s *Service) DeleteInstance(id string) error {
|
||
in, err := s.Store.GetInstance(id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if in.Status == "running" {
|
||
_ = s.StopInstance(id)
|
||
}
|
||
// 清理用户记录与目录
|
||
for _, u := range s.Store.ListUsers(id) {
|
||
_ = s.Store.DeleteUser(u.ID)
|
||
}
|
||
_ = os.RemoveAll(s.Cfg.InstanceDir(in.Name))
|
||
_ = os.RemoveAll(filepath.Join(s.Cfg.ClientsDir(), in.Name))
|
||
return s.Store.DeleteInstance(id)
|
||
}
|
||
|
||
// StartInstance 在前台启动 openvpn。
|
||
// 注意:本服务应以 root 运行;非 root 场景下应通过 systemd 单元托管。
|
||
func (s *Service) StartInstance(id string) error {
|
||
in, err := s.Store.GetInstance(id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if in.Status == "running" {
|
||
return fmt.Errorf("already running")
|
||
}
|
||
conf := s.Ovm.InstanceConf(in.Name)
|
||
if _, err := os.Stat(conf); err != nil {
|
||
return fmt.Errorf("conf missing: %w", err)
|
||
}
|
||
cmd := exec.Command(s.Cfg.OpenVPNBin,
|
||
"--cd", s.Cfg.InstanceDir(in.Name),
|
||
"--config", conf)
|
||
logf, _ := os.OpenFile(filepath.Join(s.Cfg.InstanceDir(in.Name), "logs", "openvpn-stdout.log"),
|
||
os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||
if logf != nil {
|
||
cmd.Stdout = logf
|
||
cmd.Stderr = logf
|
||
}
|
||
if err := cmd.Start(); err != nil {
|
||
return err
|
||
}
|
||
in.Status = "running"
|
||
in.PID = cmd.Process.Pid
|
||
in.UpdatedAt = time.Now()
|
||
_ = s.Store.UpsertInstance(*in)
|
||
// 后台释放
|
||
go func() {
|
||
_ = cmd.Wait()
|
||
// 进程退出时回写状态(简单模型)
|
||
cur, err := s.Store.GetInstance(id)
|
||
if err == nil && cur.PID == cmd.Process.Pid {
|
||
cur.Status = "stopped"
|
||
cur.PID = 0
|
||
cur.UpdatedAt = time.Now()
|
||
_ = s.Store.UpsertInstance(*cur)
|
||
}
|
||
}()
|
||
return nil
|
||
}
|
||
|
||
// StopInstance 通过 SIGTERM 停止实例。
|
||
func (s *Service) StopInstance(id string) error {
|
||
in, err := s.Store.GetInstance(id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if in.PID == 0 {
|
||
in.Status = "stopped"
|
||
return s.Store.UpsertInstance(*in)
|
||
}
|
||
proc, err := os.FindProcess(in.PID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := proc.Signal(os.Interrupt); err != nil {
|
||
// 兜底:直接 Kill
|
||
_ = proc.Signal(os.Kill)
|
||
}
|
||
in.Status = "stopped"
|
||
in.PID = 0
|
||
in.UpdatedAt = time.Now()
|
||
return s.Store.UpsertInstance(*in)
|
||
}
|
||
|
||
// CreateUser 新建客户端用户并签发证书。
|
||
func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
|
||
if u.Username == "" {
|
||
return nil, fmt.Errorf("username required")
|
||
}
|
||
in, err := s.Store.GetInstance(u.InstanceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := s.Store.GetUserByCN(u.InstanceID, u.Username); err == nil {
|
||
return nil, fmt.Errorf("user %s already exists", u.Username)
|
||
}
|
||
// 校验用户级 CIDR
|
||
for _, c := range u.AllowNetworks {
|
||
if err := openvpn.ValidateCIDR(c); err != nil {
|
||
return nil, fmt.Errorf("user allow_networks: %w", err)
|
||
}
|
||
}
|
||
u.ID = uuid.NewString()
|
||
u.Enabled = true
|
||
u.CreatedAt = time.Now()
|
||
// 签发证书(Manager 按实例名索引 PKI)
|
||
if _, _, err := s.Ovm.IssueCert(in.Name, u.Username); err != nil {
|
||
return nil, err
|
||
}
|
||
// CCD: 合并实例级与用户级白名单(白名单模式才生效)
|
||
merged := openvpn.MergeAllowNetworks(in.AllowNetworks, u.AllowNetworks)
|
||
if err := s.writeUserCCD(in, &u, merged); err != nil {
|
||
return nil, err
|
||
}
|
||
// 预生成 ovpn(以空 host 生成占位,用户在 UI 上下载)
|
||
if _, err := s.Ovm.GenerateClientOVPNFor(&u, in, "vpn.example.com"); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.Store.UpsertUser(u); err != nil {
|
||
return nil, err
|
||
}
|
||
return &u, nil
|
||
}
|
||
|
||
// GenerateOVPN 下载/重新生成 .ovpn,remoteHost 由前端传入。
|
||
func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
|
||
u, err := s.Store.GetUser(userID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
in, err := s.Store.GetInstance(u.InstanceID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return s.Ovm.GenerateClientOVPNFor(u, in, remoteHost)
|
||
}
|
||
|
||
// RevokeUser 吊销用户:禁用 + 标记。
|
||
func (s *Service) RevokeUser(userID string) error {
|
||
u, err := s.Store.GetUser(userID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
u.Enabled = false
|
||
now := time.Now()
|
||
u.RevokedAt = &now
|
||
if err := s.Store.UpsertUser(*u); err != nil {
|
||
return err
|
||
}
|
||
// 在 ccd 写入禁用标记
|
||
in, err2 := s.Store.GetInstance(u.InstanceID)
|
||
if err2 == nil {
|
||
body := "# revoked by manager\n"
|
||
_ = s.Ovm.WriteCCD(in.Name, u.Username, body)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteUser 删除用户及证书。
|
||
func (s *Service) DeleteUser(userID string) error {
|
||
u, err := s.Store.GetUser(userID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
in, _ := s.Store.GetInstance(u.InstanceID)
|
||
if in != nil {
|
||
_ = s.Ovm.DeleteCCD(in.Name, u.Username)
|
||
_ = os.Remove(filepath.Join(s.Ovm.PKIPath(in.Name), "issued", u.Username+".crt"))
|
||
_ = os.Remove(filepath.Join(s.Ovm.PKIPath(in.Name), "private", u.Username+".key"))
|
||
_ = os.Remove(filepath.Join(s.Cfg.ClientsDir(), in.Name, u.Username+".ovpn"))
|
||
}
|
||
return s.Store.DeleteUser(userID)
|
||
}
|
||
|
||
// UpdateUser 修改用户(主要用于改 allow_networks / static_ip 等)。
|
||
func (s *Service) UpdateUser(u model.VPNUser) error {
|
||
old, err := s.Store.GetUser(u.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, c := range u.AllowNetworks {
|
||
if err := openvpn.ValidateCIDR(c); err != nil {
|
||
return fmt.Errorf("user allow_networks: %w", err)
|
||
}
|
||
}
|
||
u.CreatedAt = old.CreatedAt
|
||
u.Enabled = old.Enabled
|
||
u.RevokedAt = old.RevokedAt
|
||
in, err := s.Store.GetInstance(u.InstanceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
merged := openvpn.MergeAllowNetworks(in.AllowNetworks, u.AllowNetworks)
|
||
if err := s.writeUserCCD(in, &u, merged); err != nil {
|
||
return err
|
||
}
|
||
return s.Store.UpsertUser(u)
|
||
}
|
||
|
||
// writeUserCCD 把允许网段写进 ccd/<cn>,client-connect 脚本读取后推送 route。
|
||
// 文件内容:
|
||
// - 第 1 行 ifconfig-push (固定 IP)
|
||
// - 之后每行一个 CIDR (允许的网段)
|
||
// 这样设计既兼容现有 static_ip 场景,又能让 client-connect.sh 简单 grep。
|
||
func (s *Service) writeUserCCD(in *model.Instance, u *model.VPNUser, allowNets []string) error {
|
||
var b strings.Builder
|
||
if u.StaticIP != "" {
|
||
b.WriteString("ifconfig-push " + u.StaticIP + " 255.255.255.0\n")
|
||
}
|
||
for _, c := range allowNets {
|
||
b.WriteString(c + "\n")
|
||
}
|
||
return s.Ovm.WriteCCD(in.Name, u.Username, b.String())
|
||
}
|
||
|
||
// CertInfos 汇总所有用户证书的过期时间。
|
||
func (s *Service) CertInfos() ([]model.CertInfo, error) {
|
||
var out []model.CertInfo
|
||
now := time.Now()
|
||
for _, in := range s.Store.ListInstances() {
|
||
pki := s.Ovm.PKIPath(in.Name)
|
||
issuedDir := filepath.Join(pki, "issued")
|
||
entries, err := os.ReadDir(issuedDir)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, e := range entries {
|
||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".crt") {
|
||
continue
|
||
}
|
||
cn := strings.TrimSuffix(e.Name(), ".crt")
|
||
if cn == "server" {
|
||
continue
|
||
}
|
||
notAfter, err := s.Ovm.CertNotAfter(filepath.Join(issuedDir, e.Name()))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
out = append(out, model.CertInfo{
|
||
InstanceID: in.ID,
|
||
Username: cn,
|
||
NotAfter: notAfter,
|
||
DaysLeft: int(notAfter.Sub(now).Hours() / 24),
|
||
})
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ListOnline 解析 status 文件获取在线客户端。
|
||
func (s *Service) ListOnline(instanceID string) ([]openvpn.StatusEntry, error) {
|
||
in, err := s.Store.GetInstance(instanceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
statusPath := filepath.Join(s.Cfg.InstanceDir(in.Name), "status.log")
|
||
return s.Ovm.ParseStatus(statusPath)
|
||
}
|
||
|
||
// Backup 创建 tar.gz 备份。
|
||
func (s *Service) Backup(note string) (*model.Backup, error) {
|
||
if err := os.MkdirAll(s.Cfg.BackupsDir(), 0o755); err != nil {
|
||
return nil, err
|
||
}
|
||
id := uuid.NewString()
|
||
ts := time.Now().Format("20060102-150405")
|
||
fp := filepath.Join(s.Cfg.BackupsDir(), "backup-"+ts+"-"+id[:8]+".tar.gz")
|
||
f, err := os.Create(fp)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer f.Close()
|
||
gz := gzip.NewWriter(f)
|
||
defer gz.Close()
|
||
tw := tar.NewWriter(gz)
|
||
defer tw.Close()
|
||
|
||
add := func(rel string) error {
|
||
abs := filepath.Join(s.Cfg.DataDir, rel)
|
||
return filepath.Walk(abs, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
if info.IsDir() {
|
||
return nil
|
||
}
|
||
hdr, err := tar.FileInfoHeader(info, "")
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
hdr.Name = filepath.ToSlash(filepath.Join(rel, strings.TrimPrefix(path, abs)))
|
||
if err := tw.WriteHeader(hdr); err != nil {
|
||
return nil
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
_, _ = tw.Write(data)
|
||
return nil
|
||
})
|
||
}
|
||
for _, sub := range []string{"pki", "instances", "clients"} {
|
||
_ = add(sub)
|
||
}
|
||
b := &model.Backup{
|
||
ID: id,
|
||
CreatedAt: time.Now(),
|
||
Filename: filepath.Base(fp),
|
||
Note: note,
|
||
Includes: []string{"pki", "instances", "clients"},
|
||
}
|
||
fi, _ := os.Stat(fp)
|
||
if fi != nil {
|
||
b.Size = fi.Size()
|
||
}
|
||
_ = s.Store.AddBackup(*b)
|
||
return b, nil
|
||
}
|
||
|
||
// Restore 从备份恢复。会覆盖现有数据。
|
||
func (s *Service) Restore(backupID string) error {
|
||
var bk *model.Backup
|
||
for _, b := range s.Store.ListBackups() {
|
||
if b.ID == backupID {
|
||
b := b
|
||
bk = &b
|
||
break
|
||
}
|
||
}
|
||
if bk == nil {
|
||
return fmt.Errorf("backup not found")
|
||
}
|
||
src := filepath.Join(s.Cfg.BackupsDir(), bk.Filename)
|
||
f, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
var r io.Reader = f
|
||
if strings.HasSuffix(src, ".gz") {
|
||
gz, err := gzip.NewReader(f)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer gz.Close()
|
||
r = gz
|
||
}
|
||
tr := tar.NewReader(r)
|
||
for {
|
||
hdr, err := tr.Next()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
target := filepath.Join(s.Cfg.DataDir, hdr.Name)
|
||
if hdr.FileInfo().IsDir() {
|
||
_ = os.MkdirAll(target, 0o755)
|
||
continue
|
||
}
|
||
_ = os.MkdirAll(filepath.Dir(target), 0o755)
|
||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, _ = io.Copy(out, tr)
|
||
_ = out.Close()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteBackup 删除备份文件与索引。
|
||
func (s *Service) DeleteBackup(id string) error {
|
||
for _, b := range s.Store.ListBackups() {
|
||
if b.ID == id {
|
||
_ = os.Remove(filepath.Join(s.Cfg.BackupsDir(), b.Filename))
|
||
return s.Store.DeleteBackup(id)
|
||
}
|
||
}
|
||
return fmt.Errorf("not found")
|
||
}
|
||
|
||
// RandomToken 生成短随机串。
|
||
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)
|
||
} |