refactor: 清理旧版兼容性,升级为 bcrypt 密码算法

- 密码哈希从 MD5+SHA1 升级为 bcrypt
- 删除 super_admins/users 表中的 salt 字段
- 删除旧版升级文件(upgrade.php, check_upgrade, execute_upgrade, sql/upgrades/)
- 删除 PASSWORD_SALT 配置项
- 清理所有'兼容 Python 版'注释
- 新项目独立,无历史包袱
This commit is contained in:
2026-06-22 10:45:13 +08:00
parent 124d7f645e
commit 4193a1a153
17 changed files with 76 additions and 1319 deletions

View File

@@ -43,13 +43,6 @@ JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=60
JWT_IDLE_TIMEOUT_MINUTES=10
# ===========================================
# 密码加密配置(与 Python 版兼容)
# 算法: MD5(SHA1(password) + SALT)
# ===========================================
PASSWORD_SALT=your-fixed-salt-string
# ===========================================
# 系统管理员配置
# ===========================================

View File

@@ -8,6 +8,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.7.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.31.0
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.25.12
)

View File

@@ -51,9 +51,6 @@ type Config struct {
JWTExpireMinutes int
JWTIdleTimeoutMinutes int
// 密码加密(兼容 Python 版)
PasswordSalt string
// 系统管理员配置
SuperAdminLoginPath string
SuperAdminDefaultUser string
@@ -98,8 +95,6 @@ func Load() (*Config, error) {
JWTExpireMinutes: getEnvInt("JWT_EXPIRE_MINUTES", 60),
JWTIdleTimeoutMinutes: getEnvInt("JWT_IDLE_TIMEOUT_MINUTES", 10),
PasswordSalt: getEnv("PASSWORD_SALT", ""),
SuperAdminLoginPath: getEnv("SUPER_ADMIN_LOGIN_PATH", "/super-admin"),
SuperAdminDefaultUser: getEnv("SUPER_ADMIN_DEFAULT_USERNAME", "admin"),
// 安全警告:默认密码仅用于首次部署初始化,上线前必须在 .env 中修改 SUPER_ADMIN_DEFAULT_PASSWORD。
@@ -110,14 +105,11 @@ func Load() (*Config, error) {
LogFile: getEnv("LOG_FILE", "logs/app.log"),
}
// 校验必填项
// 校验必填项
if cfg.JWTSecretKey == "" {
return nil, fmt.Errorf("配置 JWT_SECRET_KEY 不能为空")
}
if cfg.PasswordSalt == "" {
return nil, fmt.Errorf("配置 PASSWORD_SALT 不能为空")
}
AppConfig = cfg
return cfg, nil
}

View File

@@ -15,15 +15,14 @@ import "time"
// SuperAdmin 超级管理员模型,对应 super_admins 表
type SuperAdmin struct {
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"`
Salt string `gorm:"column:salt;type:varchar(64);not null" json:"-"`
RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"`
Status int8 `gorm:"column:status;default:1" json:"status"`
NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;type:varchar(60);not null" json:"-"`
RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"`
Status int8 `gorm:"column:status;default:1" json:"status"`
NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
}
// TableName 指定表名

View File

@@ -17,7 +17,7 @@ import "time"
type User struct {
UserID int `gorm:"column:user_id;primaryKey;autoIncrement" json:"user_id"`
Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"`
PasswordHash string `gorm:"column:password_hash;type:varchar(60);not null" json:"-"`
RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"`
UserType string `gorm:"column:user_type;type:enum('student','parent','admin','super_admin');not null" json:"user_type"`
StudentID *int `gorm:"column:student_id" json:"student_id"`

View File

@@ -54,20 +54,12 @@ func (r *SuperAdminRepo) Create(admin *model.SuperAdmin) (int, error) {
return admin.ID, nil
}
// UpdatePassword 更新超级管理员密码
// UpdatePassword 更新超级管理员密码并清除强制改密标记
func (r *SuperAdminRepo) UpdatePassword(id int, passwordHash string) error {
return r.db.Model(&model.SuperAdmin{}).
Where("id = ?", id).
Update("password_hash", passwordHash).Error
}
// UpdatePasswordWithSalt 更新超级管理员密码和盐值,并清除强制改密标记
func (r *SuperAdminRepo) UpdatePasswordWithSalt(id int, passwordHash, salt string) error {
return r.db.Model(&model.SuperAdmin{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"password_hash": passwordHash,
"salt": salt,
"password_hash": passwordHash,
"need_change_password": 0,
}).Error
}
@@ -98,11 +90,10 @@ func (r *SuperAdminRepo) UpdateStatus(id int, status int8) error {
}
// EnsureDefaultAdmin 确保默认超级管理员存在(使用 INSERT IGNORE 避免并发竞态)
func (r *SuperAdminRepo) EnsureDefaultAdmin(username, passwordHash, salt, realName string) error {
func (r *SuperAdminRepo) EnsureDefaultAdmin(username, passwordHash, realName string) error {
admin := model.SuperAdmin{
Username: username,
PasswordHash: passwordHash,
Salt: salt,
RealName: realName,
Status: 1,
}

View File

@@ -16,7 +16,6 @@ import (
"fmt"
"regexp"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto"
@@ -100,10 +99,17 @@ func (s *AdminService) getInitialPassword(classID int) (string, error) {
return pwd, nil
}
// hashPassword 对密码进行 bcrypt 哈希,失败时 panic不应发生
func hashPasswordOrPanic(password string) string {
hash, err := crypto.HashPassword(password)
if err != nil {
logger.Sugared.Fatalf("密码哈希失败: %v", err)
}
return hash
}
// AddStudent 新增学生
func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, classID int, dormitoryNumber *string) (map[string]interface{}, error) {
cfg := config.AppConfig
// 校验宿舍号格式
if !validateDormitoryNumber(dormitoryNumber) {
return map[string]interface{}{"success": false, "message": "宿舍号格式不正确,应为如 东1-101 的格式"}, nil
@@ -134,7 +140,7 @@ func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string,
if err != nil {
return nil, err
}
passwordHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt)
passwordHash := hashPasswordOrPanic(defaultPassword)
_, err = s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID)
if err != nil {
logger.Sugared.Errorf("创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err)
@@ -147,7 +153,7 @@ func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string,
if parentAccount != nil && *parentAccount != "" {
exists, _ := s.userRepo.CheckUsernameExists(*parentAccount)
if !exists {
parentHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt)
parentHash := hashPasswordOrPanic(defaultPassword)
parentRealName := fmt.Sprintf("%s家长", name)
if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil {
logger.Sugared.Warnf("创建家长账号失败(学生记录已保留): parent_account=%s, student_id=%d, err=%v", *parentAccount, studentID, err)
@@ -167,7 +173,6 @@ func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string,
// 未使用数据库事务的原因Repository 层未暴露事务接口,全量事务包裹需要较大重构;
// 且批量导入场景下允许部分成功是合理的业务权衡(用户可修正失败记录后重新导入)。
func (s *AdminService) ImportStudents(students []map[string]interface{}, classID int) (map[string]interface{}, error) {
cfg := config.AppConfig
successCount := 0
failedCount := 0
var details []map[string]interface{}
@@ -262,7 +267,7 @@ func (s *AdminService) ImportStudents(students []map[string]interface{}, classID
existingSet[studentNo] = true
// 创建学生登录账号
passwordHash := crypto.HashPassword(password, cfg.PasswordSalt)
passwordHash := hashPasswordOrPanic(password)
if _, err := s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID); err != nil {
logger.Sugared.Errorf("批量导入-创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err)
// 回滚学生记录
@@ -277,7 +282,7 @@ func (s *AdminService) ImportStudents(students []map[string]interface{}, classID
// 创建家长账号
if parentAccount != nil && *parentAccount != "" && !usernameSet[*parentAccount] {
parentHash := crypto.HashPassword(password, cfg.PasswordSalt)
parentHash := hashPasswordOrPanic(password)
parentRealName := fmt.Sprintf("%s家长", name)
if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil {
logger.Sugared.Errorf("批量导入-创建家长账号失败: parent_account=%s, student_id=%d, err=%v", *parentAccount, studentID, err)
@@ -346,7 +351,6 @@ func (s *AdminService) ResetStudentPassword(studentID int, newPassword string) e
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
return fmt.Errorf("%s", msg)
}
cfg := config.AppConfig
student, err := s.studentRepo.GetByID(studentID)
if err != nil {
return fmt.Errorf("学生不存在")
@@ -356,14 +360,15 @@ func (s *AdminService) ResetStudentPassword(studentID int, newPassword string) e
if err != nil {
return fmt.Errorf("学生登录账号不存在")
}
passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt)
passwordHash, err := crypto.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("密码加密失败")
}
return s.userRepo.UpdatePassword(user.UserID, passwordHash)
}
// AddAdmin 添加管理员
func (s *AdminService) AddAdmin(username, realName, password, roleType string, classID int, subjectID *int) (map[string]interface{}, error) {
cfg := config.AppConfig
exists, _ := s.userRepo.CheckUsernameExists(username)
if exists {
return map[string]interface{}{"success": false, "message": "用户名已存在"}, nil
@@ -377,7 +382,10 @@ func (s *AdminService) AddAdmin(username, realName, password, roleType string, c
password = pwd
}
passwordHash := crypto.HashPassword(password, cfg.PasswordSalt)
passwordHash, err := crypto.HashPassword(password)
if err != nil {
return nil, fmt.Errorf("密码加密失败: %w", err)
}
userID, err := s.userRepo.CreateAdmin(username, passwordHash, realName)
if err != nil {
return nil, err
@@ -436,8 +444,10 @@ func (s *AdminService) ResetAdminPassword(userID int, newPassword string) error
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
return fmt.Errorf("%s", msg)
}
cfg := config.AppConfig
passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt)
passwordHash, err := crypto.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("密码加密失败")
}
return s.userRepo.UpdatePassword(userID, passwordHash)
}

View File

@@ -17,13 +17,13 @@ import (
"github.com/redis/go-redis/v9"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
)
// AuthService 认证服务
@@ -124,10 +124,8 @@ func (s *AuthService) Login(username, password, ip, userAgent string) *LoginResu
return s.tryParentLogin(username, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey)
}
// 验证密码(使用全局 PASSWORD_SALT与 Python 版兼容。
// 已知设计局限:全局共享盐值,若泄露则所有普通用户密码面临风险。
// 后续迁移计划:为每个用户生成独立盐值,存储在 users 表中。)
if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) {
// 验证密码(bcrypt
if !crypto.VerifyPassword(password, user.PasswordHash) {
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
return &LoginResult{Success: false, Message: "用户名或密码错误"}
}
@@ -222,7 +220,7 @@ func (s *AuthService) loginAsStudent(student *model.Student, password, ip, userA
return &LoginResult{Success: false, Message: "用户名或密码错误"}
}
if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) {
if !crypto.VerifyPassword(password, user.PasswordHash) {
return &LoginResult{Success: false, Message: "用户名或密码错误"}
}
@@ -288,7 +286,7 @@ func (s *AuthService) tryParentLogin(username, password, ip, userAgent string, c
return &LoginResult{Success: false, Message: "用户名或密码错误"}
}
if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) {
if !crypto.VerifyPassword(password, user.PasswordHash) {
return &LoginResult{Success: false, Message: "用户名或密码错误"}
}
@@ -340,8 +338,6 @@ func (s *AuthService) Logout(userID int) error {
// ChangePassword 修改密码
func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string, force bool) error {
cfg := config.AppConfig
user, err := s.userRepo.GetByUserID(userID)
if err != nil {
return fmt.Errorf("用户不存在")
@@ -349,7 +345,7 @@ func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string
// 验证原密码(强制改密时跳过)
if !force {
if !crypto.VerifyPassword(oldPassword, user.PasswordHash, cfg.PasswordSalt) {
if !crypto.VerifyPassword(oldPassword, user.PasswordHash) {
return fmt.Errorf("原密码错误")
}
}
@@ -360,7 +356,10 @@ func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string
}
// 更新密码
newHash := crypto.HashPassword(newPassword, cfg.PasswordSalt)
newHash, err := crypto.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("密码加密失败")
}
if err := s.userRepo.UpdatePassword(userID, newHash); err != nil {
return fmt.Errorf("密码修改失败")
}
@@ -458,4 +457,3 @@ func getPasswordChangePath(userType string) string {
return "/"
}
}

View File

@@ -40,16 +40,13 @@ func (s *SuperAdminService) EnsureDefaultAdmin() error {
logger.Sugared.Warnf("⚠️ 当前使用默认超级管理员密码,部署环境请务必修改 SUPER_ADMIN_DEFAULT_PASSWORD 并重启服务")
// 为超级管理员生成独立的随机 Salt
salt, err := crypto.GenerateRandomPassword(16)
passwordHash, err := crypto.HashPassword(cfg.SuperAdminDefaultPass)
if err != nil {
return fmt.Errorf("生成随机盐值失败: %w", err)
return fmt.Errorf("密码哈希失败: %w", err)
}
passwordHash := crypto.HashPassword(cfg.SuperAdminDefaultPass, salt)
if err := s.superAdminRepo.EnsureDefaultAdmin(
cfg.SuperAdminDefaultUser,
passwordHash,
salt,
"系统管理员",
); err != nil {
return fmt.Errorf("创建默认超级管理员失败: %w", err)
@@ -82,7 +79,7 @@ func (s *SuperAdminService) Login(username, password, ip, userAgent string) (map
return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil
}
if !crypto.VerifyPassword(password, admin.PasswordHash, admin.Salt) {
if !crypto.VerifyPassword(password, admin.PasswordHash) {
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil
}
@@ -120,7 +117,7 @@ func (s *SuperAdminService) Login(username, password, ip, userAgent string) (map
}, nil
}
// ChangePassword 超级管理员修改密码(操作 super_admins 表,使用独立 salt
// ChangePassword 超级管理员修改密码
func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword string, force bool) error {
admin, err := s.superAdminRepo.GetByID(adminID)
if err != nil {
@@ -129,7 +126,7 @@ func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword
// 验证原密码(强制改密时跳过)
if !force {
if !crypto.VerifyPassword(oldPassword, admin.PasswordHash, admin.Salt) {
if !crypto.VerifyPassword(oldPassword, admin.PasswordHash) {
return fmt.Errorf("原密码错误")
}
}
@@ -139,14 +136,12 @@ func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword
return fmt.Errorf("%s", msg)
}
// 生成新的独立 salt
newSalt, err := crypto.GenerateRandomPassword(16)
newHash, err := crypto.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("生成随机盐值失败: %w", err)
return fmt.Errorf("密码加密失败: %w", err)
}
newHash := crypto.HashPassword(newPassword, newSalt)
if err := s.superAdminRepo.UpdatePasswordWithSalt(adminID, newHash, newSalt); err != nil {
if err := s.superAdminRepo.UpdatePassword(adminID, newHash); err != nil {
return fmt.Errorf("密码修改失败")
}

View File

@@ -12,41 +12,29 @@
package crypto
import (
"crypto/md5"
"crypto/rand"
"crypto/sha1"
"crypto/subtle"
"encoding/hex"
"fmt"
"math/big"
"golang.org/x/crypto/bcrypt"
)
// HashPassword 密码哈希(与 Python 版完全兼容)
// 算法: MD5(SHA1(password) + salt)
// Python 参考: backend/utils/security.py -> sha1_md5_password()
// 已知弱算法MD5 和 SHA1 均不适合密码哈希场景,保留此实现仅为兼容 Python 版数据。
// 后续迁移计划:迁移到 bcrypt/scrypt/argon2并提供兼容层逐步过渡。
func HashPassword(password string, salt string) string {
// 第一层: SHA1(password)
sha1Hash := sha1.Sum([]byte(password))
sha1Hex := hex.EncodeToString(sha1Hash[:])
// 加盐: SHA1_hex + salt
salted := sha1Hex + salt
// 第二层: MD5(salted)
md5Hash := md5.Sum([]byte(salted))
return hex.EncodeToString(md5Hash[:])
// HashPassword 使用 bcrypt 对密码进行哈希
// bcrypt 自带盐值管理,无需外部 salt
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("密码哈希失败: %w", err)
}
return string(hash), nil
}
// VerifyPassword 验证密码(使用常量时间比较,防止时序攻击)
func VerifyPassword(plainPassword, hashedPassword, salt string) bool {
computed := HashPassword(plainPassword, salt)
return subtle.ConstantTimeCompare([]byte(computed), []byte(hashedPassword)) == 1
// VerifyPassword 验证密码是否与 bcrypt 哈希匹配
func VerifyPassword(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// GenerateRandomPassword 生成随机密码
// 与 Python 版 SecurityUtils.generate_random_password() 兼容
func GenerateRandomPassword(length int) (string, error) {
alphabet := "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
result := make([]byte, length)

View File

@@ -49,7 +49,7 @@ func InitRedis(cfg *config.Config) (*redis.Client, error) {
return rdb, nil
}
// --- Token 存储操作(兼容 Python 版 Redis Token 管理) ---
// --- Token 存储操作 ---
const (
tokenKeyPrefix = "user_token:"