Files
dudu/server/internal/auth/handlers.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

148 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"encoding/json"
"net/http"
"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"
)
// Handlers 认证路由:扫码(5B)、微信 OAuth(5C)、邮箱验证码、logout。
type Handlers struct {
DB *gorm.DB
RDB *redis.Client
JWT *JWT
Wechat WechatClient
Mailer Mailer
// QrAuthURL 二维码内容模板(真实环境为微信开放平台授权页,%s 为 state)
QrAuthURL string
}
const qrTTL = 2 * time.Minute
type qrState struct {
Status string `json:"status"` // pending | confirmed
Token string `json:"token,omitempty"`
UserID string `json:"user_id,omitempty"`
Nick string `json:"nick,omitempty"`
}
// CreateQr POST /v1/auth/qr 🔓
func (h *Handlers) CreateQr(c *gin.Context) {
state := uuid.NewString()
b, _ := json.Marshal(qrState{Status: "pending"})
if err := h.RDB.Set(c, store.KeyAuthQr(state), b, qrTTL).Err(); err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
url := h.QrAuthURL
if url == "" {
url = "https://dudu.app/auth/qr/" // mock 占位,接入开放平台后替换
}
c.JSON(http.StatusOK, protocol.AuthQrResponse{State: state, QrURL: url + state})
}
// PollQr GET /v1/auth/qr/:state 🔓(桌面端 1s 轮询)
func (h *Handlers) PollQr(c *gin.Context) {
b, err := h.RDB.Get(c, store.KeyAuthQr(c.Param("state"))).Bytes()
if err == redis.Nil {
c.JSON(http.StatusOK, protocol.AuthQrStatusResponse{Status: "expired"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
var s qrState
_ = json.Unmarshal(b, &s)
resp := protocol.AuthQrStatusResponse{Status: s.Status}
if s.Status == "confirmed" {
resp.Token = s.Token
resp.User = &protocol.UserInfo{UserID: s.UserID, NicknameMasked: s.Nick}
// 一次性:下发后即删,防复用
h.RDB.Del(c, store.KeyAuthQr(c.Param("state")))
}
c.JSON(http.StatusOK, resp)
}
// QrCallback GET /v1/auth/wechat/callback?code=&state= 🔓
// 手机微信内授权后回调:code 换身份 → 建号 → 标记 state confirmed。
func (h *Handlers) QrCallback(c *gin.Context) {
code, state := c.Query("code"), c.Query("state")
key := store.KeyAuthQr(state)
if n, _ := h.RDB.Exists(c, key).Result(); n == 0 {
c.String(http.StatusBadRequest, "二维码已过期,请回到 dudu 重新获取")
return
}
info, err := h.Wechat.ExchangeCode(c, code, "web")
if err != nil {
c.String(http.StatusBadRequest, "微信授权失败,请重试")
return
}
user, err := FindOrCreateUser(h.DB, info, "web")
if err != nil {
c.String(http.StatusInternalServerError, "服务繁忙,请重试")
return
}
token, err := h.JWT.Sign(user.ID)
if err != nil {
c.String(http.StatusInternalServerError, "服务繁忙,请重试")
return
}
b, _ := json.Marshal(qrState{
Status: "confirmed", Token: token, UserID: user.ID, Nick: MaskNickname(user.Nickname),
})
_ = h.RDB.Set(c, key, b, qrTTL).Err()
c.String(http.StatusOK, "登录成功,回到 dudu 继续")
}
// MobileLogin POST /v1/auth/wechat 🔓(移动端 OpenSDK code
func (h *Handlers) MobileLogin(c *gin.Context) {
var req protocol.AuthWechatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
return
}
info, err := h.Wechat.ExchangeCode(c, req.Code, "mobile")
if err != nil {
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
return
}
user, err := FindOrCreateUser(h.DB, info, "mobile")
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)},
})
}
// Logout POST /v1/auth/logout(需登录)
// 拉黑到 token 真实自然过期时间(中间件解析后存于 CtxExpires),确保即便
// JWT_TTL_HOURS 配得很大,注销 token 也不会在固定窗口后复活(17B)。
func (h *Handlers) Logout(c *gin.Context) {
jti := c.GetString(CtxJTI)
exp, _ := c.Get(CtxExpires)
expiresAt, _ := exp.(time.Time)
if expiresAt.IsZero() {
// 兜底:claims 未带 exp(理论上不会发生),按当前配置 TTL 估一个上界。
expiresAt = time.Now().Add(time.Duration(h.JWT.ttl))
}
_ = h.JWT.Revoke(c, jti, expiresAt)
c.Status(http.StatusNoContent)
}