Files
opsx d584b64813 feat(security): 默认账号随机初始密码(不再硬编码 admin123)
参考 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
2026-10-26 17:37:21 +00:00

297 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"crypto/rand"
"fmt"
"log"
"os"
"path/filepath"
"sql-platform/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// seedAll initializes the database with default data (idempotent).
// Safe to run on every startup — skips existing records.
func seedAll(db *gorm.DB) {
seedDefaultUsers(db)
seedDefaultWorkflow(db)
seedDefaultSettings(db)
seedLDAPConfig(db)
seedAuditRules(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 {
username string
displayName string
email string
role string
}
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 {
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 <ns> 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.
func seedDefaultWorkflow(db *gorm.DB) {
var count int64
db.Model(&model.WorkflowDefinition{}).Count(&count)
if count > 0 {
return // Already has workflows
}
// Create default workflow
t := true
wf := model.WorkflowDefinition{
Name: "默认审批流程",
Description: strPtr("系统默认审批流程:开发者提交 → DBA审核 → 执行"),
IsDefault: &t,
IsActive: &t,
}
if err := db.Create(&wf).Error; err != nil {
log.Printf("[seed] Failed to create default workflow: %v", err)
return
}
// Create steps
steps := []model.WorkflowStep{
{
WorkflowID: wf.ID,
StepNumber: 1,
Name: "提交审核",
StepType: "submit",
IsRequired: &t,
TimeoutHours: intPtr(0),
},
{
WorkflowID: wf.ID,
StepNumber: 2,
Name: "DBA审核",
StepType: "audit",
ApproverRole: strPtr("dba"),
IsRequired: &t,
TimeoutHours: intPtr(24),
},
{
WorkflowID: wf.ID,
StepNumber: 3,
Name: "执行变更",
StepType: "execute",
IsRequired: &t,
TimeoutHours: intPtr(0),
},
}
for _, s := range steps {
db.Create(&s)
}
log.Printf("[seed] Created default workflow: %s (id=%d)", wf.Name, wf.ID)
}
func intPtr(v int) *int { return &v }
// seedLDAPConfig inserts default LDAP settings if not present.
func seedLDAPConfig(db *gorm.DB) {
ldapDefaults := []model.SystemSetting{
{Key: "ldap_host", Value: "8.217.205.92", ValueType: "string", Group: "ldap", Label: "LDAP 服务器地址", Desc: strPtr("LDAP 服务器 IP 或域名"), DefaultValue: strPtr("8.217.205.92"), SortOrder: 1},
{Key: "ldap_port", Value: "389", ValueType: "number", Group: "ldap", Label: "LDAP 端口", Desc: strPtr("默认 389,LDAPS 用 636"), DefaultValue: strPtr("389"), SortOrder: 2},
{Key: "ldap_use_ssl", Value: "false", ValueType: "boolean", Group: "ldap", Label: "启用 LDAPS (SSL/TLS)", Desc: strPtr("使用 SSL/TLS 加密连接"), DefaultValue: strPtr("false"), SortOrder: 3},
{Key: "ldap_bind_dn", Value: "cn=admin,dc=opsx,dc=vip", ValueType: "string", Group: "ldap", Label: "管理员 DN", Desc: strPtr("LDAP 管理员绑定 DN"), DefaultValue: strPtr("cn=admin,dc=opsx,dc=vip"), SortOrder: 4},
{Key: "ldap_base_dn", Value: "dc=opsx,dc=vip", ValueType: "string", Group: "ldap", Label: "Base DN", Desc: strPtr("用户搜索的基础 DN"), DefaultValue: strPtr("dc=opsx,dc=vip"), SortOrder: 5},
{Key: "ldap_user_filter", Value: "(objectClass=inetOrgPerson)", ValueType: "string", Group: "ldap", Label: "用户过滤器", Desc: strPtr("LDAP 用户搜索过滤器"), DefaultValue: strPtr("(objectClass=inetOrgPerson)"), SortOrder: 6},
{Key: "ldap_uid_attr", Value: "uid", ValueType: "string", Group: "ldap", Label: "UID 属性", Desc: strPtr("用户唯一标识属性"), DefaultValue: strPtr("uid"), SortOrder: 7},
{Key: "ldap_mail_attr", Value: "mail", ValueType: "string", Group: "ldap", Label: "邮箱属性", Desc: strPtr("用户邮箱属性名"), DefaultValue: strPtr("mail"), SortOrder: 8},
{Key: "ldap_cn_attr", Value: "cn", ValueType: "string", Group: "ldap", Label: "显示名属性", Desc: strPtr("用户显示名属性名"), DefaultValue: strPtr("cn"), SortOrder: 9},
}
for _, s := range ldapDefaults {
var existing model.SystemSetting
if db.Where("`key` = ?", s.Key).First(&existing).Error != nil {
db.Create(&s)
}
}
log.Println("[seed] LDAP default config seeded")
}
// seedAuditRules creates default audit rules for all DB types.
func seedAuditRules(db *gorm.DB) {
var count int64
db.Model(&model.AuditRule{}).Count(&count)
if count > 0 {
return // Already has rules
}
enabled := true
type seedRule struct {
Name, Desc, RuleType, Severity, Pattern, Message, Value string
}
allRules := map[string][]seedRule{
"mysql": {
{"禁止 DROP TABLE", "生产环境不允许删除表", "DDL", "error", `(?i)^\s*DROP\s+TABLE`, "禁止执行 DROP TABLE 操作,可能导致数据永久丢失", "reject"},
{"禁止 DROP DATABASE", "不允许删除数据库", "DDL", "error", `(?i)^\s*DROP\s+DATABASE`, "禁止执行 DROP DATABASE 操作", "reject"},
{"禁止 TRUNCATE TABLE", "清空表数据需走审批", "DDL", "error", `(?i)^\s*TRUNCATE\s+TABLE?`, "禁止执行 TRUNCATE 操作,请使用 DELETE 逐批删除", "reject"},
{"ALTER TABLE 需审核", "表结构变更需审批", "DDL", "warning", `(?i)^\s*ALTER\s+TABLE`, "ALTER TABLE 操作需要审批后执行", "review"},
{"禁止 DELETE 无 WHERE", "删除操作必须带条件", "DML", "error", `(?i)^\s*DELETE\s+FROM?\s+\S+\s*$`, "DELETE 语句必须包含 WHERE 条件", "reject"},
{"禁止 UPDATE 无 WHERE", "更新操作必须带条件", "DML", "error", `(?i)^\s*UPDATE\s+\S+\s+SET\s+.*\s*$`, "UPDATE 语句必须包含 WHERE 条件", "reject"},
{"SELECT * 建议", "避免使用 SELECT *", "DQL", "info", `(?i)^\s*SELECT\s+\*\s+FROM`, "建议明确指定查询字段,避免使用 SELECT *", "warn"},
{"INSERT 需指定列", "INSERT 需指定列名", "DML", "warning", `(?i)^\s*INSERT\s+INTO\s+\S+\s+VALUES`, "INSERT 语句应明确指定列名", "review"},
{"禁止 GRANT ALL", "不允许授予全部权限", "DCL", "error", `(?i)^\s*GRANT\s+ALL`, "禁止使用 GRANT ALL PRIVILEGES", "reject"},
{"大表变更警告", "影响行数超过阈值需注意", "performance", "warning", "", "操作影响行数可能较大,建议在低峰期执行", "warn"},
{"禁止不带 LIMIT 的 DELETE", "批量删除建议带 LIMIT", "DML", "warning", `(?i)^\s*DELETE\s+FROM\s+\S+\s+WHERE`, "建议 DELETE 语句添加 LIMIT 子句分批删除", "warn"},
},
"postgresql": {
{"禁止 DROP TABLE", "生产环境不允许删除表", "DDL", "error", `(?i)^\s*DROP\s+TABLE`, "禁止执行 DROP TABLE 操作", "reject"},
{"禁止 DROP DATABASE", "不允许删除数据库", "DDL", "error", `(?i)^\s*DROP\s+DATABASE`, "禁止执行 DROP DATABASE 操作", "reject"},
{"禁止 DELETE 无 WHERE", "删除操作必须带条件", "DML", "error", `(?i)^\s*DELETE\s+FROM?\s+\S+\s*$`, "DELETE 语句必须包含 WHERE 条件", "reject"},
{"禁止 UPDATE 无 WHERE", "更新操作必须带条件", "DML", "error", `(?i)^\s*UPDATE\s+\S+\s+SET\s+.*\s*$`, "UPDATE 语句必须包含 WHERE 条件", "reject"},
{"ALTER TABLE 需审核", "表结构变更需审批", "DDL", "warning", `(?i)^\s*ALTER\s+TABLE`, "ALTER TABLE 操作需要审批后执行", "review"},
{"SELECT * 建议", "避免使用 SELECT *", "DQL", "info", `(?i)^\s*SELECT\s+\*\s+FROM`, "建议明确指定查询字段", "warn"},
{"禁止 GRANT ALL", "不允许授予全部权限", "DCL", "error", `(?i)^\s*GRANT\s+ALL`, "禁止使用 GRANT ALL", "reject"},
},
"redis": {
{"禁止 FLUSHALL", "不允许清空所有数据", "DDL", "error", `(?i)^FLUSHALL`, "禁止执行 FLUSHALL 操作", "reject"},
{"禁止 FLUSHDB", "清空数据库需审批", "DDL", "error", `(?i)^FLUSHDB`, "禁止执行 FLUSHDB 操作", "reject"},
{"禁止 CONFIG", "配置修改需走审批", "security", "error", `(?i)^CONFIG\s`, "CONFIG 命令需要审批后执行", "reject"},
{"KEYS 命令警告", "生产环境禁止 KEYS", "performance", "error", `(?i)^KEYS\s`, "生产环境禁止使用 KEYS 命令,请使用 SCAN", "reject"},
},
"mongodb": {
{"禁止 dropDatabase", "不允许删除数据库", "DDL", "error", `dropDatabase`, "禁止执行 dropDatabase 操作", "reject"},
{"禁止 drop 集合", "不允许删除集合", "DDL", "error", `(?i)\.drop\(\)`, "禁止执行集合 drop 操作", "reject"},
{"remove 需带条件", "删除操作必须带条件", "DML", "error", `(?i)remove\(\s*\)`, "remove 操作必须带查询条件", "reject"},
{"updateMany 需审核", "批量更新需审批", "DML", "warning", `(?i)updateMany`, "updateMany 操作需要审批后执行", "review"},
},
}
total := 0
for dbType, rules := range allRules {
for _, r := range rules {
desc := r.Desc
pattern := r.Pattern
severity := r.Severity
value := r.Value
rule := model.AuditRule{
Name: r.Name,
Desc: &desc,
RuleType: r.RuleType,
Severity: &severity,
DBType: &dbType,
Pattern: &pattern,
IsEnabled: &enabled,
Message: r.Message,
Value: &value,
}
if err := db.Create(&rule).Error; err != nil {
log.Printf("[seed] Failed to create audit rule %s/%s: %v", dbType, r.Name, err)
} else {
total++
}
}
}
log.Printf("[seed] Created %d default audit rules across 4 DB types", total)
}