如何使用 JSON Web 令牌(JWT)保护您的文档

作者:Vincent Young

在本文中,我们讲解如何使用 JSON 网络令牌 JWT 来保护在线文档免受未经授权的访问,从而可以更安全的把在线文档编辑器开发集成进您自己的网络应用中去。


这里将集成开源的办公套件 ONLYOFFICE Docs:

  • 文档、表格、幻灯片、表单模板编辑功能
  • 与微软 Office 文件格式(docx、xlsx、pptx)的高度集成
  • 实时协作

为文档、表格、幻灯片以及表单模板编辑功能,与微软 Office 高度兼容,其中第二张表格截图保留了浏览器窗口的标题栏,其它截图都为网页全屏 F11 模式下截取获得。

ONLYOFFICE文档编辑器
ONLYOFFICE电子表格编辑器
ONLYOFFICE演示文档编辑器
ONLYOFFICE表单编辑其

编辑器可以和几乎所有网页应用集成,编辑器本身默认不带有文档管理功能,所以这里为了展示如何集成,我们拿 ONLYOFFICE Docs 与 Nod.js 集成开发(步骤 1 到 3)示例,然后加以保护免受未授权的访问(步骤 4)。

第一步:创建项目框架

假定已经安装好 Node.js,关于如何安装可参考这里

为工程创建一个文件夹,打开运行下述命令:

npm init

将提示我们设置包名、版本号、license 等信息,也可以直接跳过,用这些信息可以创建 package.json。

然后安装 express

npm install express --save

这里需要 npm--save 参数,在 package.json 文件中指定项目依赖于 express 包。
创建如下文件:

  • index.jx 启动并配置 express 服务器
  • app/app.js 查询处理逻辑
  • app/config.json 可变参数,例如端口号、编辑器地址等(这里使用一个 json 文件,但在真实项目中最好使用更可靠的方式)

index.js 必须包含如下代码:

const express = require('express');
const cfg = require('./app/config.json');
 
const app = express();
 
app.use(express.static("public"));
 
app.listen(cfg.port, () => {
    console.log(`Server is listening on ${cfg.port}`);
});

config.js 文件必须包含文档编辑器的端口号:

{"port": 8080}

创建公共文件夹,添加文件 index.html,向 package.json 文件加入如下行:

"scripts": {
"start": "node index.js"
}

如下命令启动运行 app:

npm start

打开浏览器测试 http://localhost:8080

第二步:打开文档

集成 ONLYOFFICE 的编辑器,需要安装 ONLYOFFICE Document Server 文档服务器。最简单的方式是使用 Docker 安装,仅需一行命令即可:

docker run -i -t -d -p 9090:80 onlyoffice/documentserver

文档服务器必须能够向这个服务器发送 http 请求,并且能接收处理服务器返回的请求。

config.json 添加编辑器(文档服务器)和示例 app 的地址,类似如下:

"editors_root": "http://192.168.0.152:9090/",
"example_root": "http://192.168.0.152:8080/"

在这个阶段,应该向 app/fileManager.js 中添加文件处理的功能(获取文件、列表、文件名、扩展名等):

const fs = require('fs');
const path = require('path');
 
const folder = path.join(__dirname, "..", "public");
const emptyDocs = path.join(folder, "emptydocs");
 
function listFiles() {
    var files = fs.readdirSync(folder);
    var result = [];
    for (let i = 0; i < files.length; i++) {
        var stats = fs.lstatSync(path.join(folder, files[i]));
        if (!stats.isDirectory()) result.push(files[i])
    }
    return result;
}
 
function exists(fileName) {
    return fs.existsSync(path.join(folder, fileName));
}
 
