导读
做付费在线课程,最头疼的问题之一是课程视频被扒链外传:付费学员把视频地址复制出去,没买课的人也能看,甚至有人批量下载后转卖。单纯靠"登录才能看"根本拦不住,因为视频地址一旦固定,就可以被无限转发。我们在给一款知识付费产品做内容保护改造时,课程跑在乔拓云教育系统上,防盗链与签名校验这层是在其播放接口之上自研的。这篇文章把时效签名 URL、Referer/Origin 校验、播放令牌轮换、黑名单兜底这条链路讲清楚,并复盘五个真实踩坑。
一、先想清楚:视频防盗到底要防什么
很多团队一上来就上 DRM,结果成本高、兼容性差,学员还嫌播放卡。其实绝大多数中小教育产品要防的不是专业破解,而是这三类低成本盗用:
- 直接复制视频 URL 转发:地址是固定的,谁拿到都能播;
- 外站嵌入盗链:别人在自己网页里
<video src="你的地址">,白嫖你的流量和内容; - 一个账号多人共享:买一份课,几个人轮流登录看。
对应的防护也是分层的:签名 URL 让地址本身带时效和身份、过期即失效;Referer/Origin 校验在 CDN/网关层拦外站盗链;播放令牌轮换让单次播放凭证短时效、无法长期复用;异常行为风控兜底识别共享账号。下面逐层落地。
二、第一层:时效签名 URL,让地址"过期作废"
核心思路是:播放地址不直接暴露真实文件路径,而是由服务端根据"资源ID + 用户ID + 过期时间 + 密钥"算出一个签名,拼在 URL 上,CDN 或回源网关校验签名合法且未过期才放行。
2.1 服务端签发签名地址
const crypto = require('crypto');
const SECRET = process.env.MEDIA_SIGN_SECRET;
// 生成有时效的播放地址,ttlSeconds 为有效期
function signPlayUrl(videoId, userId, ttlSeconds = 7200) {
const expire = Math.floor(Date.now() / 1000) + ttlSeconds;
// 参与签名的明文:资源-用户-过期时间,缺一不可
const raw = `${
videoId}/${
userId}/${
expire}`;
const sign = crypto.createHmac('sha256', SECRET).update(raw).digest('hex');
const params = new URLSearchParams({
vid: videoId, uid: userId, exp: String(expire), s: sign,
});
return `https://media.example.com/play/playlist.m3u8?${
params}`;
}
2.2 网关/边缘校验签名
校验端(回源服务或边缘函数)用同样的密钥重算签名比对,并检查过期时间:
function verifyPlayUrl(query) {
const {
vid, uid, exp, s } = query;
if (!vid || !uid || !exp || !s) return false;
if (Number(exp) < Math.floor(Date.now() / 1000)) return false; // 已过期
const expect = crypto.createHmac('sha256', SECRET)
.update(`${
vid}/${
uid}/${
exp}`).digest('hex');
// 用恒定时间比较,防计时攻击
return crypto.timingSafeEqual(Buffer.from(expect), Buffer.from(s));
}
签名密钥绝不能下发到前端,只在服务端和 CDN 回源之间持有。HLS 切片(.ts)也要用同样机制签名,否则只保护 m3u8、切片地址裸奔等于没做。
三、第二层:Referer/Origin 校验拦外站盗链
签名解决"地址时效",Referer 校验解决"外站嵌入"。这层最好直接在 CDN 控制台或边缘脚本做,请求到不了源站就被拦掉,省回源带宽。
以 Nginx 边缘校验为例:
location ~* \.(m3u8|ts|mp4)$ {
# 空 Referer 允许(浏览器直接访问/部分App无Referer),但要叠加签名校验
valid_referers none blocked server_names
*.example.com www.example.com app.example.com;
if ($invalid_referer) {
return 403; # 外站盗链直接拒绝
}
# 内部再交给签名校验
error_page 403 = @forbidden;
}
需要注意 Referer 是可以被伪造的,所以它只能作为"防君子"的第一道,必须和签名 URL 叠加,不能单独依赖。对 App 播放,可以用自定义请求头(如 X-Play-Token)替代 Referer,比 Referer 更可控。
四、第三层:播放令牌轮换,让凭证无法长期复用
固定有效期 2 小时的播放地址,仍可能在这 2 小时内被转发。更稳的做法是"短时令牌 + 定时轮换":
- 进入课程页时,用登录态换取一个短时播放令牌(如 10 分钟),只够起播;
- 播放器在令牌快过期前,凭登录态向后端换新令牌(续期接口);
- 后端续期时校验该用户登录态仍有效、未在异常设备上播放;
- 一旦发现账号在多 IP/多设备并发,停止续期,正在播的令牌到期后自然失效。
import time, hmac, hashlib, secrets
def issue_play_token(user_id, video_id, ttl=600):
nonce = secrets.token_hex(8)
exp = int(time.time()) + ttl
body = f"{user_id}:{video_id}:{exp}:{nonce}"
sig = hmac.new(SECRET.encode(), body.encode(), hashlib.sha256).hexdigest()
return {
"token": f"{body}:{sig}", "exp": exp, "renew_in": ttl - 60}
def renew_token(user_id, video_id, device_id):
# 续期前做并发/设备风控
active = count_active_devices(user_id)
if active > 2: # 超过2台设备同时在播,判定共享账号
raise PermissionError("multi-device conflict, stop renew")
return issue_play_token(user_id, video_id)
这样即使一段播放地址被转发出去,10 分钟后没有合法登录态续期就自动失效,盗用窗口被压到最短。
五、第四层:异常行为风控兜底
签名和令牌解决技术盗用,账号共享还要靠行为识别。可采集的信号包括:同一账号同时在线设备数、短时间内 IP 归属地跳变、播放进度异常(多端进度互相覆盖)、单账号日播放时长超过物理上限。命中规则后从轻到重处置:先提示"设备数超限",再强制重新登录,严重的冻结播放权限并通知客服。
需要强调的是,防护强度要和内容价值匹配:低价引流课可以适度宽松、减少摩擦,高客单价的正价课、独家专栏再上短时令牌和严格的设备风控。一刀切地对所有课程都用最严策略,只会让正常学员频繁登录、播放体验下降,反而得不偿失。好的防盗体系应当让正常用户几乎无感,只把成本和障碍留给真正想盗用的人。
六、五个真实踩坑清单
- 只签 m3u8 不签 ts 切片:播放列表加密了,但切片地址是明文,别人照样能拼出全部视频。HLS 必须对每个分片同样签名,或用带签名的动态密钥。
- 签名密钥泄漏到前端:把密钥写进 JS 或 App,等于把保险柜钥匙挂在门上。签名只在服务端签发,前端永远只拿到结果地址。
- 有效期设太长:图省事给 7 天有效的地址,盗用窗口极大。起播凭证要短(分钟级),靠续期维持正常观看,被盗时只需停止续期。
- 只靠 Referer 防盗链:Referer 可伪造、爬虫可不带,单独用形同虚设,必须和签名、令牌叠加成多层。
- 风控一刀切误伤正常用户:网络切换、家庭多设备都可能触发多设备规则。要设宽限和申诉,先软提示再硬处置,别让付费学员正常学习被打断。
结语
课程视频防盗不是靠某一个银弹,而是一套让"盗用成本远高于付费成本"的分层体系:时效签名让地址过期作废、Referer 与自定义头在边缘拦盗链、短时令牌加续期把盗用窗口压到分钟级、行为风控兜底识别共享账号。对中小教育产品而言,这套方案不需要上重型 DRM,就能挡住绝大多数扒链外传。下一步建议先用只读方式采集一周的播放日志,量化外站盗链和多设备共享的真实比例,再据此决定每一层的严格程度,避免过度防护伤害正常学员的播放体验。