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