function getDocType(fileName) {
    var ext = getFileExtension(fileName);
    if (".doc.docx.docm.dot.dotx.dotm.odt.fodt.ott.rtf.txt.html.htm.mht.pdf.djvu.fb2.epub.xps".indexOf(ext) != -1) return "text";
    if (".xls.xlsx.xlsm.xlt.xltx.xltm.ods.fods.ots.csv".indexOf(ext) != -1) return "spreadsheet";
    if (".pps.ppsx.ppsm.ppt.pptx.pptm.pot.potx.potm.odp.fodp.otp".indexOf(ext) != -1) return "presentation";
    return null;
}
 
function isEditable(fileName) {
    var ext = getFileExtension(fileName);
    return ".docx.xlsx.pptx".indexOf(ext) != -1;
}
 
function createEmptyDoc(ext) {
    var fileName = "new." + ext;
    if (!fs.existsSync(path.join(emptyDocs, fileName))) return null;
    var destFileName = getCorrectName(fileName);
    fs.copyFileSync(path.join(emptyDocs, fileName), path.join(folder, destFileName));
    return destFileName;
}
 
function getCorrectName(fileName) {
    var baseName = getFileName(fileName, true);
    var ext = getFileExtension(fileName);
    var name = baseName + "." + ext;
    var index = 1;
 
    while (fs.existsSync(path.join(folder, name))) {
        name = baseName + " (" + index + ")." + ext;
        index++;
    }
 
    return name;
}
 
function getFileName(fileName, withoutExtension) {
    if (!fileName) return "";
 
    var parts = fileName.toLowerCase().split(path.sep);
    fileName = parts.pop();
 
    if (withoutExtension) {
        fileName = fileName.substring(0, fileName.lastIndexOf("."));
    }
 
    return fileName;
}
 
function getFileExtension(fileName) {
    if (!fileName) return null;
    var fileName = getFileName(fileName);
    var ext = fileName.toLowerCase().substring(fileName.lastIndexOf(".") + 1);
    return ext;
}
function getKey(fileName) {
    var stat = fs.statSync(path.join(folder, fileName));
    return new Buffer(fileName + stat.mtime.getTime()).toString("base64");
}
 
module.exports = {
    listFiles: listFiles,
    createEmptyDoc: createEmptyDoc,
    exists: exists,
    getDocType: getDocType,
    getFileExtension: getFileExtension,
    getKey: getKey,
    isEditable: isEditable
}

添加 pug 包:

npm install pug --save

既然已经安装 pug 模板引擎,就可以删除 index.html 了。创建一个查阅文件夹,在 index.js 中添加下面代码连接引擎:

app.set("view engine", "pug");

然后就可以创建 views/index.pug,添加创建文档、打开文档的按钮:

extends master.pug 
block content
  div
    a(href="editors?new=docx", target="_blank")
      button= "Create DOCX"
    a(href="editors?new=xlsx", target="_blank")
      button= "Create XLSX"
    a(href="editors?new=pptx", target="_blank")
      button= "Create PPTX"
  div
    each val in files
      div
      a(href="editors?filename=" + val, target="_blank")= val

逻辑将在 app/app.js 中讲解:创建一个文件(或者检查是否已经存在),然后格式化编辑器的配置,可以阅读这里查看细节,然后返回页面模板:

const fm = require('./fileManager');
const cfg = require('./config.json');
 
function index(req, res) {
    res.render('index', { title: "Index", files: fm.listFiles() });
}
 
function editors(req, res) {
    var fileName = "";
 
    if (req.query.new) {
        var ext = req.query.new;
        fileName = fm.createEmptyDoc(ext);
    } else if (req.query.filename) {
        fileName = req.query.filename;
    }
 
    if (!fileName || !fm.exists(fileName)) {
        res.write("can't open/create file");
        res.end();
        return;
    }
 
    res.render('editors', { title: fileName, api: cfg.editors_root, cfg: JSON.stringify(getEditorConfig(req, fileName)) });
}
 
