// // Copyright (C) 2024 veypi // 2025-03-04 16:08:06 // Distributed under terms of the MIT license. // package auth import ( "fmt" "math/rand" "regexp" "strings" "time" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/libs/crypto" "github.com/veypi/vbase/models" "github.com/veypi/vigo" "github.com/veypi/vigo/logv" "gorm.io/gorm" ) // Email 正则表达式 var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) // RegisterRequest 注册请求 type RegisterRequest struct { Username string `json:"username" src:"json" desc:"用户名"` Password string `json:"password" src:"json" desc:"密码"` Email string `json:"email,omitempty" src:"json" desc:"邮箱"` Phone string `json:"phone,omitempty" src:"json" desc:"手机号"` EmailCode string `json:"email_code,omitempty" src:"json" desc:"邮箱验证码"` PhoneCode string `json:"phone_code,omitempty" src:"json" desc:"手机号验证码"` Nickname string `json:"nickname,omitempty" src:"json" desc:"昵称"` } // register 用户注册 func register(x *vigo.X, req *RegisterRequest) (*AuthResponse, error) { // 验证用户名 if strings.TrimSpace(req.Username) == "" { return nil, vigo.ErrInvalidArg.WithString("username is required") } if len(req.Username) < 3 || len(req.Username) > 50 { return nil, vigo.ErrInvalidArg.WithString("username must be between 3 and 50 characters") } // 用户名只能包含字母、数字和下划线 if !regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString(req.Username) { return nil, vigo.ErrInvalidArg.WithString("username can only contain letters, numbers and underscores") } // 验证密码 if strings.TrimSpace(req.Password) == "" { return nil, vigo.ErrInvalidArg.WithString("password is required") } if len(req.Password) < 8 { return nil, vigo.ErrInvalidArg.WithString("password must be at least 8 characters") } // 验证邮箱格式 if req.Email != "" && !emailRegex.MatchString(req.Email) { return nil, vigo.ErrInvalidArg.WithString("invalid email format") } // 检查注册配置 requireEmail, _ := models.GetSettingBool(models.SettingAuthRegRequireEmail) requirePhone, _ := models.GetSettingBool(models.SettingAuthRegRequirePhone) emailEnabled, _ := models.GetSettingBool(models.SettingEmailEnabled) smsEnabled, _ := models.GetSettingBool(models.SettingSMSEnabled) // 校验必填字段(优先于服务检查) if requireEmail && req.Email == "" { return nil, vigo.ErrInvalidArg.WithString("email is required") } if requirePhone && req.Phone == "" { return nil, vigo.ErrInvalidArg.WithString("phone is required") } // 服务开启时至少一种验证方式 if emailEnabled || smsEnabled { hasEmail := req.Email != "" && req.EmailCode != "" hasPhone := req.Phone != "" && req.PhoneCode != "" if emailEnabled && smsEnabled { if !hasEmail && !hasPhone { return nil, vigo.ErrInvalidArg.WithString("email or phone verification is required") } } else if emailEnabled && !hasEmail { return nil, vigo.ErrInvalidArg.WithString("email verification is required") } else if smsEnabled && !hasPhone { return nil, vigo.ErrInvalidArg.WithString("phone verification is required") } } // 校验邮箱验证码(提供了邮箱时) if req.Email != "" { if err := validateRegisterCode(req.Email, req.EmailCode, "email"); err != nil { return nil, err } } // 校验手机验证码(提供了手机号时) if req.Phone != "" { if err := validateRegisterCode(req.Phone, req.PhoneCode, "sms"); err != nil { return nil, err } } // 使用事务处理注册,防止并发创建多个首个管理员用户 var user *models.User err := cfg.DB().Transaction(func(tx *gorm.DB) error { // 检查是否是第一个用户(在事务内检查,带锁) var userCount int64 if err := tx.Model(&models.User{}).Count(&userCount).Error; err != nil { return err } // 检查用户名是否已存在 var count int64 if err := tx.Model(&models.User{}).Where("username = ?", req.Username).Count(&count).Error; err != nil { return err } if count > 0 { return vigo.ErrInvalidArg.WithArgs("username already exists") } // 检查邮箱是否已存在 if req.Email != "" { count = 0 if err := tx.Model(&models.User{}).Where("email = ?", req.Email).Count(&count).Error; err != nil { return err } if count > 0 { return vigo.ErrInvalidArg.WithArgs("email already exists") } } // 检查手机是否已存在 if req.Phone != "" { count = 0 if err := tx.Model(&models.User{}).Where("phone = ?", req.Phone).Count(&count).Error; err != nil { return err } if count > 0 { return vigo.ErrInvalidArg.WithArgs("phone already exists") } } // 哈希密码 hashedPassword, err := crypto.HashPassword(req.Password, 12) if err != nil { return err } // 创建用户 var email *string if req.Email != "" { email = &req.Email } var phone *string if req.Phone != "" { phone = &req.Phone } user = &models.User{ Username: req.Username, Password: hashedPassword, Email: email, Phone: phone, Nickname: req.Nickname, Status: models.UserStatusActive, } if user.Nickname == "" { user.Nickname = user.Username } // 生成随机头像 user.Avatar = fmt.Sprintf("https://public.veypi.com/img/avatar/%04d.jpg", rand.New(rand.NewSource(time.Now().UnixNano())).Intn(220)) // 第一个用户固定 ID 为 admin if userCount == 0 { user.ID = "admin" } if err := tx.Create(user).Error; err != nil { return err } return nil }) if err != nil { return nil, err } if err := cfg.OnUserCreate(user.ID); err != nil { logv.Warn().Msgf("user create hook failed: %s", err) } // 授予角色(事务外,因为事务已确保用户创建成功) roleCode := "user" if user.ID == "admin" { roleCode = "admin" } if err := cfg.Auth.GrantRole(x.Context(), user.ID, roleCode); err != nil { return nil, vigo.ErrInternalServer.WithError(err) } // 生成token return generateAuthResponseForUser(x, user) } // validateRegisterCode 校验注册验证码 func validateRegisterCode(target, code, codeType string) error { if code == "" { return vigo.ErrInvalidArg.WithString("verification code is required") } db := cfg.DB() var verification models.VerificationCode if err := db.Where("target = ? AND type = ? AND purpose = ?", target, codeType, models.CodePurposeRegister). Order("created_at DESC").First(&verification).Error; err != nil { return vigo.ErrInvalidArg.WithString("invalid verification code") } maxAttempts, _ := models.GetSettingInt(models.SettingCodeMaxAttempt) if maxAttempts == 0 { maxAttempts = 3 } if !verification.CanRetry(maxAttempts) { return vigo.ErrInvalidArg.WithString("verification code expired or max attempts exceeded") } if verification.Code != code { db.Model(&verification).UpdateColumn("attempts", verification.Attempts+1) return vigo.ErrInvalidArg.WithString("invalid verification code") } now := time.Now() verification.Status = models.CodeStatusUsed verification.UsedAt = &now db.Save(&verification) return nil }