package service import ( "context" "errors" "fmt" "strings" "golang.org/x/crypto/bcrypt" "oci-portal/internal/model" ) // ErrCredentialConfirm 表示当前密码校验失败;api 层映射 401。 var ErrCredentialConfirm = errors.New("当前密码不正确") // ErrCredentialInvalid 标记凭据输入非法;api 层映射 400。 var ErrCredentialInvalid = errors.New("凭据输入非法") // ErrPasswordLoginDisabled 表示密码登录已被禁用;api 层映射 403。 var ErrPasswordLoginDisabled = errors.New("密码登录已禁用,请使用外部身份登录") // ErrNeedIdentity 表示未绑定外部身份不可禁用密码登录;api 层映射 409。 var ErrNeedIdentity = errors.New("至少绑定一个外部身份后才能禁用密码登录") // ErrLastIdentity 表示密码登录禁用期间不可解绑最后一个身份(防自锁);api 层映射 409。 var ErrLastIdentity = errors.New("密码登录已禁用,不能解绑最后一个外部身份;请先允许密码登录") // UpdateCredentialsInput 是修改登录凭据的请求体;两项至少改一项,当前密码必填。 type UpdateCredentialsInput struct { NewUsername string `json:"newUsername"` NewPassword string `json:"newPassword"` CurrentPassword string `json:"currentPassword" binding:"required"` } // UpdateCredentials 修改用户名 / 密码:当前密码必验;改名后旧 JWT 的 // sub 不再命中账号,前端应强制重新登录。 func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in UpdateCredentialsInput) error { user, err := s.findUser(ctx, username) if err != nil { return err } if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(in.CurrentPassword)) != nil { return ErrCredentialConfirm } newName := strings.TrimSpace(in.NewUsername) if err := validateCredentialChange(user, newName, in.NewPassword); err != nil { return err } updates := map[string]any{} if newName != "" && newName != user.Username { taken, err := s.usernameTaken(ctx, newName, user.ID) if err != nil { return err } if taken { return fmt.Errorf("用户名已被占用: %w", ErrCredentialInvalid) } updates["username"] = newName } if in.NewPassword != "" { hash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("hash password: %w", err) } updates["password_hash"] = string(hash) } if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil { return fmt.Errorf("update credentials: %w", err) } return nil } // validateCredentialChange 校验改名 / 改密输入;两者均无实际变更时报非法。 func validateCredentialChange(user *model.User, newName, newPassword string) error { if newName != "" && len(newName) > 64 { return fmt.Errorf("用户名最长 64 字符: %w", ErrCredentialInvalid) } // bcrypt 只取前 72 字节,超长部分静默截断,直接拒绝 if newPassword != "" && (len(newPassword) < 8 || len(newPassword) > 72) { return fmt.Errorf("新密码长度须在 8-72 之间: %w", ErrCredentialInvalid) } if (newName == "" || newName == user.Username) && newPassword == "" { return fmt.Errorf("没有需要保存的变更: %w", ErrCredentialInvalid) } return nil } func (s *AuthService) usernameTaken(ctx context.Context, name string, selfID uint) (bool, error) { var count int64 err := s.db.WithContext(ctx).Model(&model.User{}). Where("username = ? AND id <> ?", name, selfID).Count(&count).Error if err != nil { return false, fmt.Errorf("check username: %w", err) } return count > 0, nil } // PasswordLoginDisabled 读密码登录禁用开关;settings 未注入(测试)视为未禁用。 func (s *AuthService) PasswordLoginDisabled(ctx context.Context) (bool, error) { if s.settings == nil { return false, nil } return s.settings.PasswordLoginDisabled(ctx) } // SetPasswordLoginDisabled 保存开关;开启前须至少绑定一个外部身份,防止自锁。 func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool) error { if s.settings == nil { return errors.New("settings unavailable") } if disabled { user, err := s.findUser(ctx, username) if err != nil { return err } n, err := s.identityCount(ctx, user.ID) if err != nil { return err } if n == 0 { return ErrNeedIdentity } } return s.settings.SetPasswordLoginDisabled(ctx, disabled) } func (s *AuthService) identityCount(ctx context.Context, userID uint) (int64, error) { var count int64 err := s.db.WithContext(ctx).Model(&model.UserIdentity{}). Where("user_id = ?", userID).Count(&count).Error if err != nil { return 0, fmt.Errorf("count identities: %w", err) } return count, nil }