作者:Vincent Young
在本文中,我们讲解如何使用 JSON 网络令牌 JWT 来保护在线文档免受未经授权的访问,从而可以更安全的把在线文档编辑器开发集成进您自己的网络应用中去。
这里将集成开源的办公套件 ONLYOFFICE Docs:
- 文档、表格、幻灯片、表单模板编辑功能
- 与微软 Office 文件格式(docx、xlsx、pptx)的高度集成
- 实时协作
为文档、表格、幻灯片以及表单模板编辑功能,与微软 Office 高度兼容,其中第二张表格截图保留了浏览器窗口的标题栏,其它截图都为网页全屏 F11 模式下截取获得。
编辑器可以和几乎所有网页应用集成,编辑器本身默认不带有文档管理功能,所以这里为了展示如何集成,我们拿 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 依据一个密钥和一个
header.payload
的字符串计算一个哈希值。 - 令牌
header.payload.hash
生成 - 服务器 2 接收到这个令牌,依据它的前两部分生成哈希值。
- 服务器 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 实现的技术细节。