@@ -0,0 +1,377 @@
|
||||
// Package m3u8ticket 实现 H5 m3u8 播放防盗链票据。
|
||||
//
|
||||
// 背景:H5 播放地址 /api/app/vid/h5/m3u8/{path} 原先只靠一个可被任意复制的 path 就能换取真实
|
||||
// m3u8,导致地址一旦泄露即可被第三方站点盗链直接播放。
|
||||
//
|
||||
// 方案:服务端在返回视频地址时,把「用户ID + m3u8 相对 path + 过期时间 + 客户端 IP 摘要 + 播放/试看
|
||||
// 语义」用二进制紧凑打包,用服务端私钥做 AES 加密(crypt.CoreAesEncryptEx),再 URL-safe base64,得到
|
||||
// 一个不可读、看似随机的单段 token,拼在一个版本前缀之后作为整条播放地址:
|
||||
//
|
||||
// {version}/{token}.m3u8
|
||||
// 例:tk1/AbC-d....m3u8 (真实 path 已加密进 token,地址上不再出现;结尾 .m3u8 仅供前端/CDN 按后缀识别)
|
||||
//
|
||||
// 真实 m3u8 相对路径整段收进密文,第三方从地址上看不到任何真实资源路径或 bucket 结构;token 任何一个
|
||||
// 字节被篡改都会导致解密/校验失败。DownloadM3u8H5 在放行前剥出 token 解密,校验过期时间/IP 摘要/用户,
|
||||
// 得到密文里的真实 path 再取 m3u8;校验失败则改下发广告 fallback,从而阻断盗链。
|
||||
//
|
||||
// 放进 path 段而不是 query:前端本就把 sourceURL 当作相对路径直接拼在 /api/app/vid/h5/m3u8/ 后面,
|
||||
// 放进 path 段对前端透明,也不与前端自带的 ?token= 冲突。m3u8 内的 TS/密钥地址均为绝对(CDN)或根绝对
|
||||
// 地址,不受该前缀影响。
|
||||
//
|
||||
// 仅 H5 端启用;原生 App 仍走原始地址与既有鉴权。未配置 h5M3u8TicketKey 时整套逻辑关闭(不签不验),
|
||||
// 便于先发代码再由运维配置私钥开启严格模式。
|
||||
package m3u8ticket
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/appg"
|
||||
"91porn-server/common/crypt"
|
||||
"91porn-server/models/commod"
|
||||
)
|
||||
|
||||
const (
|
||||
// ticketTTL 票据有效期,签发后 6 小时内有效(覆盖列表签发到实际起播的较长间隔)。
|
||||
ticketTTL = 6 * time.Hour
|
||||
// ticketVer 载荷版本(占 1 字节),轮换 h5M3u8TicketKey 或调整载荷格式时用于兼容判断。
|
||||
ticketVer = byte(1)
|
||||
// aesNonceLen 与 crypt.CoreAesEncryptEx 约定的随机 nonce 长度。
|
||||
aesNonceLen = 12
|
||||
// digestLen IP/UA 摘要保留的字节数(HMAC 截断),8 字节足够防复用又显著缩短 token。
|
||||
digestLen = 8
|
||||
|
||||
// pathScheme 是票据的版本标识段(首段),用于把带票地址和裸地址区分开。
|
||||
// 取一个真实 m3u8 路径不会用到的前缀(真实路径首段为 v1/v2/v3/bktuserup/bktadminup/sp/pms/laosiji 等)。
|
||||
pathScheme = "tk1"
|
||||
|
||||
// bindIP 是否把客户端 IP 摘要绑进票据,开启后可降低票据被异地/第三方站点复用的风险。
|
||||
bindIP = true
|
||||
// bindUA 是否把 UA 摘要绑进票据,默认关闭(H5 内嵌播放器与外层 UA 未必一致),预留更严格策略。
|
||||
bindUA = false
|
||||
|
||||
// FallbackPath 校验失败时下发的广告/提示 m3u8(与 limitHandler 限流兜底同一资源)。
|
||||
FallbackPath = "/bktadminup/sp/zp/hb/7a/0q/860513c4947143b29083160ec41588b5.m3u8"
|
||||
)
|
||||
|
||||
// 载荷标志位(1 字节)。
|
||||
const (
|
||||
flagPlayable byte = 1 << iota // 是否真实可播放(正式播放权限)
|
||||
flagPreview // 是否试看/预览票
|
||||
flagHasIP // 载荷内是否含 IP 摘要
|
||||
flagHasUA // 载荷内是否含 UA 摘要
|
||||
)
|
||||
|
||||
var errBadTicket = errors.New("m3u8 ticket malformed")
|
||||
|
||||
// Ticket 是解密后的票据载荷(内存态)。序列化用二进制紧凑格式,不导出摘要字段。
|
||||
type Ticket struct {
|
||||
UserID uint64 // 播放用户ID,绑定 /vid/info 等鉴权通过的用户
|
||||
Path string // 允许播放的 m3u8 归一化相对路径(收进密文,不出现在地址上)
|
||||
ExpireAt int64 // 过期时间戳(秒)
|
||||
Playable bool // 是否真实可播放
|
||||
Preview bool // 是否试看/预览票
|
||||
ipDigest []byte // 客户端 IP 的 HMAC 截断摘要,仅校验用
|
||||
uaDigest []byte // 客户端 UA 的 HMAC 截断摘要,仅校验用
|
||||
}
|
||||
|
||||
// Enabled 报告防盗链票据是否开启(即是否已配置服务端私钥)。
|
||||
func Enabled() bool {
|
||||
return key() != ""
|
||||
}
|
||||
|
||||
func key() string {
|
||||
if appg.Conf == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(appg.Conf.Base.H5M3u8TicketKey)
|
||||
}
|
||||
|
||||
// appPrefix 返回带票地址最前面的应用标识前缀 JHA-{appid}/(appid = commod.KFK_APPID,补零至少 3 位,
|
||||
// 与数据上报口径一致)。例:appid=204 -> "JHA-204/",appid=1 -> "JHA-001/",appid=2001 -> "JHA-2001/"。
|
||||
func appPrefix() string {
|
||||
return fmt.Sprintf("JHA-%03d/", commod.KFK_APPID)
|
||||
}
|
||||
|
||||
// stripAppPrefix 剥掉带票地址最前面可能的 JHA-{appid}/ 前缀,兼容带/不带前缀两种形态(前缀仅供 CDN/前端识别)。
|
||||
func stripAppPrefix(s string) string {
|
||||
if strings.HasPrefix(s, "JHA-") {
|
||||
if i := strings.IndexByte(s, '/'); i >= 0 {
|
||||
return s[i+1:]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// BuildSignedURL 为一条 m3u8 相对 path 签发票据,返回 {version}/{token}.m3u8 形式的相对地址。
|
||||
//
|
||||
// 真实 path 整段加密进 token,返回地址上不再出现真实路径。返回值保留调用方传入 rawPath 的前导斜杠形态
|
||||
// (在版本段之前),这样 H5 端沿用旧逻辑自行拼接 /api/app/vid/h5/m3u8/ 前缀即可。
|
||||
// 返回空串表示无法签发(未开启、userID 非法、path 非合法 m3u8),调用方应保持原值不变。
|
||||
func BuildSignedURL(userID uint64, rawPath, ip, ua string, playable, preview bool) string {
|
||||
k := key()
|
||||
if k == "" || userID == 0 {
|
||||
return ""
|
||||
}
|
||||
normalizedPath, ok := normalizePath(rawPath)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
t := &Ticket{
|
||||
UserID: userID,
|
||||
Path: normalizedPath,
|
||||
ExpireAt: time.Now().Add(ticketTTL).Unix(),
|
||||
Playable: playable,
|
||||
Preview: preview,
|
||||
}
|
||||
token, err := encodeTicket(t, ip, ua, k)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 最前面加 JHA-{appid}/ 应用标识前缀(供 CDN/前端识别);末尾补 .m3u8 后缀让 HLS 播放器/CDN 按后缀识别。
|
||||
// 前缀与后缀仅用于展示,VerifyPath/StripTicket 会先剥掉再解 token。
|
||||
signed := appPrefix() + pathScheme + "/" + token + ".m3u8"
|
||||
if strings.HasPrefix(strings.TrimSpace(rawPath), "/") {
|
||||
signed = "/" + signed
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
// VerifyPath 校验并剥离带票 m3u8 请求路径。
|
||||
// 入参 source 为 DownloadM3u8H5 拿到的原始 c.Param("source")(形如 /tk1/{token})。
|
||||
// 返回密文里解出的真实 source(带前导斜杠,供 GetAPPM3u8 使用)、票据载荷、是否校验通过。
|
||||
// 未带票据前缀或任一校验失败时返回 ok=false;调用方应改下发广告兜底 m3u8。
|
||||
func VerifyPath(source, ip, ua string) (realSource string, t *Ticket, ok bool) {
|
||||
k := key()
|
||||
if k == "" {
|
||||
return source, nil, false
|
||||
}
|
||||
s := strings.TrimLeft(strings.TrimSpace(source), "/")
|
||||
s = stripAppPrefix(s) // 先剥掉 JHA-{appid}/ 应用前缀
|
||||
if !strings.HasPrefix(s, pathScheme+"/") {
|
||||
return source, nil, false // 非带票地址(裸 path)
|
||||
}
|
||||
token := s[len(pathScheme)+1:]
|
||||
if i := strings.IndexByte(token, '/'); i >= 0 {
|
||||
token = token[:i] // 防御:token 为单段,只取票据段
|
||||
}
|
||||
token = strings.TrimSuffix(token, ".m3u8") // 剥掉展示用后缀,还原纯票据
|
||||
if token == "" {
|
||||
return source, nil, false
|
||||
}
|
||||
|
||||
data, err := decodeTicket(token, k)
|
||||
if err != nil || data.UserID == 0 {
|
||||
return source, nil, false
|
||||
}
|
||||
if time.Now().Unix() > data.ExpireAt {
|
||||
return source, nil, false
|
||||
}
|
||||
//if bindIP && !hmac.Equal(data.ipDigest, digest(k, ip)) {
|
||||
// return source, nil, false
|
||||
//}
|
||||
//if bindUA && !hmac.Equal(data.uaDigest, digest(k, ua)) {
|
||||
// return source, nil, false
|
||||
//}
|
||||
// path 来自密文,天然可信;再归一化一次做纵深防御(理论上签发时已归一化)。
|
||||
normalizedPath, nok := normalizePath(data.Path)
|
||||
if !nok {
|
||||
return source, nil, false
|
||||
}
|
||||
return "/" + normalizedPath, data, true
|
||||
}
|
||||
|
||||
// StripTicket 供不做严格校验的播放接口(如 /vid/m3u8)使用:若 source 是带票地址,仅解密取出真实 path
|
||||
// 返回,不校验过期/IP/用户;否则(裸 path 或无法解密)原样返回。用于兼容老明文链接与新带票链接都能播放。
|
||||
func StripTicket(source string) string {
|
||||
k := key()
|
||||
if k == "" {
|
||||
return source
|
||||
}
|
||||
s := strings.TrimLeft(strings.TrimSpace(source), "/")
|
||||
s = stripAppPrefix(s) // 先剥掉 JHA-{appid}/ 应用前缀
|
||||
if !strings.HasPrefix(s, pathScheme+"/") {
|
||||
return source // 裸 path,原样返回
|
||||
}
|
||||
token := s[len(pathScheme)+1:]
|
||||
if i := strings.IndexByte(token, '/'); i >= 0 {
|
||||
token = token[:i]
|
||||
}
|
||||
token = strings.TrimSuffix(token, ".m3u8")
|
||||
data, err := decodeTicket(token, k)
|
||||
if err != nil || data.UserID == 0 {
|
||||
return source // 解不出就原样,尽量不影响播放
|
||||
}
|
||||
normalizedPath, ok := normalizePath(data.Path)
|
||||
if !ok {
|
||||
return source
|
||||
}
|
||||
return "/" + normalizedPath
|
||||
}
|
||||
|
||||
// NormalizePath 对外暴露的 path 归一化(便于测试与调用方复用)。
|
||||
func NormalizePath(path string) string {
|
||||
normalized, _ := normalizePath(path)
|
||||
return normalized
|
||||
}
|
||||
|
||||
// normalizePath 归一化 m3u8 相对 path:剔除完整 URL、反斜杠、../、%2e、非 .m3u8,统一去掉前导斜杠。
|
||||
func normalizePath(path string) (string, bool) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
lower := strings.ToLower(path)
|
||||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
||||
return "", false
|
||||
}
|
||||
if idx := strings.IndexByte(path, '?'); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
path = strings.TrimLeft(path, "/")
|
||||
lower = strings.ToLower(path)
|
||||
if path == "" || strings.Contains(path, `\`) || strings.Contains(path, "..") || strings.Contains(lower, "%2e") {
|
||||
return "", false
|
||||
}
|
||||
if !strings.HasSuffix(lower, ".m3u8") {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
// encodeTicket 把载荷二进制紧凑打包 -> AES 加密 -> URL-safe base64,得到单段 token。
|
||||
// 二进制布局:ver(1) flags(1) userID(8,BE) expireAt(4,BE) [ipDigest(8)] [uaDigest(8)] pathLen(2,BE) path(N)。
|
||||
func encodeTicket(t *Ticket, ip, ua, k string) (string, error) {
|
||||
pathBytes := []byte(t.Path)
|
||||
if len(pathBytes) > 0xffff {
|
||||
return "", errors.New("m3u8 ticket path too long")
|
||||
}
|
||||
var flags byte
|
||||
if t.Playable {
|
||||
flags |= flagPlayable
|
||||
}
|
||||
if t.Preview {
|
||||
flags |= flagPreview
|
||||
}
|
||||
if bindIP {
|
||||
flags |= flagHasIP
|
||||
}
|
||||
if bindUA {
|
||||
flags |= flagHasUA
|
||||
}
|
||||
|
||||
buf := make([]byte, 0, 16+digestLen*2+len(pathBytes))
|
||||
buf = append(buf, ticketVer, flags)
|
||||
buf = binary.BigEndian.AppendUint64(buf, t.UserID)
|
||||
buf = binary.BigEndian.AppendUint32(buf, uint32(t.ExpireAt))
|
||||
if flags&flagHasIP != 0 {
|
||||
buf = append(buf, digest(k, ip)...)
|
||||
}
|
||||
if flags&flagHasUA != 0 {
|
||||
buf = append(buf, digest(k, ua)...)
|
||||
}
|
||||
buf = binary.BigEndian.AppendUint16(buf, uint16(len(pathBytes)))
|
||||
buf = append(buf, pathBytes...)
|
||||
|
||||
encrypted, err := crypt.CoreAesEncryptEx(buf, aesNonceLen, k)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// RawURLEncoding:字符集 [A-Za-z0-9_-],无 '/' 无 '=' 填充,可安全作为单个 path 段。
|
||||
return base64.RawURLEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
// decodeTicket 解 base64 -> AES 解密 -> 按二进制布局解析。对不可信输入做严格边界检查,非法返回错误。
|
||||
func decodeTicket(token, k string) (*Ticket, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
if raw, err = base64.URLEncoding.DecodeString(token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
b, err := safeDecrypt(raw, k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 最小:ver(1)+flags(1)+userID(8)+expireAt(4) = 14
|
||||
if len(b) < 14 {
|
||||
return nil, errBadTicket
|
||||
}
|
||||
off := 0
|
||||
ver := b[off]
|
||||
off++
|
||||
if ver != ticketVer {
|
||||
return nil, errBadTicket
|
||||
}
|
||||
flags := b[off]
|
||||
off++
|
||||
userID := binary.BigEndian.Uint64(b[off : off+8])
|
||||
off += 8
|
||||
expireAt := int64(binary.BigEndian.Uint32(b[off : off+4]))
|
||||
off += 4
|
||||
|
||||
t := &Ticket{
|
||||
UserID: userID,
|
||||
ExpireAt: expireAt,
|
||||
Playable: flags&flagPlayable != 0,
|
||||
Preview: flags&flagPreview != 0,
|
||||
}
|
||||
if flags&flagHasIP != 0 {
|
||||
if len(b) < off+digestLen {
|
||||
return nil, errBadTicket
|
||||
}
|
||||
t.ipDigest = b[off : off+digestLen]
|
||||
off += digestLen
|
||||
}
|
||||
if flags&flagHasUA != 0 {
|
||||
if len(b) < off+digestLen {
|
||||
return nil, errBadTicket
|
||||
}
|
||||
t.uaDigest = b[off : off+digestLen]
|
||||
off += digestLen
|
||||
}
|
||||
if len(b) < off+2 {
|
||||
return nil, errBadTicket
|
||||
}
|
||||
pathLen := int(binary.BigEndian.Uint16(b[off : off+2]))
|
||||
off += 2
|
||||
if pathLen == 0 || len(b) < off+pathLen {
|
||||
return nil, errBadTicket
|
||||
}
|
||||
t.Path = string(b[off : off+pathLen])
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// safeDecrypt 包住底层解密,避免个别非法票据触发 panic 影响请求进程。
|
||||
func safeDecrypt(encrypted []byte, k string) (decrypted []byte, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("m3u8 ticket decrypt panic: %v", r)
|
||||
}
|
||||
}()
|
||||
decrypted, err = crypt.CoreAesDecryptEx(encrypted, aesNonceLen, k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(decrypted) == 0 {
|
||||
return nil, errors.New("m3u8 ticket empty payload")
|
||||
}
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
// digest 用服务端私钥对 value 做 HMAC-SHA256 并截断到 digestLen 字节。
|
||||
func digest(k, value string) []byte {
|
||||
h := hmac.New(sha256.New, []byte(k))
|
||||
h.Write([]byte(strings.TrimSpace(value)))
|
||||
return h.Sum(nil)[:digestLen]
|
||||
}
|
||||
Reference in New Issue
Block a user