如何使用JSON Web令牌(JWT)保护您的文档
在本文中,我们讲解如何使用JSON网络令牌JWT来保护在线文档免受未经授权的访问,从而可以更安全的把在线文档编辑器开发集成进您自己的网络应用中去。
这里将集成开源的办公套件ONLYOFFICE Docs:
- 文档、表格、幻灯片、表单模板编辑功能
- 与微软Office文件格式(docx、xlsx、pptx)的高度集成
- 实时协作
如下各图所示为文档、表格、幻灯片以及表单模板编辑功能,与微软Office高度兼容,其中第二张表格截图保留了浏览器窗口的标题栏,其它截图都为网页全屏F11模式下截取获得。
第一步:创建项目框架
假定已经安装好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.pugblock contentdiva(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"diveach val in filesdiva(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 statusesbreak;}} 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实现的技术细节。
R5cCI6IkpXVCJ9.eyJrZXkiOiJ2YWx1ZSJ9.ozm44FMRAlWXB0PhJg935wyOkp7wtj1jXvgEGIS0iig`
校验方法获得这个令牌,使用点作为分隔符拆解它,得到前两部分和后面的哈希值,然后对比自己生成的哈希值和接收到的哈希值,如果相匹配,这个负载被编码并返回JSON对象。
你也可以更深入研究令牌,学习他是如何创建的,并且寻找不同编程语言的开源库。
注意这里只是JWT的最小实现,这个标准内容很丰富,考虑了各种复杂情况,例如,令牌的有限生命周期。所以我们建议在真正实践中使用现成的JWT相关包。
我们希望这个例子能帮助你将ONLYOFFICE集成在你的网页应用中,使用JWT保护在线协同编辑功能,更多的集成示例可以在github上查阅研究,也可以在ONLYOFFICE API documentation上查找更多关于JWT实现的技术细节。