You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

199 lines
7.5 KiB

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>.<sig>
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
}