1. 引言

上一期在《Node 博客开发之CURD》中,本人使用 express 框架对服务端开发的基础知识进行了介绍。但在这个处处都是大坏蛋的时代,我们要时刻注意自己的安全。我们也要好好爱后端老铁们,他们不仅仅只是 curd,为了保护大家的安全,他们也有做不少努力。

本文将从服务端的视角来看看,服务端是怎么做安全措施,建议功能实现部分结合源码进行阅读,主要包括:

  • 登录功能
  • 注册加密
  • Sql 注入与 xss 攻击

在开发实践中,本人分别使用原生 nodejs 和 express 框架都进行了一次开发

  1. 点击这里,可查看原生 nodejs 版本代码
  2. 点击这里,可查看 express 版本代码

可演示的页面部署版本,将在后续补上

2. 登录功能

(滴,好人卡,恭喜您通过好人验证) o(╥﹏╥)o

登录功能就像是身份校验一样。成功完成登录后即可对用户的信息进行查看甚至使用用户的权限做各种操作,并且在现在这个时代,登录账号的用户名往往是用户的手机号或者是个人邮箱。因此在登录功能上,需要做好相应的安全措施

本节主要将围绕 登录校验登录信息存储 两个问题,谈谈服务端是如何解决这些问题的,我将会以渐进式的方式来介绍各项技术的使用和必要性,即 Cookie => Session => Redis

2.1 Cookie

关于登录功能的解决方案,首先要谈到的便是 cookie ,相信前端开发人员对 cookie 不会陌生

2.1.1 Cookie 简介

Cookie 是存储在浏览器的一段字符串(最大5kb),它有以下几个特点

  • 跨域不共享,每个域名独立
  • 格式如 k1 = v1; k2 = v2; k3 = v3; 因此可以存储结构化数据
  • 每次发送 http 请求,会将请求域的 cookie 一起发送给 server
  • Server 端可以修改 cookie 并返回给浏览器
  • 浏览器也可以通过 js 修改 cookie (有限制)

2.1.2 浏览器中如何查看 cookie,Javascript 如何操作 cookie

在浏览器中,有以下三种方式可以查看 cookie

  • Chrome devtool 中的 Network
  • Chrome devtool 中的 Application
  • 使用 document.cookie 进行查看

修改方式(并非替换,为追加方式)

  1. document.cookie = 'key=value'

2.1.3 Server 端如何操作 cookie,来实现登录验证(仅使用 cookie)

大致分为以下三个步骤:

  1. 首先用户登录时,在 Server 端对 cookie 进行设置, cookie 中需要设置以下几个字段:
  • username: 用户名,用于登录验证
  • path: Cookie 生效地址路径
  • httpOnly: 不允许客户端对 cookie 进行修改
  • expires: Cookie 有效期
  1. router.post('/login', function(req, res, next) {
  2. const { username, password } = req.body
  3. // 设置 cookie
  4. res.setHeader('Set-Cookie', `username=${username}; path=/; httpOnly; expires=${getCookieExpires()}`)
  5. ...
  6. });
  1. 接着是后续调用接口后 Server 端解析 cookie
  1. // 解析cookie
  2. req.cookie = {}
  3. const cookieStr = req.headers.cookie || ''
  4. cookieStr.split(';').forEach(item => {
  5. if (!item) {
  6. return
  7. }
  8. const arr = item.split('=')
  9. const key = arr[0].trim()
  10. const val = arr[1].trim()
  11. req.cookie[key] = val
  12. })
  1. 接着对登录状态进行判断,这里以新建博客为例,来说明如果具体做登录验证
  1. // 统一的登录验证函数
  2. const loginCheck = (req) => {
  3. if (!req.cookie.username) {
  4. return Promise.resolve(
  5. new ErrorModel('用户尚未登录')
  6. )
  7. }
  8. }
  9. // 新建一篇博客
  10. router.post('/new', function(req, res, next) {
  11. // 登录验证
  12. const loginCheckResult = loginCheck(req)
  13. if (loginCheckResult) {
  14. return loginCheckResult
  15. }
  16. ...
  17. })

2.1.4 本节总结

Cookie 能够做到登录校验的功能,但登录信息的存储方式不安全,比如能直接看到用户名等。这就需要另一种数据存储的方式,即后文中说到的 Session 和 Redis

2.2 Session

本节将使用另一种方式,即使用 Session 的方式来解决用户名暴露的问题。

2.2.1 Session 简介

Session 是另一种记录客户状态的机制,不同的是 Cookie 保存在客户端浏览器中,而 Session 保存在服务器上。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上。

例如:将原来 username=malie 的记录,换成userId="1585637382713_0.31550642163928444",同时在服务端使用如下 Session 的方式对用户名进行存储。如此,即可实现在浏览器仅可看到 userId ,不会暴露用户的信息

  1. const SESSION_DATA = {
  2. "1585637382713_0.31550642163928444": "malie"
  3. }

2.2.2 Server 端如何使用 Session 来实现登录验证(未使用 Redis)

