package echomeet import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "os" "strings" "time" "yunyan/comm" "yunyan/pb" ) /* 会议纪要分享:服务端签发的只读票据。 ⚠️ 2026-09-23 之前,客户端把**用户的登录 JWT**(GetStorage 里的 logintoken)直接拼进 分享 URL 的最后一段。谁拿到那条链接就拿到了整个账号——能调任何业务接口、看全部纪要、 消费算力,而且除了改密码没有任何失效手段。链接一旦转发进群或被截图就收不回来。 现在换成票据:一条票据只对应**一条记录**、只能读、有有效期,且不含任何可用于其它接口的凭据。 票据格式(自包含,不落库): . payload = base64url(JSON{"i":记录id,"u":owner uid,"e":到期unix秒}) sig = base64url(HMAC-SHA256(shareKey, payload)) 为什么不落库:分享是读多写少、量小且可失效期自然收敛的场景,落库要多一张表 + 清理任务, 而票据本身已经带了全部信息。代价是**签发后无法单独撤销**(只能等过期或换密钥), 这是刻意接受的取舍——真要撤销能力时再补一张吊销表即可,票据格式不用变。 */ const ( // shareTicketDefaultDays 不指定时的有效期。 shareTicketDefaultDays = 7 // shareTicketMaxDays 有效期上限。链接是公开可转发的,无限期等于永久泄露。 shareTicketMaxDays = 30 // shareKeyContext 密钥派生的域分隔串。换它等于让全部存量票据立刻失效。 shareKeyContext = "echomeet.share.v1" ) var errShareTicketInvalid = errors.New("分享链接无效或已过期") // sharePayload 票据载荷。字段名压到一个字母:它要进 URL,越短越不容易被中间环节截断。 type sharePayload struct { Id uint64 `json:"i"` Uid string `json:"u"` Expire int64 `json:"e"` } // shareKey 从 FIELD_ENCRYPT_KEY 派生分享票据的签名密钥。 // // 为什么派生而不是直接用:FIELD_ENCRYPT_KEY 是 svc_config 里第三方凭据的加密密钥, // 拿它直接签票据会让两个用途共用同一份秘密。HMAC 是单向的,从派生key 推不回原 key。 // // 为什么选 FIELD_ENCRYPT_KEY 而不是新加一个配置项:它已经在每台机的 .env 里 // (console 与各业务服务本就必须配同一把),新增配置项意味着上线时漏配一处就整个功能不可用, // 而且漏配的表现是「分享按钮点了报错」这种要到真机才发现的问题。 func shareKey() ([]byte, error) { base := strings.TrimSpace(os.Getenv("FIELD_ENCRYPT_KEY")) if base == "" { // 刻意不退化成空密钥签名:那样任何人都能自己造票据读任意记录。 return nil, errors.New("缺少 FIELD_ENCRYPT_KEY,无法签发分享票据") } m := hmac.New(sha256.New, []byte(base)) m.Write([]byte(shareKeyContext)) return m.Sum(nil), nil } // signShareTicket 签发票据。 func signShareTicket(p sharePayload) (string, error) { key, err := shareKey() if err != nil { return "", err } raw, err := json.Marshal(p) if err != nil { return "", err } body := base64.RawURLEncoding.EncodeToString(raw) m := hmac.New(sha256.New, key) m.Write([]byte(body)) return body + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)), nil } // parseShareTicket 校验并解出票据载荷。 // // ⚠️ 「签名不对」「格式不对」「已过期」一律回同一个 errShareTicketInvalid。 // 分开回等于告诉攻击者「这条票据是真的、只是过期了」,同 recordErr 的理由。 func parseShareTicket(ticket string) (*sharePayload, error) { ticket = strings.TrimSpace(ticket) i := strings.LastIndex(ticket, ".") if i <= 0 || i == len(ticket)-1 { return nil, errShareTicketInvalid } body, sig := ticket[:i], ticket[i+1:] key, err := shareKey() if err != nil { return nil, err } m := hmac.New(sha256.New, key) m.Write([]byte(body)) want := m.Sum(nil) got, err := base64.RawURLEncoding.DecodeString(sig) // hmac.Equal 是常数时间比较:用 == 比会让攻击者靠计时逐字节试出正确签名。 if err != nil || !hmac.Equal(want, got) { return nil, errShareTicketInvalid } raw, err := base64.RawURLEncoding.DecodeString(body) if err != nil { return nil, errShareTicketInvalid } p := &sharePayload{} if err = json.Unmarshal(raw, p); err != nil { return nil, errShareTicketInvalid } if p.Id == 0 || p.Uid == "" || p.Expire <= 0 || time.Now().Unix() > p.Expire { return nil, errShareTicketInvalid } return p, nil } // @Summary 创建会议纪要分享链接 // @Description 为自己的一条会议记录签发只读分享票据,客户端拼成落地页 URL // @Tags Echomeet // @Accept json // @Produce json // @Security BearerAuth // @Param user body pb.EchomeetCreateShareLinkReq true "请求数据" // @Success 200 {object} comm.HttpResult{data=pb.EchomeetCreateShareLinkResp} "响应数据" // @Router /api/home/echomeet_createsharelink [post] func (this *apiComp) CreateShareLink(session comm.IUserSession, req *pb.EchomeetCreateShareLinkReq) (resp *pb.EchomeetCreateShareLinkResp, errdata *pb.ErrorData) { uid, errdata := requireUID(session) if errdata != nil { return } // 先确认这条记录确实是本人的:签发前不校验的话,改个 id 就能给别人的纪要签出公开链接。 if _, err := this.module.model.getrecordforuid(uid, req.Id); err != nil { errdata = recordErr(err) return } days := int(req.Expiredays) if days <= 0 { days = shareTicketDefaultDays } if days > shareTicketMaxDays { days = shareTicketMaxDays } expire := time.Now().AddDate(0, 0, days).Unix() ticket, err := signShareTicket(sharePayload{Id: req.Id, Uid: uid, Expire: expire}) if err != nil { errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: err.Error()} return } resp = &pb.EchomeetCreateShareLinkResp{Ticket: ticket, Expiretime: expire} return } // @Summary 按分享票据读取会议纪要 // @Description 落地页调用,**免登录**,须在 gateway 的 WhiteList 里 // @Tags Echomeet // @Accept json // @Produce json // @Param user body pb.EchomeetGetSharedRecordReq true "请求数据" // @Success 200 {object} comm.HttpResult{data=pb.EchomeetGetSharedRecordResp} "响应数据" // @Router /api/home/echomeet_getsharedrecord [post] func (this *apiComp) GetSharedRecord(session comm.IUserSession, req *pb.EchomeetGetSharedRecordReq) (resp *pb.EchomeetGetSharedRecordResp, errdata *pb.ErrorData) { // 注意:这里**不能**调 requireUID —— 本接口的全部意义就是让没登录的人也能打开链接。 p, err := parseShareTicket(req.Ticket) if err != nil { errdata = &pb.ErrorData{Code: pb.ErrorCode_StateInvalid, Message: err.Error()} return } // 仍按 (uid, id) 查:票据签发时绑定了 owner,记录易主或被删后票据自然失效。 model, err := this.module.model.getrecordforuid(p.Uid, p.Id) if err != nil { // 记录已删时同样回「链接无效」,不区分——链接是公开的,不该成为探测记录存在性的入口。 errdata = &pb.ErrorData{Code: pb.ErrorCode_StateInvalid, Message: errShareTicketInvalid.Error()} return } resp = &pb.EchomeetGetSharedRecordResp{ Id: model.Id, Title: model.Title, Creationtime: model.Creationtime, Seconds: model.Seconds, Audiourl: model.Audiourl, Original: model.Original, Translate: model.Translate, Overview: model.Overview, Summary: model.Summary, Formlanguage: model.Formlanguage, Tolanguage: model.Tolanguage, Expiretime: p.Expire, } return }