function getEditorConfig(req, fileName) {
    var canEdit = fm.isEditable(fileName);
    return {
        width: "100%",
        height: "100%",
        type: "desktop",
        documentType: fm.getDocType(fileName),
        document: {
            title: fileName,
            url: cfg.example_root + fileName,
            fileType: fm.getFileExtension(fileName),
            key: fm.getKey(fileName),
            permissions: {
                download: true,
                edit: canEdit
            }
        },
        editorConfig: {
            mode: canEdit ? "edit" : "view",
            lang: "en"
        }
    }
}
 
module.exports = {
    index: index,
    editors: editors
};

在这里,加载编辑器脚本 http://docserver/web-apps/apps/api/documents/api.js 然后添加编辑器的实例 new DocsAPI.DocEditor("iframeEditor", !{cfg})

现在运行 app 测试一下。

第三步:编辑文档

编辑文档,更准确的说,是保存您的修改。这需要处理从文档服务器发来的修改保存请求,在配置文件中指定如何响应这个请求,关于文档服务器的请求可以参考这里

文档服务器发送带有 JSON 内容的 POST 请求,这就是为什么我们需要连接到中间件来从 JSON 解析到 index.js

app.use(express.json());

为了第一时间接收它,应告诉文档服务器如何处理,向编辑器的配置文件中添加 callbackUrl: cfg.example_root + "callback?filename=" + fileName

然后创建一个回调函数,从文档服务器获取信息,检查请求状态:

function callback(req, res) {
    try {
        var fileName = req.query.filename;

        !checkJwtToken(req);
        var status = req.body.status;

        switch (status) {
            case 2:
            case 3:
                fm.downloadSave(req.body.url, fileName);
                break;
            default:
                // to-do: process other statuses
                break;
        }
    } catch (e) {
        res.status(500);
        res.write(JSON.stringify({ error: 1, message: e.message }));
        res.end();
        return;
    }
    res.write(JSON.stringify({ error: 0 }));
    res.end();
}

在这个例子里,只关注文档保存的请求处理,一旦接收到保存文件请求,我们将从 POST 数据中获取指向我们文档的链接并将其保存到我们的文件系统中:

functiondownloadSave(downloadFrom, saveAs) {
    http.get(downloadFrom, (res) => {
        if (res.statusCode==200) {
            varfile=fs.createWriteStream(path.join(folder, saveAs));
            res.pipe(file);
            file.on('finish', function() {
                file.close();
            });
       }
    });
}

现在我们就有了一个具备文档编辑功能的网页应用了,接下来使用 JWT 来保护它免受未授权的访问。

第四步:实施 JWT

ONLYOFFICE 使用 JSON 网络令牌保护在编辑器、内部服务以及存储空间之间的数据交换。它请求一个加密的签名,然后托管在令牌中。 此令牌校验对数据执行特定操作的权限。

如果打算使用 JWT 最好使用准备好的包,但是在这里为了理解工作原理将完全手动实现。

入门理论基础

JWT 包含三部分:

  • 头:包含元信息,例如,一个加密算法
  • 负载:数据内容
  • hash 哈希:基于上面两部分和密码的哈希值

所有这三部分是 JSON 对象,然而 JSON 令牌本身是由点符号 (.) 所连接的所有部分的 base64URL 编码。

工作原理:

  1. 服务器 1 依据一个密钥和一个 header.payload 的字符串计算一个哈希值。
  2. 令牌 header.payload.hash 生成
  3. 服务器 2 接收到这个令牌,依据它的前两部分生成哈希值。
  4. 服务器 2 比较生成的令牌和接收到的令牌,如果匹配,那么就说明数据没有被修改

现在为这个集成实例实现 JWT 令牌

编辑器允许在请求包头和正文中传输 JWT 令牌,使用请求包正文部分比较好,因为数据包头空间有限,但是这里将考虑所有情况。

如果选择包头传输令牌,需要使用负载 key 密钥来将数据加入对象中。

如果选择包正文传输令牌,负载类似如下:

{
"key": "value"
}

使用包头传输令牌:

{
"payload": {
"key": "value"
   }
}

config.json 添加 key 密钥:

"jwt_secret": "supersecretkey"

