19281d9c42
后端(协议 → 存储 → 接口,E2E 测试全绿): - protocol:AuthEmailCodeRequest / AuthEmailRequest DTO - store:EmailIdentity 表(邮箱唯一索引)+ authmail:* Redis key - auth/email.go:POST /v1/auth/email/code 发 6 位码(crypto/rand, 60s 冷却 SetNX,10 分钟 TTL);POST /v1/auth/email 常量时间比对、 码一次性、邮箱建号(昵称取前缀)→ JWT(与微信登录同响应形态) - Mailer 接口 + MockMailer(验证码打日志供联调;SMTP 未配置自动降级, 装配结果入启动日志,上线前须换真实实现) - TestEmailLogin E2E:发码/冷却 429/错码拒绝/登录/token 可用/码防复用 桌面(Rust 命令 + UI 改版): - api.rs:login_email_code / login_email(成功保存 token + ws 重连, 与扫码登录同路径) - 登录窗改版(原型 LoginPurchase 先行,dark 截图验收):logo + 分段 切换(微信扫码 / 邮箱登录,与设置页同控件语言)+ 邮箱表单 (60s 倒计时、错误文案、未注册自动建号提示) - 二维码真渲染:qrcode 库画 canvas(前景/背景取主题 token), 替换原 URL 文本占位;过期态半透明化 - 登录窗无边框化:transparent + Overlay 标题栏 + 原生贴形阴影; 抽公共 WindowFrame 组件(设置窗同步重构复用) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
153 lines
4.5 KiB
Go
153 lines
4.5 KiB
Go
// 邮箱验证码登录(桌面端第二登录方式):
|
|
// POST /v1/auth/email/code 发送 6 位验证码(60s 冷却,码 10 分钟有效)
|
|
// POST /v1/auth/email 校验验证码 → 建号/登录 → JWT
|
|
// 邮件发送经 Mailer 接口;SMTP 未配置时装配 MockMailer(验证码打日志,供联调)。
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
|
|
"dudu/server/internal/store"
|
|
"dudu/server/pkg/protocol"
|
|
)
|
|
|
|
const (
|
|
emailCodeTTL = 10 * time.Minute
|
|
emailCodeCooldown = 60 * time.Second
|
|
)
|
|
|
|
// Mailer 验证码邮件发送。
|
|
type Mailer interface {
|
|
SendCode(ctx context.Context, email, code string) error
|
|
}
|
|
|
|
// MockMailer 开发期 mock:验证码打日志不真发(⚠️ 上线前必须替换 SMTP 实现)。
|
|
type MockMailer struct{}
|
|
|
|
func (MockMailer) SendCode(_ context.Context, email, code string) error {
|
|
slog.Info("mock mailer: email login code", "email", email, "code", code)
|
|
return nil
|
|
}
|
|
|
|
// randCode 6 位数字验证码(crypto/rand)。
|
|
func randCode() string {
|
|
b := make([]byte, 6)
|
|
_, _ = rand.Read(b)
|
|
digits := make([]byte, 6)
|
|
for i, v := range b {
|
|
digits[i] = '0' + v%10
|
|
}
|
|
return string(digits)
|
|
}
|
|
|
|
// EmailCode POST /v1/auth/email/code 🔓
|
|
func (h *Handlers) EmailCode(c *gin.Context) {
|
|
var req protocol.AuthEmailCodeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
|
|
return
|
|
}
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
|
|
// 60s 冷却(SET NX),防轰炸
|
|
ok, err := h.RDB.SetNX(c, store.KeyAuthEmailCd(email), 1, emailCodeCooldown).Result()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
|
|
return
|
|
}
|
|
if !ok {
|
|
c.JSON(http.StatusTooManyRequests, protocol.NewAPIError(protocol.ErrRateLimited))
|
|
return
|
|
}
|
|
|
|
code := randCode()
|
|
if err := h.RDB.Set(c, store.KeyAuthEmail(email), code, emailCodeTTL).Err(); err != nil {
|
|
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
|
|
return
|
|
}
|
|
if err := h.Mailer.SendCode(c, email, code); err != nil {
|
|
slog.Error("send email code failed", "email", email, "err", err)
|
|
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// EmailLogin POST /v1/auth/email 🔓
|
|
func (h *Handlers) EmailLogin(c *gin.Context) {
|
|
var req protocol.AuthEmailRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
|
|
return
|
|
}
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
|
|
want, err := h.RDB.Get(c, store.KeyAuthEmail(email)).Result()
|
|
if err == redis.Nil || subtle.ConstantTimeCompare([]byte(want), []byte(req.Code)) != 1 {
|
|
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
|
|
return
|
|
}
|
|
if err != nil && err != redis.Nil {
|
|
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
|
|
return
|
|
}
|
|
// 一次性:校验通过即删,防复用
|
|
h.RDB.Del(c, store.KeyAuthEmail(email))
|
|
|
|
user, err := findOrCreateUserByEmail(h.DB, email)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
|
|
return
|
|
}
|
|
token, err := h.JWT.Sign(user.ID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, protocol.AuthTokenResponse{
|
|
Token: token,
|
|
User: protocol.UserInfo{UserID: user.ID, NicknameMasked: MaskNickname(user.Nickname)},
|
|
})
|
|
}
|
|
|
|
// findOrCreateUserByEmail 邮箱身份建号/登录(昵称取邮箱前缀)。
|
|
func findOrCreateUserByEmail(db *gorm.DB, email string) (*store.User, error) {
|
|
var ident store.EmailIdentity
|
|
err := db.Where("email = ?", email).First(&ident).Error
|
|
if err == nil {
|
|
var u store.User
|
|
return &u, db.First(&u, "id = ?", ident.UserID).Error
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
|
|
nick := email
|
|
if i := strings.IndexByte(email, '@'); i > 0 {
|
|
nick = email[:i]
|
|
}
|
|
user := store.User{ID: strings.ReplaceAll(uuid.NewString(), "-", "")[:24], Nickname: nick}
|
|
err = db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(&user).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Create(&store.EmailIdentity{UserID: user.ID, Email: email}).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|