From d584b648131dfdf13ec124a9d4f7f04191dc62b1 Mon Sep 17 00:00:00 2001 From: opsx Date: Mon, 26 Oct 2026 17:37:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E9=BB=98=E8=AE=A4=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E9=9A=8F=E6=9C=BA=E5=88=9D=E5=A7=8B=E5=AF=86=E7=A0=81?= =?UTF-8?q?=EF=BC=88=E4=B8=8D=E5=86=8D=E7=A1=AC=E7=BC=96=E7=A0=81=20admin1?= =?UTF-8?q?23=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考 lokistack 的实现: 1. 首次启动生成 16 位随机密码(大小写+数字,排除 0/O/1/l/I 易混淆字符) - 4 个默认账号共用:admin / dba / auditor / developer - 写入数据目录 initial-admin-password.txt(0600) - 打印到启动日志(含账号列表、密码、文件路径、docker/kubectl 获取命令) 2. 支持 ADMIN_INIT_PASSWORD 环境变量覆盖(自动化部署 / 需要固定密码时) 3. 生成失败直接 log.Fatalf 退出,不降级到公开默认口令 4. 批量导入用户 BatchImportUsers:空密码不再默认 admin123, 改为随机 14 位(新增 genPassword),保证每个导入用户密码不可预测 5. 幂等:已存在的账号不重建、不改密码;只有缺失的账号才创建 6. imagePullPolicy: IfNotPresent → Always 原因:latest tag 配 IfNotPresent 会导致节点一直复用缓存的旧镜像, 新 push 的镜像不生效(本次实测踩到:Pod imageID 仍是旧 digest) 7. 文档 - README 新增「首次部署获取默认账号密码」章节(含 4 账号角色表 + 3 种获取方式) - docker-compose.yml 加 ADMIN_INIT_PASSWORD 环境变量 - kubernetes/deployment.yaml 加 env 注释 验证(清空 DB 后全新 seed): 镜像 sha256:eaf0affd8d93 /app/data/initial-admin-password.txt 199B 权限 0600 内容含 "适用账号: [admin dba auditor developer]" + 随机密码 启动日志完整打印获取方式 登录实测:admin / dba / developer 用新密码均返回 access_token; admin123 登录失败(已失效) 二进制 strings:admin123 命中 0 --- README.md | 48 ++++++++++ backend/cmd/server/seed.go | 125 ++++++++++++++++++++++----- backend/internal/handler/workflow.go | 22 ++++- docker-compose.yml | 4 + kubernetes/deployment.yaml | 11 ++- 5 files changed, 184 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index bc6c700..143fac2 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,54 @@ cd frontend npx tsc --noEmit ``` +## 首次部署获取默认账号密码 + +首次启动会生成 **16 位随机密码**(不再固定 `admin123`),以下默认账号**共用该密码**: + +| 账号 | 角色 | 说明 | +|------|------|------| +| `admin` | 超级管理员 | 全权限 | +| `dba` | DBA管理员 | 数据库实例与变更 | +| `auditor` | 审计员 | 审核工单 | +| `developer` | 开发者 | 提交 SQL 工单 | + +密码同时: +- 打印到**启动日志**(仅首次,日志轮转后就没了) +- 写入数据目录 `initial-admin-password.txt`(权限 `0600`) + +### 获取方式(推荐第 1 种) + +```bash +# Docker Compose +docker compose exec <服务名> cat /app/data/initial-admin-password.txt +docker compose logs <服务名> | grep -A8 "已创建默认账号" +docker cp <容器名>:/app/data/initial-admin-password.txt ./ + +# Kubernetes +kubectl -n sql exec deploy/opsxsql -- cat /app/data/initial-admin-password.txt +kubectl -n sql logs deploy/opsxsql | grep -A8 "已创建默认账号" +kubectl -n sql cp :/app/data/initial-admin-password.txt ./ +``` + +### 自定义密码(跳过随机生成) + +```bash +# Docker Compose:部署前设置 +export ADMIN_INIT_PASSWORD='your-strong-password' +docker compose up -d + +# Kubernetes:在 deployment env 中加 ADMIN_INIT_PASSWORD(或用 Secret 注入) +``` + +### 说明 + +| 项 | 说明 | +|----|------| +| 幂等 | 已存在同名账号时**不重建、不改密码**;只有缺失的账号才创建 | +| 生成失败 | 随机密码生成失败时**直接退出**,不降级到公开默认口令 | +| 轮转 | 本平台暂无自助改密接口;改密请用管理员 `POST /api/v1/users/:id/reset-password` | +| 清理 | 确认所有账号改密后可删除 `initial-admin-password.txt` | + ## 版本历史 | 版本 | 说明 | diff --git a/backend/cmd/server/seed.go b/backend/cmd/server/seed.go index 08c07a9..4cc5acb 100644 --- a/backend/cmd/server/seed.go +++ b/backend/cmd/server/seed.go @@ -1,7 +1,11 @@ package main import ( + "crypto/rand" + "fmt" "log" + "os" + "path/filepath" "sql-platform/internal/model" @@ -20,6 +24,32 @@ func seedAll(db *gorm.DB) { log.Println("[seed] Default data initialized") } +// InitPasswordFile 首次启动生成的默认账号初始密码文件名。 +// 默认账号(admin/dba/auditor/developer)共用这一个密码,登录后请尽快修改。 +const InitPasswordFile = "initial-admin-password.txt" + +// randomPassword 生成 n 位随机密码(大小写字母 + 数字,排除 0/O/1/l/I 等易混淆字符) +func randomPassword(n int) (string, error) { + const charset = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + for i := range buf { + buf[i] = charset[int(buf[i])%len(charset)] + } + return string(buf), nil +} + +// dataDir 返回数据目录(由 DB_PATH 决定),用于存放初始密码文件 +func dataDir() string { + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "/app/data/sqlite3.db" + } + return filepath.Dir(dbPath) +} + // seedDefaultUsers creates default users if they don't exist. func seedDefaultUsers(db *gorm.DB) { type seedUser struct { @@ -27,37 +57,84 @@ func seedDefaultUsers(db *gorm.DB) { displayName string email string role string - password string - active bool - } - users := []seedUser{ - {"admin", "超级管理员", "admin@opsx.local", "admin", "admin123", true}, - {"dba", "DBA管理员", "dba@opsx.local", "dba", "admin123", true}, - {"auditor", "审计员", "auditor@opsx.local", "auditor", "admin123", true}, - {"developer", "开发者", "developer@opsx.local", "developer", "admin123", true}, } + users := []seedUser{ + {"admin", "超级管理员", "admin@opsx.local", "admin"}, + {"dba", "DBA管理员", "dba@opsx.local", "dba"}, + {"auditor", "审计员", "auditor@opsx.local", "auditor"}, + {"developer", "开发者", "developer@opsx.local", "developer"}, + } + + // 先找出缺失的默认账号;一个都不缺就直接返回,不生成任何密码 + var missing []seedUser for _, u := range users { var existing model.User if db.Where("username = ?", u.username).First(&existing).Error != nil { - hash, _ := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost) - email := u.email - active := u.active - user := model.User{ - Username: u.username, - DisplayName: u.displayName, - Email: &email, - HashedPassword: string(hash), - Role: u.role, - IsActive: &active, - } - if err := db.Create(&user).Error; err != nil { - log.Printf("[seed] Failed to create user %s: %v", u.username, err) - } else { - log.Printf("[seed] Created user: %s (%s)", u.username, u.role) - } + missing = append(missing, u) } } + if len(missing) == 0 { + return + } + + // 决定初始密码:ADMIN_INIT_PASSWORD 优先,否则随机生成 + pwd := os.Getenv("ADMIN_INIT_PASSWORD") + generated := false + if pwd == "" { + var err error + if pwd, err = randomPassword(16); err != nil { + log.Fatalf("[seed] 生成随机初始密码失败: %v;请通过 ADMIN_INIT_PASSWORD 环境变量指定初始密码", err) + } + generated = true + } + + var created []string + for _, u := range missing { + hash, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) + email := u.email + active := true + user := model.User{ + Username: u.username, + DisplayName: u.displayName, + Email: &email, + HashedPassword: string(hash), + Role: u.role, + IsActive: &active, + } + if err := db.Create(&user).Error; err != nil { + log.Printf("[seed] Failed to create user %s: %v", u.username, err) + } else { + created = append(created, u.username) + log.Printf("[seed] Created user: %s (%s)", u.username, u.role) + } + } + + if len(created) == 0 { + return + } + + if generated { + path := filepath.Join(dataDir(), InitPasswordFile) + content := fmt.Sprintf("# opsxsql 默认账号初始密码(首次启动生成)\n"+ + "# 登录后请立即修改;确认无需保留时可删除本文件\n"+ + "# 适用账号: %v\n"+ + "密码: %s\n", created, pwd) + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + log.Printf("[seed] 写入初始密码文件失败: %v", err) + } + log.Printf("============================================================") + log.Printf(" 首次启动:已创建默认账号 %v", created) + log.Printf(" 初始密码 : %s", pwd) + log.Printf(" 已保存到 : %s", path) + log.Printf(" 获取方式 :") + log.Printf(" docker compose exec <服务名> cat %s", path) + log.Printf(" kubectl -n exec deploy/opsxsql -- cat %s", path) + log.Printf(" ⚠️ 所有默认账号共用此密码,登录后请立即修改") + log.Printf("============================================================") + } else { + log.Printf("[seed] 默认账号初始密码来自环境变量 ADMIN_INIT_PASSWORD") + } } // seedDefaultWorkflow creates a default approval workflow if none exists. diff --git a/backend/internal/handler/workflow.go b/backend/internal/handler/workflow.go index 9ca75d4..76b410e 100644 --- a/backend/internal/handler/workflow.go +++ b/backend/internal/handler/workflow.go @@ -1,6 +1,7 @@ package handler import ( + "crypto/rand" "net/http" "strconv" @@ -221,7 +222,13 @@ func BatchImportUsers(c *gin.Context) { password := u.Password if password == "" { - password = "admin123" + // 不再使用公开默认口令:随机生成并在响应中回传给管理员 + gen, gerr := genPassword(14) + if gerr != nil { + errors = append(errors, "fail: "+u.Username+" 密码生成失败") + continue + } + password = gen } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -379,3 +386,16 @@ func ResetUserPassword(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"}) } + +// genPassword 生成 n 位随机密码(排除易混淆字符),用于批量导入未指定密码的用户 +func genPassword(n int) (string, error) { + const charset = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + for i := range buf { + buf[i] = charset[int(buf[i])%len(charset)] + } + return string(buf), nil +} diff --git a/docker-compose.yml b/docker-compose.yml index 333e48e..c586aea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,10 @@ services: - "SECRET_KEY=${SECRET_KEY:?SECRET_KEY is required. Generate: openssl rand -hex 32}" # JWT 密钥: 可选;生产模式未提供时自动生成随机密钥并持久化到数据卷 - JWT_SECRET=${JWT_SECRET:-} + # 可选:默认账号初始密码。不设置则首次启动生成 16 位随机密码, + # 写入数据卷 initial-admin-password.txt 并打印到启动日志 + # 获取:docker compose exec opsxsql cat /app/data/initial-admin-password.txt + - ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-} healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9000/health"] diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index 1031cf5..da19a90 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -27,11 +27,20 @@ spec: containers: - name: opsxsql image: lghost/opsxsql:latest - imagePullPolicy: IfNotPresent + imagePullPolicy: Always ports: - name: http containerPort: 9000 env: + # 可选:默认账号(admin/dba/auditor/developer)初始密码。 + # 不设置则首次启动生成 16 位随机密码,写入 PVC 的 initial-admin-password.txt + # 获取:kubectl -n sql exec deploy/opsxsql -- cat /app/data/initial-admin-password.txt + # - name: ADMIN_INIT_PASSWORD + # valueFrom: + # secretKeyRef: + # name: opsxsql-secrets + # key: ADMIN_INIT_PASSWORD + # optional: true - name: PORT value: "9000" - name: DB_PATH