本文研究了使用非对称加密保证数据安全的技术,使用NodeJS作为服务,演示了用户注册和登录操作过程中的密码验证加密传输。注册/登录的中转流程大致如下:%%{init:{'theme':'forest'}}%%sequenceDiagramautonumberparticipantBasfront-endparticipantSasserverB->>+S:requestpublickeyS-->>-B:"P_KEY"B->>B:"E_PASS"B注意事项:使用"P_KEY"加密密码得到"E_PASS"B->>+S:请求注册/登录"用户名,E_PASS"S->>S:Registration/VerificationLoginS:用私钥解密"E_PASS"得到原始密码,进行注册或登录验证S-->>-B:Registration/Login结果切换开发环境,前后端均使用JavaScript开发。采用前后端分离模式,但不介绍构建过程,避免项目分离。这样就可以在VSCode中将前后端的内容组织在同一个目录下,不用担心发布位置。具体技术选择如下:服务器环境:Node15+(14应该也可以)。使用这么高的版本主要是为了使用更新的JS语法和特性,比如“nullcoalescingoperator(??)”。Web框架:Koa及其相关中间件-[@koa/router](https://www.npmjs.com/package/@koa/router),服务器路由支持-[koa-body](https://www.npmjs.com/package/@koa/router)npmjs.com/package/koa-body),解析POST传入数据-[koa-static-resolver](https://www.npmjs.com/package/koa-static-resolver),静态文件服务(HTML,JS),CSS等在前端)前端:为了简单起见,不用框架,需要自己写一些样式。使用了一些JS库,,,,-[JSEncrypt](http://travistidwell.com/jsencrypt/),RSA加密-[jQuery](https://jquery.com/),DOM操作和Ajax。jQueryAjax就够了,不需要Axios。-模块化JavaScript,需要高版本浏览器(Chrome80+)支持,避免前端构建。VSCode插件-[EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig),规范代码风格(小事勿做)。-[ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint),代码静态检查和修复工具。-[EasyLESS](https://marketplace.visualstudio.com/items?itemName=mrcrowl.easy-less),自动翻译LESS(前端部分未搭建,需要工具进行简单编译)。其他NPM模块,开发时使用,不影响运行,安装在devDependencies-@types/koa,提供koa语法提示(VSCode可以通过TypeScript语言服务为JS提供语法提示)-@types/koa__router,提供@koa/router语法提示-eslint,配合VSCodeESLint插件进行代码检查和修复2.初始化项目初始化项目目录mkdirsecuret-democdsecuret-demonpminit-y使用Git初始化,支持代码版本管理gitinit-bmain既然它据说是用main代替master,那么在初始化添加的时候指定分支名称为main。eslint--initeslint在初始化配置的时候会询问一些问题,根据项目目标和自己的习惯选择即可。3.项目目录结构SECURET-DEMO├──public//静态文件,通过koa-static-resolver直接发送给浏览器│├──index.html│├──js//前端业务逻辑脚本│├──css//样式表,Less和CSS都在里面│└──libs//第三方库,如JSEncrypt、jQuery等├──server//服务器业务逻辑│└──index.js//服务器应用入口├──(↓↓↓项目配置文件一般放在根目录↓↓↓)├──.editorconfig├──.eslintrc.js├──.gitignore├──package.json└──README.md4.修改一些配置主要是修改package.json默认支持ESM(ECMAScript模块),指定应用启动入口"type":"module","scripts":{"start":"node./server/index.js”},其他配置可参考源码,源码放在Gitee(码云),文末给出地址。关注关键的服务器端代码:阅读时不要忽略代码注释!加载/生成密钥对的逻辑是:尝试从数据文件加载,如果加载失败,则生成新的密钥对并保存,然后重新加载。文件放在.data目录下,公钥和私钥分别保存在PUBLIC_KEY和PRIVATE_KEY这两个文件中。生成密钥对的过程需要在逻辑上进行阻塞,是否使用异步函数无关紧要。但是保存的时候,可以异步和并发保存两个文件,所以定义generateKeys()为异步函数:importcryptofrom"crypto";importfsfrom"fs";importpathfrom"path";import{promisify}from"util";//fs.promises是Node提供的Promise风格的API//参考:https://nodejs.org/api/fs.html#fs_promises_apiconstfsPromise=fs.promises;//准备公钥和私钥文件路径constfilePathes={public:path.join(".data","PUBLIC-KEY"),private:path.join(".data","PRIVATE_KEY"),}//放Node回调样式异步函数成为Promise风格的回调函数constasyncGenerateKeyPair=promisify(crypto.generateKeyPair);asyncfunctiongenerateKeys(){const{publicKey,privateKey}=awaitasyncGenerateKeyPair("rsa",{modulusLength:1024,publicKeyEncoding:{type:"spki",格式:"pem",},privateKeyEncoding:{类型:"pkcs1",格式:"pem"}});//保证数据目录存在awaitfsPromise.mkdir(".data");//并发,异步保存公私钥awaitPromise.allSettled([fsPromise.writeFile(filePathes.public,publicKey),fsPromise.writeFile(filePathes.private,privateKey),]);}generateKey()根据加载密钥时的情况调用,不导出加载KEY的过程无论是公钥还是私钥都是一样的key,可以写一个公私函数getKey(),然后封装成两个可导出的函数:getPublicKey()和getPrivateKey()。/***@param{"public"|"private"}类型只能是“public”或“private”之一。*/asyncfunctiongetKey(type){constfilePath=filePathes[type];constgetter=async()=>{//这是一个异步操作,返回读取的内容,如果读取失败则返回undefinedtry{returnawaitfsPromise.readFile(filePath,"utf-8");}catch(err){console.error("[读取文件时出错]",err);返回;}};//尝试加载(读取)关键数据,如果加载成功,直接返回constkey=awaitgetter();如果(键){返回键;}//如果上一步加载失败,则生成新的密钥对并重新加载awaitgenerateKeys();returnawaitgetter();}exportasyncfunctiongetPublicKey(){returngetKey("public");}exportasyncfunctiongetPrivateKey(){returngetKey("private");}getKey()只能是“public”或“私人的”。因为是内部调用,不需要做参数校验,自己调用的时候小心点就行了。在小demo中这样处理是没有问题的。在正式应用中,最好找一套断言库来使用。并且对于内部接口,最好将开发环境和生产环境的断言分开:在开发环境做断言输出,生产环境直接忽略断言,提高效率。写相关技术。获取公钥的API:GET/public-key上面已经完成了获取密钥的过程,所以这部分没有技术含量,只需要在router中注册一个路由,输出公钥导入KoaRouter即可"@koa/router";constrouter=newKoaRouter();router.get("/public-key",async(ctx,next)=>{ctx.body={key:awaitgetPublicKey()};returnnext();});//注册其他路由//......app.use(router.routes());app.use(router.allowedMethods());API注册用户:POST/user注册用户需要接收加密后的密码,解密后与用户名结合形成用户信息保存。此API需要在路由器中注册一条新路由:asyncfunctionregister(ctx,next){...}router.post("/user",register);在register()函数中,我们需要获取POSTPayload用户名和加密后的密码,从密码中解密得到原始密码registration{username,originalPassword}解密过程在《技术预研》中已经提到section,可以打包到decrypt()函数中asyncfunctiondecrypt(data){constkey=awaitgetPrivateKey();returncrypto.privateDecrypt({key,padding:crypto.constants.RSA_PKCS1_PADDING},Buffer.from(data,"base64"),).toString("utf8");}注册过程:importcryptofrom"crypto";//使用内存对象保存所有用户//将cache.users初始化为空数组,可以保存使用constcache={users:[]}时的可用性判断;asyncfunctionregister(ctx,next){const{username,password}=ctx.request.body;if(cache.users.find(u=>u.username===username)){//TODO用户已经存在,通过ctx.body信息输出错误,结束当前业务returnnext();}constoriginalPassword=awaitdecrypt(密码);//拿到originalPassword后不能直接保存,先用HMAC加密//随机生成“salt”,就是用来加密密码的KEYconstsalt=crypto.randomBytes(32).toString(hex);//然后加密密码consthash=(hmac=>{//hamc传入时创建,使用sha256摘要算法,以salt为KEYhamc.update(password,"utf8");returnhmac.digest("十六进制");})(crypto.createHmac("sha256",盐,"十六进制"));//最后保存用户cache.users.push({username,salt,hash});ctx.body={成功:true};returnnext();}保存用户时,有几点需要注意Point:用户信息在Demo中保存在内存中,但在实际应用中应该保存在数据库或文件中(持久化)和原始密码将在使用后被丢弃。不能保存,以免拖拽数据库泄露用户密码。直接Hash原文拖库后可以通过彩虹表破解,所以采用HMAC引入随机密钥(salt)来防止这种破解方式。salt一定要保存,因为在登录验证时,还需要重新计算用户输入的密码的Hash,并与数据库中保存的Hash进行比较。上述过程没有充分考虑容错处理,需要在实际应用中考虑。例如,当输入的密码不是正确的加密数据时,descrypt()会抛出异常。还有一个细节,用户名通常是不区分大小写的,所以在正式应用中保存和查询用户时需要考虑这个因素。API登录:POST/user/login登录时,前端也将密码加密后传给后端,方法与注册时相同。后端先解密originalPassword,再验证。asyncfunctionlogin(ctx,next){const{用户名,密码}=ctx.request.body;//根据用户名查找用户,如果没有找到则直接登录失败constuser=cache.users.find(u=>u.username===username);if(!user){//TODO通过ctx.body输出失败数据returnnext();}constoriginalPassword=decrypt(密码);consthash=...//参考上面注册部分的代码//比较计算出的hash和保存的hash,如果一致说明输入的密码正确if(hash===user.hash){//TODO通过ctx.body输出登录成功的信息和数据}else{//TODO通过ctx.body输出登录失败的信息和数据}returnnext();}router.post("/user/login",登录);备注:这段代码中有很多ctx.body=...和returnnext(),是为了“叙事”而写的。(代码本身也是人类可以理解的语言?)但是为了减少意想不到的bug,应该结合逻辑优化,尽量只有一个ctx.body=...和returnnext()。Gitee上的demo代码已经优化,请在文末找到下载地址。前端应用的关键技术前端代码的关键部分是使用JSEncrypt对用户输入的密码进行加密。示例代码已在《技术预研》中提供。在index.html中使用模块类型的脚本,通过常规方式引入JSEncrypt和jQuery,然后将业务代码js/index.js导入为模块类型,这样index.js和它所引用的所有模块都可以写成ESM的形式,无需打包。比如index.js只绑定事件,所有的业务处理函数都是从其他源文件导入的:import{register,...}from"./users.js";$("#register")。on("click",register);......users.js实际上只包含import/export语句,有效代码写在reg.js、login.js等文件中:export*from"./users/list.js";export*from"./users/reg.js";export*from"./users/login.js";export{randomUser}from"./users/util.js";so,使用HTML中的ESM模块化脚本,只要在