Files
quyun-v2/backend/app/http/web/auth.go

327 lines
10 KiB
Go

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
}