Files
dudu/server/internal/auth/email.go
T
wangjia 8266c575aa
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 10s
fix(auth): 固定验证码改为显式 AUTH_EMAIL_DEV_CODE 环境开关(安全评审反馈)
不再从 mailer 运行时类型推导认证材料:固定码仅当显式设置
AUTH_EMAIL_DEV_CODE 时生效(run-dev.sh 设 888888,生产部署不经
该脚本即为随机码),设置时启动打印醒目告警。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:42:11 +08:00

158 lines
4.8 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()
// ⚠️ 开发期便利:AUTH_EMAIL_DEV_CODE 显式设置时验证码固定(run-dev.sh 设 888888),
// 免翻日志。仅开发脚本会设置该变量;生产部署不设即为随机码,启动时若设有醒目告警。
if h.DevCode != "" {
code = h.DevCode
}
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
}