与 Cookie 方式相同,分为以下三个步骤:

  1. 首先用户登录时,在 Server 端对 Session 进行设置
  1. router.post('/login', function(req, res, next) {
  2. const { username, password } = req.body
  3. // 解析session
  4. req.session.username = data.username
  5. req.session.realname = data.realname
  6. ...
  7. });
  1. 重点是第二步,解析 session 方式与 cookie 的方式有些区别,这里使用一个全局参数 SESSION_DATA 进行 session 数据存储,具体实现逻辑如下:
  1. // session 数据
  2. const SESSION_DATA = {}
  3. // 解析 session
  4. let needSetCookie = false // 是否需要设置 cookie
  5. let userId = req.cookie.userid
  6. if (userId) {
  7. if (!SESSION_DATA[userId]) {
  8. SESSION_DATA[userId] = {}
  9. }
  10. } else {
  11. needSetCookie = true
  12. userId = `${Date.now()}_${Math.random()}`
  13. SESSION_DATA[userId] = {}
  14. }
  15. req.session = SESSION_DATA[userId]
  16. ......
  17. // 设置 cookie
  18. // 处理路由时进行 cookie 设置,使用方式同 cookie
  19. if (needSetCookie) {
  20. res.setHeader('Set-Cookie', `userId=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`)
  21. }
  1. 登录验证逻辑与 cookie 相同,把 req.cookie 修改为 req.session 即可
  1. // 统一的登录验证函数
  2. const loginCheck = (req) => {
  3. if (!req.session.username) {
  4. return Promise.resolve(
  5. new ErrorModel('用户尚未登录')
  6. )
  7. }
  8. }
  9. // 新建一篇博客
  10. router.post('/new', function(req, res, next) {
  11. // 登录验证
  12. const loginCheckResult = loginCheck(req)
  13. if (loginCheckResult) {
  14. return loginCheckResult
  15. }
  16. ...
  17. })

2.2.3 本节总结

目前 session 直接是 js 变量,放在 nodejs 进程内存中,其存在以下两个问题:

  1. 进程内存有限,访问量过大,内存暴增时难以处理
  2. 正式线上运行是多线程,进程之间内存无法共享

这时候就是英雄出场的时刻 —— Redis 闪亮登场

2.3 Redis

仅仅在进程内存中使用 session 还不足以满足使用场景,这时候需要使用上我们最伟大的缓存数据库 —— Redis

2.3.1 Redis 简介

Redis 是 web server 最常用的缓存数据库,数据存放在内存中。相比于 mysql (硬盘数据库),访问速度快很多。并且能将 web server 和 redis 拆分为两个单独的服务。双方各自独立,并且都是可扩展的(例如都扩展成集群),可以解决 session 存在的问题。缺点是其成本更高,可存储的数据量更小

2.3.2 Redis 适用场景

Redis 更加适合在 session 上使用,而不适合在网站数据上使用,主要有以下原因:

  1. session 适合 redis 原因:
  • session 访问频繁,对性能要求极高
  • session 可不考虑断电丢失数据的问题
  • session 数据量不会太大
  1. 网站数据不适合用 redis 原因:
  • 操作频率不是太高
  • 断电不能丢失、必须保留
  • 数据量太大、内存成本太高

2.3.3 Server 端如何使用 Session 来实现登录验证(使用上 Redis)

对于 redis 的安装与配置同 mysql 相似,配置方式大家可参照 mysql ,这里我也在 /src/db/redis.js 文件中封装了 redis 数据的 get 和 set 方法,详细可查看源码

与 Session 方式相同,分为以下三个步骤:

  1. 首先用户登录时,在 Server 端对 Session 进行设置
  1. router.post('/login', function(req, res, next) {
  2. const { username, password } = req.body
  3. // 解析session
  4. req.session.username = data.username
  5. req.session.realname = data.realname
  6. // 同步到 redis
  7. set(req.sessionId, req.session)
  8. ...
  9. });
  1. 重点也是第二步,这里使用了 redis,将用户信息以 key/value 的方式保存在了 redis 中,具体实现逻辑如下:
  1. // 解析 session
  2. // 使用 redis
  3. let needSetCookie = false
  4. let userId = req.cookie.userid
  5. if (!userId) {
  6. needSetCookie = true
  7. userId = `${Date.now()}_${Math.random()}`
  8. // 初始化 redis 中的 session 值
  9. set(userId, {})
  10. }
  11. // 获取 session
  12. req.sessionId = userId
  13. get(req.sessionId).then(sessionData => {
  14. if (sessionData == null) {
  15. // 初始化 redis 中的 session 值
  16. set(req.sessionId, {})
  17. // 设置 session
  18. req.session = {}
  19. } else {
  20. req.session = sessionData
  21. }
  22. })
  23. ......
  24. // 设置 cookie
  25. // 处理路由时进行 cookie 设置,使用方式同 cookie
  26. if (needSetCookie) {
  27. res.setHeader('Set-Cookie', `userId=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`)
  28. }
  1. 登录验证逻辑与 session 相同

3. 注册加密

