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 // 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) }