Files
acc-server-manager/local/migrations/001_upgrade_password_security.go
Fran Jurmanović 5e7c96697a code cleanup
2025-09-18 13:33:51 +02:00

200 lines
5.5 KiB
Go

package migrations
import (
"acc-server-manager/local/utl/logging"
"acc-server-manager/local/utl/password"
"errors"
"fmt"
"gorm.io/gorm"
)
type Migration001UpgradePasswordSecurity struct {
DB *gorm.DB
}
func NewMigration001UpgradePasswordSecurity(db *gorm.DB) *Migration001UpgradePasswordSecurity {
return &Migration001UpgradePasswordSecurity{DB: db}
}
func (m *Migration001UpgradePasswordSecurity) Up() error {
logging.Info("Starting password security upgrade migration...")
var migrationRecord MigrationRecord
err := m.DB.Where("migration_name = ?", "001_upgrade_password_security").First(&migrationRecord).Error
if err == nil {
logging.Info("Password security migration already applied, skipping")
return nil
}
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
return fmt.Errorf("failed to create migration tracking table: %v", err)
}
tx := m.DB.Begin()
if tx.Error != nil {
return fmt.Errorf("failed to start transaction: %v", tx.Error)
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := tx.Exec("ALTER TABLE users ADD COLUMN password_backup TEXT").Error; err != nil {
if !isDuplicateColumnError(err) {
tx.Rollback()
return fmt.Errorf("failed to add backup column: %v", err)
}
}
var users []UserForMigration
if err := tx.Find(&users).Error; err != nil {
tx.Rollback()
return fmt.Errorf("failed to fetch users: %v", err)
}
logging.Info("Found %d users to migrate", len(users))
migratedCount := 0
failedCount := 0
for _, user := range users {
if err := m.migrateUserPassword(tx, &user); err != nil {
logging.Error("Failed to migrate user %s (ID: %s): %v", user.Username, user.ID, err)
failedCount++
continue
}
migratedCount++
}
if err := tx.Exec("ALTER TABLE users DROP COLUMN password_backup").Error; err != nil {
logging.Error("Failed to remove backup column (non-critical): %v", err)
}
migrationRecord = MigrationRecord{
MigrationName: "001_upgrade_password_security",
AppliedAt: "datetime('now')",
Success: true,
Notes: fmt.Sprintf("Migrated %d users, %d failed", migratedCount, failedCount),
}
if err := tx.Create(&migrationRecord).Error; err != nil {
tx.Rollback()
return fmt.Errorf("failed to record migration: %v", err)
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("failed to commit migration: %v", err)
}
logging.Info("Password security migration completed successfully. Migrated: %d, Failed: %d", migratedCount, failedCount)
if failedCount > 0 {
logging.Error("Some users failed to migrate. They will need to reset their passwords.")
}
return nil
}
func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, user *UserForMigration) error {
if isAlreadyHashed(user.Password) {
logging.Debug("User %s already has hashed password, skipping", user.Username)
return nil
}
if err := tx.Model(user).Update("password_backup", user.Password).Error; err != nil {
return fmt.Errorf("failed to backup password: %v", err)
}
var plainPassword string
decrypted, err := decryptOldPassword(user.Password)
if err != nil {
logging.Error("Failed to decrypt password for user %s, treating as plain text: %v", user.Username, err)
plainPassword = user.Password
if len(plainPassword) > 100 || containsBinaryData(plainPassword) {
return fmt.Errorf("password appears to be corrupted encrypted data")
}
} else {
plainPassword = decrypted
}
if plainPassword == "" {
return errors.New("decrypted password is empty")
}
if len(plainPassword) < 1 {
return errors.New("password too short after decryption")
}
hashedPassword, err := password.HashPassword(plainPassword)
if err != nil {
return fmt.Errorf("failed to hash password: %v", err)
}
if err := tx.Model(user).Update("password", hashedPassword).Error; err != nil {
return fmt.Errorf("failed to update password: %v", err)
}
logging.Debug("Successfully migrated password for user %s", user.Username)
return nil
}
type UserForMigration struct {
ID string `gorm:"column:id"`
Username string `gorm:"column:username"`
Password string `gorm:"column:password"`
}
func (UserForMigration) TableName() string {
return "users"
}
type MigrationRecord struct {
ID uint `gorm:"primaryKey"`
MigrationName string `gorm:"unique;not null"`
AppliedAt string `gorm:"not null"`
Success bool `gorm:"not null"`
Notes string
}
func (MigrationRecord) TableName() string {
return "migration_records"
}
func isAlreadyHashed(password string) bool {
return len(password) >= 60 && (password[:4] == "$2a$" || password[:4] == "$2b$" || password[:4] == "$2y$")
}
func containsBinaryData(s string) bool {
for _, b := range []byte(s) {
if b < 32 && b != 9 && b != 10 && b != 13 {
return true
}
}
return false
}
func isDuplicateColumnError(err error) bool {
errStr := err.Error()
return fmt.Sprintf("%v", errStr) == "duplicate column name: password_backup" ||
fmt.Sprintf("%v", errStr) == "SQLITE_ERROR: duplicate column name: password_backup"
}
func decryptOldPassword(encryptedPassword string) (string, error) {
return "", errors.New("old decryption not implemented - treating as plain text")
}
func (m *Migration001UpgradePasswordSecurity) Down() error {
logging.Error("Password security migration rollback is not supported for security reasons")
return errors.New("password security migration rollback is not supported")
}
func RunPasswordSecurityMigration(db *gorm.DB) error {
migration := NewMigration001UpgradePasswordSecurity(db)
return migration.Up()
}