package web import ( "crypto/rand" "errors" "fmt" "math/big" "regexp" "strings" "sync" "time" "quyun/v2/app/errorx" "quyun/v2/app/http/web/dto" "quyun/v2/app/services" "quyun/v2/database/models" "quyun/v2/pkg/consts" "quyun/v2/providers/jwt" "github.com/gofiber/fiber/v3" "github.com/google/uuid" "gorm.io/gorm" ) // @provider type auth struct { jwt *jwt.JWT } var ( reUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`) rePhone = regexp.MustCompile(`^[0-9]{6,20}$`) ) type passwordResetState struct { code string codeExpire time.Time lastSentAt time.Time resetToken string tokenExpire time.Time } var passwordResetStore = struct { mu sync.Mutex phoneToItem map[string]*passwordResetState tokenToPhone map[string]string }{ phoneToItem: make(map[string]*passwordResetState), tokenToPhone: make(map[string]string), } const ( passwordResetCodeTTL = 5 * time.Minute passwordResetTokenTTL = 10 * time.Minute passwordResetSendGap = 60 * time.Second ) // Login 用户登录(平台侧,非超级管理员)。 // // @Summary 用户登录 // @Tags Web // @Accept json // @Produce json // @Param form body dto.LoginForm true "form" // @Success 200 {object} dto.LoginResponse "成功" // @Router /v1/auth/login [post] // @Bind form body func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse, error) { m, err := services.User.FindByUsername(ctx, form.Username) if err != nil { return nil, errorx.Wrap(err).WithMsg("用户名或密码错误") } if ok := m.ComparePassword(ctx, form.Password); !ok { return nil, errorx.Wrap(errorx.ErrInvalidCredentials).WithMsg("用户名或密码错误") } token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{ UserID: m.ID, })) if err != nil { return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") } return &dto.LoginResponse{Token: token}, nil } // PasswordResetSendSMS 找回密码:发送短信验证码(预留:当前返回验证码用于前端弹窗展示)。 // // @Summary 找回密码-发送短信验证码 // @Tags Web // @Accept json // @Produce json // @Param form body dto.PasswordResetSendSMSForm true "form" // @Success 200 {object} dto.PasswordResetSendSMSResponse "成功" // @Router /v1/auth/password/reset/sms [post] // @Bind form body func (ctl *auth) passwordResetSendSMS(ctx fiber.Ctx, form *dto.PasswordResetSendSMSForm) (*dto.PasswordResetSendSMSResponse, error) { phone := strings.TrimSpace(form.Phone) if phone == "" { return nil, errorx.ErrMissingParameter.WithMsg("请输入手机号") } if !rePhone.MatchString(phone) { return nil, errorx.ErrInvalidParameter.WithMsg("手机号格式不正确") } passwordResetStore.mu.Lock() defer passwordResetStore.mu.Unlock() now := time.Now() item := passwordResetStore.phoneToItem[phone] if item == nil { item = &passwordResetState{} passwordResetStore.phoneToItem[phone] = item } if !item.lastSentAt.IsZero() { elapsed := now.Sub(item.lastSentAt) if elapsed < passwordResetSendGap { remain := int((passwordResetSendGap - elapsed).Seconds()) if remain < 1 { remain = 1 } return nil, errorx.ErrTooManyRequests.WithMsgf("操作太频繁,请 %d 秒后再试", remain) } } n, err := rand.Int(rand.Reader, big.NewInt(1000000)) if err != nil { return nil, errorx.Wrap(err).WithMsg("验证码生成失败,请稍后再试") } code := fmt.Sprintf("%06d", n.Int64()) item.code = code item.codeExpire = now.Add(passwordResetCodeTTL) item.lastSentAt = now item.resetToken = "" item.tokenExpire = time.Time{} return &dto.PasswordResetSendSMSResponse{ NextSendSeconds: int(passwordResetSendGap.Seconds()), Code: code, }, nil } // PasswordResetVerify 找回密码:校验短信验证码。 // // @Summary 找回密码-校验验证码 // @Tags Web // @Accept json // @Produce json // @Param form body dto.PasswordResetVerifyForm true "form" // @Success 200 {object} dto.PasswordResetVerifyResponse "成功" // @Router /v1/auth/password/reset/verify [post] // @Bind form body func (ctl *auth) passwordResetVerify(ctx fiber.Ctx, form *dto.PasswordResetVerifyForm) (*dto.PasswordResetVerifyResponse, error) { phone := strings.TrimSpace(form.Phone) if phone == "" { return nil, errorx.ErrMissingParameter.WithMsg("请输入手机号") } if !rePhone.MatchString(phone) { return nil, errorx.ErrInvalidParameter.WithMsg("手机号格式不正确") } code := strings.TrimSpace(form.Code) if code == "" { return nil, errorx.ErrMissingParameter.WithMsg("请输入验证码") } passwordResetStore.mu.Lock() defer passwordResetStore.mu.Unlock() now := time.Now() item := passwordResetStore.phoneToItem[phone] if item == nil || item.code == "" || item.codeExpire.IsZero() || now.After(item.codeExpire) { return nil, errorx.ErrPreconditionFailed.WithMsg("验证码已过期,请重新获取") } if item.code != code { return nil, errorx.ErrInvalidParameter.WithMsg("验证码错误,请重新输入") } // 创建一次性重置令牌,并清理验证码,避免复用。 resetToken := uuid.NewString() item.resetToken = resetToken item.tokenExpire = now.Add(passwordResetTokenTTL) item.code = "" item.codeExpire = time.Time{} passwordResetStore.tokenToPhone[resetToken] = phone return &dto.PasswordResetVerifyResponse{ResetToken: resetToken}, nil } // PasswordReset 找回密码:重置密码。 // // @Summary 找回密码-重置密码 // @Tags Web // @Accept json // @Produce json // @Param form body dto.PasswordResetForm true "form" // @Success 200 {object} dto.PasswordResetResponse "成功" // @Router /v1/auth/password/reset [post] // @Bind form body func (ctl *auth) passwordReset(ctx fiber.Ctx, form *dto.PasswordResetForm) (*dto.PasswordResetResponse, error) { resetToken := strings.TrimSpace(form.ResetToken) if resetToken == "" { return nil, errorx.ErrMissingParameter.WithMsg("请先完成验证码校验") } if form.Password == "" || form.ConfirmPassword == "" { return nil, errorx.ErrMissingParameter.WithMsg("请输入新密码并确认") } if len(form.Password) < 8 { return nil, errorx.ErrParameterTooShort.WithMsg("密码至少 8 位,请设置更安全的密码") } if form.Password != form.ConfirmPassword { return nil, errorx.ErrInvalidParameter.WithMsg("两次输入的密码不一致,请重新确认") } passwordResetStore.mu.Lock() phone, ok := passwordResetStore.tokenToPhone[resetToken] item := passwordResetStore.phoneToItem[phone] now := time.Now() if !ok || phone == "" || item == nil || item.resetToken != resetToken || item.tokenExpire.IsZero() || now.After(item.tokenExpire) { passwordResetStore.mu.Unlock() return nil, errorx.ErrTokenInvalid.WithMsg("重置会话已失效,请重新获取验证码") } // 令牌一次性使用 delete(passwordResetStore.tokenToPhone, resetToken) item.resetToken = "" item.tokenExpire = time.Time{} passwordResetStore.mu.Unlock() // 当前版本将手机号视为用户名。 if _, err := services.User.FindByUsername(ctx, phone); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound.WithMsg("该手机号尚未注册") } return nil, errorx.Wrap(err).WithMsg("用户信息校验失败,请稍后再试") } if err := services.User.ResetPasswordByUsername(ctx, phone, form.Password); err != nil { return nil, errorx.Wrap(err).WithMsg("重置密码失败,请稍后再试") } return &dto.PasswordResetResponse{Ok: true}, nil } // Register 用户注册(平台侧,非超级管理员)。 // // @Summary 用户注册 // @Tags Web // @Accept json // @Produce json // @Param form body dto.RegisterForm true "form" // @Success 200 {object} dto.LoginResponse "成功" // @Router /v1/auth/register [post] // @Bind form body func (ctl *auth) register(ctx fiber.Ctx, form *dto.RegisterForm) (*dto.LoginResponse, error) { username := strings.TrimSpace(form.Username) if username == "" { return nil, errorx.ErrMissingParameter.WithMsg("请输入用户名") } if !reUsername.MatchString(username) { return nil, errorx.ErrInvalidParameter.WithMsg("用户名需为 3-32 位字母/数字/下划线") } if form.Password == "" { return nil, errorx.ErrMissingParameter.WithMsg("请输入密码") } if len(form.Password) < 8 { return nil, errorx.ErrParameterTooShort.WithMsg("密码至少 8 位,请设置更安全的密码") } if form.Password != form.ConfirmPassword { return nil, errorx.ErrInvalidParameter.WithMsg("两次输入的密码不一致,请重新确认") } // 先查询用户名是否已存在,避免直接插入导致不友好的数据库错误信息。 _, err := services.User.FindByUsername(ctx, username) if err == nil { return nil, errorx.ErrRecordDuplicated.WithMsg("用户名已被占用,换一个试试") } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.Wrap(err).WithMsg("用户信息校验失败,请稍后再试") } m := &models.User{ Username: username, Password: form.Password, Roles: []consts.Role{consts.RoleUser}, Status: consts.UserStatusVerified, } if _, err := services.User.Create(ctx, m); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, errorx.ErrRecordDuplicated.WithMsg("用户名已被占用,换一个试试") } return nil, errorx.Wrap(err).WithMsg("注册失败,请稍后再试") } token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{UserID: m.ID})) if err != nil { return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") } return &dto.LoginResponse{Token: token}, nil } // Token 刷新登录凭证。 // // @Summary 刷新 Token // @Tags Web // @Accept json // @Produce json // @Success 200 {object} dto.LoginResponse "成功" // @Router /v1/auth/token [get] func (ctl *auth) token(ctx fiber.Ctx) (*dto.LoginResponse, error) { claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) if !ok || claims == nil || claims.UserID <= 0 { return nil, errorx.ErrTokenInvalid } token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{ UserID: claims.UserID, })) if err != nil { return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") } return &dto.LoginResponse{Token: token}, nil }