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>
148 lines
4.6 KiB
Go
148 lines
4.6 KiB
Go
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)
|
||
}
|