diff --git a/go-backend/internal/security/password.go b/go-backend/internal/security/password.go index 608ad4f..5ed5597 100644 --- a/go-backend/internal/security/password.go +++ b/go-backend/internal/security/password.go @@ -23,3 +23,17 @@ func VerifyPassword(storedHash, plain string) (bool, bool) { } return false, false } + +func IsLegacyPasswordHash(storedHash string) bool { + storedHash = strings.TrimSpace(storedHash) + if len(storedHash) != 32 { + return false + } + for _, r := range storedHash { + if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') { + continue + } + return false + } + return true +} diff --git a/go-backend/internal/security/password_test.go b/go-backend/internal/security/password_test.go index 6662487..eb8227e 100644 --- a/go-backend/internal/security/password_test.go +++ b/go-backend/internal/security/password_test.go @@ -23,3 +23,16 @@ func TestVerifyPasswordAcceptsLegacyMD5(t *testing.T) { t.Fatalf("VerifyPassword() = (%v,%v), want (true,true)", ok, legacy) } } + +func TestIsLegacyPasswordHash(t *testing.T) { + if !IsLegacyPasswordHash("3c85cdebade1c51cf64ca9f3c09d182d") { + t.Fatal("expected 32-char hex MD5 hash to be legacy") + } + hash, err := HashPassword("admin_user") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if IsLegacyPasswordHash(hash) { + t.Fatalf("expected bcrypt hash not to be legacy: %q", hash) + } +} diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index b029d75..b4a5d2f 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -1,7 +1,9 @@ package repo import ( + "crypto/rand" "database/sql" + "encoding/hex" "errors" "fmt" "log" @@ -19,6 +21,7 @@ import ( "gorm.io/gorm/clause" "gorm.io/gorm/logger" + "go-backend/internal/security" "go-backend/internal/store/model" ) @@ -416,13 +419,23 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error { } func seedData(db *gorm.DB) { - adminUser := model.User{ - ID: 1, User: "admin_user", Pwd: "3c85cdebade1c51cf64ca9f3c09d182d", - RoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0, - FlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000, - UpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1, + var adminCount int64 + if err := db.Model(&model.User{}).Where("id = ?", 1).Count(&adminCount).Error; err == nil && adminCount == 0 { + adminPwd, err := security.HashPassword("admin_user") + if err != nil { + log.Printf("seed admin password hash failed: %v", err) + } else { + adminUser := model.User{ + ID: 1, User: "admin_user", Pwd: adminPwd, + RoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0, + FlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000, + UpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, + Status: 1, + PasswordChangedAt: 1748914865000, + } + db.Create(&adminUser) + } } - db.Where("id = ?", 1).FirstOrCreate(&adminUser) appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000} db.Where("id = ?", 1).FirstOrCreate(&appNameConfig) @@ -2353,26 +2366,31 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) { count := 0 for _, u := range users { - item := model.User{ - ID: u.ID, - User: u.User, - Pwd: u.Pwd, - RoleID: u.RoleID, - ExpTime: u.ExpTime, - Flow: u.Flow, - InFlow: u.InFlow, - OutFlow: u.OutFlow, - FlowResetTime: u.FlowResetTime, - Num: u.Num, - CreatedTime: u.CreatedTime, - UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, - Status: u.Status, + pwdHash, status, err := normalizeImportedUserPassword(u.Pwd, u.Status) + if err != nil { + return count, err } - err := tx.Clauses(clause.OnConflict{ + item := model.User{ + ID: u.ID, + User: u.User, + Pwd: pwdHash, + RoleID: u.RoleID, + ExpTime: u.ExpTime, + Flow: u.Flow, + InFlow: u.InFlow, + OutFlow: u.OutFlow, + FlowResetTime: u.FlowResetTime, + Num: u.Num, + CreatedTime: u.CreatedTime, + UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, + Status: status, + PasswordChangedAt: now, + } + err = tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ "user", "pwd", "role_id", "exp_time", "flow", "in_flow", "out_flow", - "flow_reset_time", "num", "updated_time", "status", + "flow_reset_time", "num", "updated_time", "status", "password_changed_at", }), }).Create(&item).Error if err != nil { @@ -2416,6 +2434,33 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) return count, nil } +func normalizeImportedUserPassword(password string, status int) (string, int, error) { + password = strings.TrimSpace(password) + if strings.HasPrefix(password, "$2") { + return password, status, nil + } + if security.IsLegacyPasswordHash(password) || password == "" { + replacement, err := randomPasswordHash() + if err != nil { + return "", status, err + } + return replacement, 0, nil + } + hash, err := security.HashPassword(password) + if err != nil { + return "", status, err + } + return hash, status, nil +} + +func randomPasswordHash() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return security.HashPassword(hex.EncodeToString(buf)) +} + func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) { count := 0 for _, n := range nodes { diff --git a/go-backend/internal/store/repo/repository_backup_test.go b/go-backend/internal/store/repo/repository_backup_test.go index 665a662..f55d363 100644 --- a/go-backend/internal/store/repo/repository_backup_test.go +++ b/go-backend/internal/store/repo/repository_backup_test.go @@ -5,9 +5,32 @@ import ( "testing" "time" + "go-backend/internal/security" "go-backend/internal/store/model" ) +func TestSeedDataDefaultAdminUsesBcrypt(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "seed.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + admin, err := r.GetUserByUsername("admin_user") + if err != nil { + t.Fatalf("get admin user: %v", err) + } + if admin == nil { + t.Fatal("expected seeded admin user") + } + if security.IsLegacyPasswordHash(admin.Pwd) { + t.Fatalf("seeded admin password is legacy MD5: %q", admin.Pwd) + } + if ok, legacy := security.VerifyPassword(admin.Pwd, "admin_user"); !ok || legacy { + t.Fatalf("VerifyPassword() = (%v,%v), want (true,false)", ok, legacy) + } +} + func TestBackupRoundTripsTunnelProbeTarget(t *testing.T) { source, err := Open(filepath.Join(t.TempDir(), "source.db")) if err != nil { @@ -136,6 +159,62 @@ func TestImportIgnoresSensitiveConfigs(t *testing.T) { assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before") } +func TestImportUsersDoesNotStoreLegacyMD5Passwords(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "legacy-user-import.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + now := time.Now().UnixMilli() + backup := &model.BackupData{ + Version: "1.0", + Users: []model.UserBackup{{ + ID: 55, + User: "legacy-import-user", + Pwd: "3c85cdebade1c51cf64ca9f3c09d182d", + RoleID: 1, + ExpTime: 2727251700000, + Flow: 99999, + InFlow: 0, + OutFlow: 0, + FlowResetTime: 1, + Num: 99999, + CreatedTime: now, + UpdatedTime: now, + Status: 1, + }}, + } + + result, err := r.Import(backup, []string{"users"}) + if err != nil { + t.Fatalf("Import() error = %v", err) + } + if result.UsersImported != 1 { + t.Fatalf("UsersImported = %d, want 1", result.UsersImported) + } + + user, err := r.GetUserByUsername("legacy-import-user") + if err != nil { + t.Fatalf("get imported user: %v", err) + } + if user == nil { + t.Fatal("expected imported user") + } + if security.IsLegacyPasswordHash(user.Pwd) { + t.Fatalf("imported password remained legacy MD5: %q", user.Pwd) + } + if ok, _ := security.VerifyPassword(user.Pwd, "admin_user"); ok { + t.Fatal("legacy imported password should not remain usable") + } + if user.Status != 0 { + t.Fatalf("legacy imported user status = %d, want disabled status 0", user.Status) + } + if user.PasswordChangedAt <= 0 { + t.Fatalf("PasswordChangedAt = %d, want import revocation timestamp", user.PasswordChangedAt) + } +} + func seedConfig(t *testing.T, r *Repository, name, value string) { t.Helper() if err := r.DB().Exec(` diff --git a/vite-frontend/src/config/site.ts b/vite-frontend/src/config/site.ts index 49ac542..efd39fc 100644 --- a/vite-frontend/src/config/site.ts +++ b/vite-frontend/src/config/site.ts @@ -144,7 +144,15 @@ export const configCache = { // 设置缓存的配置 set: (key: string, value: string): void => { - const cacheKey = CACHE_PREFIX + key; + const normalizedKey = key.trim().toLowerCase(); + + if (!shouldPersistConfigKey(normalizedKey)) { + configCache.remove(normalizedKey); + + return; + } + + const cacheKey = CACHE_PREFIX + normalizedKey; localStorage.setItem(cacheKey, value); },