开启 JWT 启动编辑器还需要设定环境变量:

docker run -i -t -d -p 9090:80 -e JWT_ENABLED=true -e JWT_SECRET=supersecretkey onlyoffice/documentserver

如果使用包正文传输令牌,还需添加一个变量 -e JWT_IN_BODY=true

docker run -i -t -d -p 9090:80 -e JWT_ENABLED=true -e JWT_SECRET=supersecretkey -e JWT_IN_BODY=true onlyoffice/documentserver

app/jwtManager.js 包含 JWT 的所有逻辑,只需要在打开编辑器的时候向配置添加令牌:

if (jwt.isEnabled()) {
editorConfig.token=jwt.create(editorConfig);
}

令牌本身有上面理论解释的算法来计算生成,代码如下:

function create(payloadObj) {
    if (!isEnabled()) return null;

    var headerObj = {
        alg: "HS256",
        typ: "JWT"
    };

    header = b64Encode(headerObj);
    payload = b64Encode(payloadObj);
    hash = calculateHash(header, payload);

    return header + "." + payload + "." + hash;
}

function calculateHash(header, payload) {
    return b64UrlEncode(crypto.createHmac("sha256", cfg.jwt_secret).update(header + "." + payload)
        .digest("base64"));
}

这样就打开了一个文档,但也要检查一下从文档服务器接收到的令牌。

要检查包正文和包头,函数很简单,如果有问题它就会抛出错误,否则,确认了令牌后将合并包正文和令牌负载:

function checkJwtToken(req) {
    if (!jwt.isEnabled()) return;
    var token = req.body.token;
    var inBody = true;

    if (!token && req.headers.authorization) {
        token = req.headers.authorization.substr("Bearer ".length);
        inBody = false;
    }

    if (!token) throw new Error("Expected JWT token");

    var payload = jwt.verify(token);

    if (!payload) throw new Error("JWT token validation failed");

    if (inBody) {
        Object.assign(req.body, payload);
    } else {
        Object.assign(req.body, payload.payload);
    }
}

校验函数也很简单:

function verify(token) {
    if (!isEnabled()) return null;
    if (!token) return null;

    var parts = token.split(".");
    if (parts.length != 3) {
        return null;
    }

    var hash = calculateHash(parts[0], parts[1]);
    if (hash !== parts[2]) return null;
    return b64Decode(parts[1]);
}

看一下在 jwtManager 中的方法:

创建方法获取一个带有数据的对象,例如:

{
"key": "value"
}

创建 JWT 头:

{
"alg": "HS256",
"typ": "JWT"
}

然后这个方法使用这两个对象,创建 JSON 字符串,编码为 base64url。然后用点连接这两行,基于你的 key 密钥生成一个 hash 哈希值,在这个例子里我们使用超级密钥 supersecretkey

作为结果我们得到如下令牌:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ2YWx1ZSJ9.ozm44FMRAlWXB0PhJg935wyOkp7wtj1jXvgEGIS0iig

校验方法获得这个令牌,使用点作为分隔符拆解它,得到前两部分和后面的哈希值,然后对比自己生成的哈希值和接收到的哈希值,如果相匹配,这个负载被编码并返回 JSON 对象。

你也可以更深入研究令牌,学习他是如何创建的,并且寻找不同编程语言的开源库

注意这里只是 JWT 的最小实现,这个标准内容很丰富,考虑了各种复杂情况,例如,令牌的有限生命周期。所以我们建议在真正实践中使用现成的 JWT 相关包。

我们希望这个例子能帮助你将 ONLYOFFICE 集成在你的网页应用中,使用 JWT 保护在线协同编辑功能,更多的集成示例可以在 github 上查阅研究,也可以在 ONLYOFFICE API documentation 上查找更多关于 JWT 实现的技术细节。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,172评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,346评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,788评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,299评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,409评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,467评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,476评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,262评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,699评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,994评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,167评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,827评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,499评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,149评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,387评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,028评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,055评论 2 352