Files
wangjia b5ab92a57e
ci / server (push) Failing after 13s
ci / design-tokens (push) Failing after 11s
fix: 应用 xhigh 代码评审的跨端修复
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端:
- server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、
  billing 行锁、redis Lua 过期与设备槽刷新、config 解析
- desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询
- android:握手时序、请求头封装、账户状态派生、按需重组
- ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:50:08 +08:00

147 lines
4.6 KiB
Go
Raw Permalink 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
// 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)
}