若密码未进行加密,让攻击者获取了用户的账号及明文密码后,存在的后续安全问题不必多说。首先需要封装一个加密的方法,这里本人使用的是 nodejs 自带的 crypto 插件

  1. // /src/utils/cryp.js
  2. const crypto = require('crypto')
  3. // 密匙 (防止解密,加大解密难度)
  4. const SECRET_KEY = 'Ml_123#'
  5. // md5 加密
  6. function md5(content) {
  7. let md5 = crypto.createHash('md5')
  8. return md5.update(content).digest('sex')
  9. }
  10. // 加密函数
  11. function genPassword(password) {
  12. const str = `password=${password}&key=${SECRET_KEY}`
  13. return md5(str)
  14. }
  15. module.exports = {
  16. genPassword
  17. }

接着在注册和登录时,对用户的密码进行一次包装加密即可:

  1. const { genPassword } = require('../utils/cryp')
  2. const login = (username, password) => {
  3. // 生成加密密码
  4. password = genPassword(password)
  5. const sql = `select username, realname from users where username = ${username} and password = ${password}`
  6. ......
  7. }
  8. module.exports = {
  9. login
  10. }

4. Sql 注入与 xss 攻击

4.1 Sql 注入

攻击方式: 输入一个 sql 片段,最终拼接成一段攻击代码

4.1.1 案例

这里我以登录为例进行说明:

假如数据库中用户名为 malie 的用户密码为 123456,那么以下代码是无法查出该用户的信息的(此时前端输入的用户名为 malie ,密码为 111111)

  1. select * from users where username='malie' and password='111111'

但是,若前端输入的用户名为 malie’— , 密码为 111111,注意小横线后有个空格,这时候的 sql 语句如下:

  1. select * from users where username='malie'-- ' and password='111111'

由于 ‘— ’会将后面的代码注释,因此,此时的代码等效于下面的效果,便能直接跳过密码的验证,实现拿到用户信息,成功登录

  1. select * from users where username='malie'

上面的例子,最后只能说是一个盗号的行为,假如用户输入的用户名是 malie’;delete from users— ,即:

  1. select * from users where username='malie';delete from users-- ' and password='111111'

此时 sql 甚至将整个 users 表给删除了,可见 sql 注入确实存在非常高的危险

4.1.2 预防措施

使用 mysql 的 escape 函数处理输入内容即可,再次以登录功能为例,见如下代码

  1. // src/db/mysql.js
  2. const mysql = require('mysql')
  3. ......
  4. module.exports = {
  5. exec,
  6. escape: mysql.escape
  7. }
  1. // src/controller/user.js
  2. const { exec, escape } = require('../db/mysql')
  3. const login = (username, password) => {
  4. username = escape(username)
  5. password = escape(password)
  6. const sql = `select username, realname from users where username = ${username} and password = ${password}`
  7. ...
  8. }

此时若再使用用户名: malie’— ,进行登录,sql 便被转义为以下代码,此时 反斜杠+单引号为转义单引号,malie’— 为整个字符串,便不会起到注释作用,起到了预防作用

  1. select username, realname from users where username = 'malie\'-- ' and password = '123456'

4.2 Xss 攻击

攻击方式:在页面展示内容中掺杂 js 代码,以获取网页信息

4.2.1 案例

假如用户在创建博客时,用以下 js 代码块作为博客的标题或内容,则在显示该博客信息时,即会执行里面的内容,显示了一个弹框,包含了用户的 cookie 信息,这样不仅让界面逻辑异常,更是暴露了用户的个人信息。并且任何 js 代码块都可以嵌入其中,这样就存在了不可预知的巨大安全隐患

  1. <script>alert(document.cookie)</script>

4.2.2 预防措施

预防的方式即为转换生成 js 的特殊字符,具体使用以下的转义规则:

  1. & => &amp;
  2. < => &lt;
  3. > => &gt;
  4. " => &quot;
  5. ' => &#x27;
  6. / => &#x2F;

在服务端可以安装 xss 插件来实现转义

  1. npm install xss --save

具体使用方式如下:

  1. const xss = require('xss')
  2. const newBlog = (blogData = {}) => {
  3. const title = xss(blogData.title)
  4. ......
  5. }

通过以上方式即可将 xss 攻击代码进行转义,来解决安全隐患

  1. <script>alert(document.cookie)</script>
  2. <!-- 转义后:&lt;script&gt;alert(document.cookie)&lt;/script&gt -->

5. 总结

本次在上一次使用 Node.js 进行了一个博客系统服务端基本功能开发的基础上,对服务端的安全相关问题进行了更进一步的学习实践总结。为了实现一个更加健壮的服务端而努力奋斗,我们义不容辞,盘它!!!

恳请大佬们批评指正,喵喵喵~

6. 其他

nginx 代理

反向代理:对客户端不可见
正向代理:客户端可控制的,比如在家中使用代理访问公司内网

  1. sudo vi /usr/local/etc/nginx/nginx.conf
  2. worker_processes 6;// 多线程数,一般可设置为cpu的内核数
  3. #location / {
  4. # root html;
  5. # index index.html index.htm;
  6. #}
  7. location / {
  8. proxy_pass http://localhost:8001;
  9. }
  10. location /api/ {
  11. proxy_pass http://localhost:8000;
  12. proxy_set_header Host $host;
  13. }