b5ab92a57e
来自 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>
147 lines
4.6 KiB
Go
147 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
|
||
// 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)
|
||
}
|