Files
dudu/server/internal/auth/email.go
T
wangjia 19281d9c42
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 10s
feat(auth+desktop): 邮箱验证码登录 + 登录窗改版(微信/邮箱双方式,无边框)
后端(协议 → 存储 → 接口,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>
2026-07-11 09:40:16 +08:00

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
}