百步梯技术部 2017 级 Web 后端进阶

文章目录

写在前面

这篇文章是面向百步梯技术部 2017 级后端组的同学们的,结合目前大家的学习进度以及时间安排,并参考去年同期的我们接下来所需要的知识储备,整理出下面这些内容。鉴于大家现在也没有上手使用后端框架,写这篇文章的时候就完全没有考虑框架相关的内容了,比如配置等方面的东西如果是用框架的话这里就没有必要讲了。

文章的大部分都是关于后端安全的,并且由于主要偏向的是安全意识这块的内容,就没有给出太多实际的代码,只是希望大家能了解在后端的开发过程中需要注意哪些问题,在接下来的锻炼中我相信大家也会更深刻地理解重视这些问题的意义和作用。

虽然标题是“进阶”,但实际上也只是相对“入门”而言在难度方面有一小部分提高,距离真正的进阶其实还有挺长的路,不过不要太担心啦 2333,踏踏实实走下去才是坠重要的。以及,如果有什么讲得不太好的地方也欢迎大家提出。

参数验证

看到大家目前的后端代码都有做这一块的判断,无论是基本的非空非零判断还是基于正则表达式的格式判断都有考虑,挺不错的,虽然有些时候逻辑写得有点糟糕

那我想额外讲讲有关参数验证的必要性之类的问题,后端一般是介于前端和数据库之间的一层服务,实际上前端和数据库也都会对它们涉及到的参数进行验证,但是尽管如此,在后端逻辑里进行参数验证还是非常必要的,具体原因我就试着分这两块来讲。

前端与后端

这是参数验证的一个非常重要的原因,后端开发有个基本的准则:前端传来的任何数据都是不可信的

如果大家用过一些诸如 Postman 之类的后端调试工具的话,就应该知道前端的请求发送都是可以通过其他方式模拟的,换句话说就是,在请求的数据发送给后端之前的任何操作和验证实际上都可以绕过。所以前端对某个参数进行的检查并不能保证这个数据传给后台的时候一定符合要求。

那既然如此,前端的数据验证意义何在呢?可以设想一下,如果每次填写一张表单都要等到提交后才能知道哪些数据是不符合要求的,用户体验是不是就非常差了。而且这样也会导致服务端的压力增大,可能因为响应过慢而进一步降低了用户体验。

其实我们也能看到现在很多表单的用户体验做得非常棒,输入框验证数据都是实时的,毕竟有了万能的 JavaScript,绑定个 onchange 事件就可以了:在用户离开当前输入框时进行验证,不符合条件直接显示错误信息,然后在改对后自动去掉。我个人感觉这种实时提醒修改的逻辑要比最后一起验证批量提醒更能节省用户的时间,体验也好很多(想象一下点完提交发现一片红的极端情况= =),值得学习一个。

所以一般来说,前端进行参数验证是为了优化用户体验以及减轻服务端压力,而后端进行参数验证是为了安全,防止一些乱搞的数据被写入数据库

后端与数据库

在创建数据库的时候,我们可以对表里的字段增加一些约束条件,最简单的比如 NOT NULL 之类的,它们也可以看作是一种参数验证,因为不满足这些约束条件的数据会被数据库系统直接拒绝写入。

但是很显然,我们不能把所有对参数的验证都放在数据库这个服务层上,增大数据库压力是一部分原因,出错时相对后端验证而言不方便捕捉和返回错误信息等也是部分原因。

由于后端的验证并不能由用户直接绕过,所以一些相对不是那么必要的格式约束就可以只放在后端进行。当然设计数据库的时候还是要做好业务层面的格式约束的,这样能避免后端考虑不周导致的后果,也方便后期的维护。

总之,参数验证主要要考虑的就是从“从前端拿到数据之后”到“写入数据库之前”中间这个阶段需要做什么样的判断操作,尤其是“从前端拿到的任何数据都是不可信的”这一点,务必要作为思考逻辑流程时参照的基本准则。

当然如果为了验证写一堆的 if...else 可能会很烦,不过到这个程度的时候基本是在用框架了,很多框架都做了这方面的简化工作,像通过注解来直接限制传入参数的格式等,有兴趣的可以去了解一下或者尝试自己实现一个类似的东西

权限控制

很多时候一个项目并不是所有的页面都完全开放的,有些可能要求用户登录之后才能查看,再复杂一点的可能要求登录的用户有相应级别的权限才能访问一些特定路径下的页面或者执行一些敏感操作。

权限控制大部分是后端的工作,当然前端的判断也是很重要的:需要权限的入口不应该直接展示给无权限的用户(为了吸引用户注册那种这里就不考虑了),这个具体的实现可以前后端协商,比如前端通过一个后端接口获取当前用户的权限级别和允许的入口列表等然后对应这些数据构造和渲染页面之类的。

但是就像前面提到的,这个前端的限制可以很容易被绕过,而且主要还是为了用户体验而不是安全,所以后端在接收到请求之后必须判断用户有没有对应的权限,这里就稍微提一下一些常用的权限控制方法,实际上因为权限对应的是用户,很多时候主要工作就是验证用户身份。

Note: 接下来的内容部分涉及到有关编码、摘要和加密等方面的内容,为了避免叙述混乱我就尽量直接用“验证”之类的词来表达,只要理解它可以在保证安全的情况下做到验证内容是否符合要求即可,具体的内容可以自己去搜索相关的资料学习,顺便安利一下结城浩的《图解密码技术》(最后会提到这个)

Session

在开始之前,我想先简单讲讲有关 Session 的知识。我们知道,HTTP 本身是无状态的协议,每一个请求都是独立的,服务端不能知道连续接收的两个请求是不是来自同一个会话,于是后来随着时代的发展和需求的增加出现了 Cookie 和 Session 这样的机制。

Cookie 一般由服务端生成并保存在浏览器等客户端中,在后续的请求中通过附带这个 Cookie 值就可以告知服务端自己的身份。但是这个值很容易被客户端修改,甚至有时候可以伪造成其他用户进行操作。

后来就出现了 HttpOnly 等选项用于强化它的安全性,而且用于验证的 Cookie 值一般都会用摘要算法生成,修改了就直接相当于失效了。不过目前可能应该大概也许一般不会直接用 Cookie 进行敏感信息的储存和验证,而是使用 Session 机制。

Session 是存储在服务端的一种数据,用于记录和后续验证客户端的身份。那客户端如何向服务端证明自己的身份呢?一般我们使用 SessionID 的方式,这个 SessionID 和服务端存着的对应的 Session 值是相关联的,服务端会发给客户端,然后客户端把这个值存在 Cookie 中,在后续的请求中带上这个值,服务端进行验证即可知道当前请求对应着哪一个用户。

写了这些希望大家可以大致明白 Session 机制是什么,它本身可以认为和 Cookie 没有什么关系,只是为了保存这个 SessionID 我们一般都用 Cookie 来实现,其实 LocalStorage 和 Index DB 也可以的,只要客户端支持的保存方式能用来保存这个值就行了。

像我们去年这个时候在写一个弹幕的任务的时候,因为一些原因需要不通过 PHP 验证用户登录状态,当时就是在用户表里加了一个字段用来存 SessionID(PHP 里是 PHPSESSID),然后判断请求传输的那个 SessionID 和存在数据库里的值是不是一致来区分用户。

PHP 的 Session 用起来比较方便,只要一个 session_start(); 然后把 $_SESSION 当作普通数组来使用、赋值、取值就可以了,但是这个实现还是希望大家理解,作为(伪)开发者,只会用肯定是不行的。感兴趣的话可以在一些页面打开控制台看看,设置了 Session 的页面都有一个 Cookie 项是存着 SessionID 的,不想写代码验证的可以直接打开 W3Schools 的例子,在控制台的 Application->Cookies 项里面。

所以回到权限控制的内容,如果用户分级比较简单的话可以直接用 Session 机制来实现,也就是根据 Session 存储的用户信息判断他的权限,然后决定是返回错误提示还是继续执行。比较需要注意的是,权限验证一般是要在所有操作之前进行的,比如连接数据库、加载配置信息等。另外一点就是,对于这种方式实现的权限控制,每次请求都要重新验证一遍权限。

Token

这里的 Token 中文翻译为“令牌”,Session 虽然基本可以解决验证的问题了,但是它毕竟是存在服务端的数据,一旦用户量大了就可能对服务器产生压力,毕竟默认 Session 数据都是放在内存里的。

Token 机制就避免了大量数据的保存的问题,它的一般实现是服务端将客户端信息签名后发回给客户端,客户端保存这个信息,并作为后续的身份认证数据。服务端在收到这个数据后,通过自己的密钥解密、验证这个信息,并据此判断客户端身份。这种机制服务端只需要存一对密钥即可,相对 Session 来说就没有太大的数据占用的问题。

ACL 与 RBAC

权限和用户的关系稍微多一点,我们可能就会考虑把这样的对应关系写进数据库,避免由代码来直接进行权限定义和控制带来的维护困难。其实现在已经有很多成熟的模型了,像 ACL(Access Control List) 这种就是类似的想法,我就提一下它不往下讲了,因为我并没有用过

如果项目再复杂一些的话,直接定义用户和权限的对应关系也会变得很乱、很难维护。于是就有了 RBAC(Role Based Access Control) 这种模型,用户和角色关联,角色和权限关联,这样抽象出来之后我们的工作就清晰了很多,只要思考好项目需要哪些用户角色,他们分别应该具有什么权限,然后再给用户分配相应的角色即可。这个东西很多框架都有实现,现阶段你们可能还用不到,我也点到为止。

代码注入

接下来讲一些代码注入方面的内容,这一块的安全很多时候要由前后端共同负责。就像之前参数验证一节提到的“前端传来的任何数据都是不可信”的一样,安全这方面的基本准则就是用户提交的任何数据都可能产生安全风险。风险相对比较大的比如 Shell 注入和一些语言的 eval() 函数(用于通过字符串执行代码和命令,函数的字符串参数会被当作代码解析执行)之类的,不过对我们而言并不常用,我觉得带过就行了。

以下几个是比较常见的代码注入风险以及相应的防护措施。

SQL 注入

这个东西我们很早就已经提过,看大家的代码现在也都做了防注入的工作,但是我相信有部分同学是和我当时一样只会用而不知道为什么的。字符串拼接的危险不言而喻,最常用的例子大概就是密码验证的拼接了:

SELECT * FROM users WHERE username = $username AND password = $password  

那我们输入这样的密码:xxx OR 1=1,拼接之后就是:

SELECT * FROM users WHERE username = uesrname AND password = xxx OR 1=1  

因为 OR 后面的表达式永远为真,所以通过字符串拼接 SQL 语句就导致了攻击者可以绕过密码验证而登录任意账户了。但是基本不会有人真的这么写,至少会进行一下过滤,而且密码也不可能用明文存储和验证。这个例子只是用来做个比喻,让大家知道 SQL 注入大致是什么。

防注入的方法也有很多,比如过滤 SQL 关键词、过滤单引号、转义特殊字符等,但是相对来说通过预处理语句(Prepared Statements)来实现,无论是安全性还是对原始数据的影响都是比较符合业务需求的。

目前的大部分数据库系统都资瓷预处理语句的概念,这种参数化传递数据进行增删查改的功能本身也都是由数据库系统实现的,我们用的 mysqli、 PDO 等是把它封装起来便于具体的语言使用这些功能。而且它的实现也不仅仅是用于防注入,还有个批量执行的功能也非常有实用价值,特别是涉及格式固定的批量数据增删查改的时候,我们只要写好预留参数的 SQL Prepare 语句,然后每次设置好参数值执行一次就可以了,可以在一定程度上减轻数据库压力,毕竟没有了解析重复 SQL 语句的时间。

以 MySQL 为例,举个简单的例子:

PREPARE stmt FROM "INSERT INTO table (a, b) VALUES (?, ?)";  
SET @x = 1;  
SET @y = 2;  
SET @z = 3;  
EXECUTE stmt USING @x, @y;  
EXECUTE stmt USING @y, @z;  
DEALLOCATE PREPARE stmt;  

执行后 table 表会多两行值,分别是 (1, 2)(2, 3)。这样应该就很容易理解关于参数化的安全性和对批量操作的便捷性了。语言对数据库预处理模块的封装,在底层都是用类似的方式执行的,当然这只是个最基本的例子,实际上还有诸如参数类型绑定等更多的功能,这里就不多提了,主要是希望大家能对防注入这块有稍微深入一点的了解。

跨站脚本

和 SQL 注入相同,跨站脚本(XSS)也主要是因为拼接攻击者刻意制造的字符串导致的,这个我们也在之前提到过,拼接普通 HTML 标签导致 DOM 和页面结构混乱相对来说还算小事了,如果代码里面有诸如读取甚至传送 Cookie 等相对敏感的数据操作的话就会涉及到比较严重的问题。

这个的防护一般需要前后端同时做过滤,后端比如 PHP 可以使用 htmlspecialchars() 函数对每个字段编码一次,实际上是把一些 HTML 特殊字符换成对应的编码符号,在页面上看起来和原来是一样的,但是不会被当作实际代码执行,可以用其他语言的转义符之类的知识来对比理解。

前端的话可能要自己写个类似的转码函数了,主要就是尖括号这块要做好处理,涉及用户自定义的数据都要检查过滤一遍。和后端拿到前端的数据不能直接处理一样,前端拿到后端返回的数据后也不能直接就放进页面里,需要仔细考虑是以文本方式补充还是以 HTML 格式补充,以及有没有什么疏忽会导致意料之外的代码被执行。

跨站请求伪造

跨站请求伪造(CSRF)指的是攻击者欺骗用户在不知情的情况下向服务端发送一个非本愿的请求,是不是读起来有点绕 2333。最简单的例子就是,比如某个站点用 URL 直接编码的方式提交点赞请求 http://example.com?like=23,攻击者发现这一点后,可以更改这个 URL http://example.com?like=233 然后用诱导点击等方式欺骗用户点击这个链接,那用户就在不知情的情况下给一个自己并不知道的内容点了个赞。这类安全问题在于,我们通过验证可以确认用户身份,但却不能知道这个请求是不是用户自愿发出的,或者准确点说,是不是用户在不知情的情况下发出的。

前面提到的 Token 的验证方式,可以用于这个问题的防护,比如把它的值添加到 URL 中,攻击者不可能猜出它的值来模拟请求链接。实际上我们一般也不会通过 GET 的方式传递比较重要的操作的数据,这类请求前后端都是使用 POST 进行交互的,尽管如此要模拟这个请求还是比较容易,毕竟模拟的站点在别人那里。

不知道大家现在能不能区分 XSS 和 CSRF,刚开始学可能一下子不太好理解。我借用 维基百科 上的一句话再给点对比:XSS 利用的是用户对网站的信任,也就是默认网站不会有盗取用户 Cookie 等行为,而 CSRF 利用的是服务端对客户端的信任,也就是默认请求都是用户自愿发出的。

后端 API

这个阶段应该基本都是使用后端 API 的模式对前端的请求进行处理和返回的,我讲一下除了逻辑代码以外的一些可能需要注意的问题。

返回格式

这个格式可以自己定,和前端商量好并且便于理解就行。推荐用类似微信 API 的格式:

{
    "errcode": 0,
    "data": "data",
    "errmsg": ""
}

其中 errcode 用来记录操作的返回码,默认成功为 0,失败为其它值。这样做其实是方便前端的判断,如果非零就直接把 errmsg 里的信息提示给用户。至于用 0 和其它值区分成功和失败,就是为了便于理解,大概就是成功只有成功一种情况,而失败的原因可能有很多,怎么有种鸡汤的感觉

出错的情况下后端要在编码返回数据时注意在 errmsg 段给出详细的错误信息,然后设置 errcode 为非零值,至于具体是什么值可以自己给每一种情况分类编码,这个东西主要的用处就是调试的时候方便定位错误来源,特别是有些错误的报错信息 errmsg 一样的情况下。

data 段的内容就是实际的数据了,可以是单一的值,也可以是 JSON 数组,具体当然是取决于接口定义。

接口文档

接口文档当然是接口的提供方来写的,一般写文档都是比较痛苦的事,毕竟程序员坠讨厌的两件事就是:

  1. 别人的代码没写文档
  2. 要给自己的代码写文档

但是写好文档可以提高团队合作的效率,还能避免很多后期的撕 X,想想如果接口很多的话用口头交接的方式有多恐怖就知道了。

理论上只要前端能看懂,文档写成什么样都行。不过我们还是要知道规范一点的文档怎么写,在时间允许的情况下也要尽量争取锻炼,这样以后写起文档来就“身经百战,见得多啦”(先声明我不是我没有。

接口文档现在有很多的开放平台、第三方接口都给出了非常好的例子,可以自己去找来学习。一般为了方便和美观建议用 Markdown 编写,主要的结构如下:

POST /host/path/to/api

请求参数:(数据示例)
请求参数说明:(表格)

返回参数:(数据示例)
返回参数说明:(表格)

嗯大概就是这样……首先是请求方法和请求路径,然后是请求参数和返回参数的分别示例和说明,说明里的表格内容包括几个列:参数名、参数格式、参数描述、是否必须、默认值、示例、备注等,其中前三四个是一定要有的,后面的可以看自己心情决定取舍。

配置问题

接下来写点团队合作的时候需要注意的一些问题,以及测试、部署的时候可能能用上的部分 Tips。

项目配置

所有会因为环境不同而不一样的值都要放到单独的配置文件里面,这样即便于调试也便于部署上线。常见的配置内容当然是数据库的地址、端口、数据库名、用户名和密码这些了,其他比较经常用到的就是一些限时项目的开放和截止时间,比如报名表这些的。这个时间检查和参数验证一样,前后端都要考虑到。

关于 Git

配置文件一般都是放在 .gitignore 里的,这个大家都知道,但是在其他人拿到 Repo 想部署的时候,他怎么知道需要哪些配置信息呢?一般我们使用一个 config.example.php 文件来代替真实的 config.php,然后在那个 example 文件里给出所有的配置变量名,它们的值设置为示例值,比如 DATABASE_NAME 这种,这样就能在保证自己配置不泄露的情况下给其他人提供很大的方便。

关于这个配置和备份文件还有一个容易疏忽的点,就是服务端是通过一些像 Nginx、Apache 之类的 Web 服务器程序来对请求进行处理的,比如对 PHP 文件我们会使用代理转发的方式转发到用于处理 PHP 文件的端口。如果不使用和原文件相同的拓展名,而是直接在末尾加个 .example 或者 .bak 来标识示例或备份文件,而这些文件又因为一些原因被放到了线上,那么根据默认的请求处理规则,它们会被客户端直接下载下来或者至少能在网页上查看到内容。特别是需要紧急修改配置文件所以操作前备份的情况,很可能在线上环境进行而没意识到这个安全问题,那很可能配置信息就会泄露,这是极其危险的。所以一般最简单的做法就是把这种标识信息放在中间,保证它们的拓展名和原文件相同。

此外,还有一个我自己之前碰到的问题,就是一个添加到 Git 仓库的文件,希望一些更改不被继续 track,当时找了蛮久的资料,因为根本不知道怎么描述这个问题[捂脸]。最后发现了这个:

$ git update-index --assume-unchanged filename

字面意思很容易理解吧,但是要直接找到它还真花了我一点时间 2333。

不过这个东西在团队合作的时候慎用,而且你要保证这个文件后续不会再被更改,不然 Merge 的时候会有问题的。

顺便也给出对应的取消和列表的命令:

$ git update-index --no-assume-unchanged filename
$ git ls-files -v | grep '^h '
数据库配置

默认的数据库内容编码、连接操作编码等我们都统一使用 UTF-8,测试的时候也要记得尝试写入中文看看会不会导致乱码。

部署上线的时候一般是用命令行的,复制粘贴 SQL 语句有时候容易出问题,特别是在对中文支持不是很完善的终端上,这个时候可以把要操作的 SQL 放在一个文件里,然后用数据库系统提供的文件导入命令执行,比如 MySQL 是:

mysql> SOURCE file.sql;  

注意这是进入 MySQL 后的终端,不是命令行的终端。

上线测试之后,在正式上线的时候需要清空数据库,并且把一些 AUTO_INCREMENT 的值设置为初始值,这个可以通过 TRUNCATE 命令实现:

mysql> TRUNCATE TABLE table_name;  

然后再讲一个给数据库分配用户的事情,虽然部署上线是我们负责的,但我觉得这个东西还是要知道比较好。

线上环境严格来说得给每一个项目数据库创建一个单独的数据库用户,然后对应设置单独的密码,这样在数据库操作逻辑代码有漏洞的时候可以保证不对其他数据库甚至整个数据库系统产生影响:

mysql> CREATE USER 'db_username'@'localhost' IDENTIFIED BY 'password';  
mysql> GRANT ALL PRIVILEGES ON db_name.* TO 'db_username'@'localhost';  

代码规范

关于代码规范的问题我们也已经强调了很多次了,其实这个“规范”并不是说要完全按照他人的编写代码标准去要求自己,命名、样式这方面只要能便于团队合作就足够了。我相信大家在项目越写越大的过程中也感受到了维护一份不规范的代码时消耗的时间和精力是非常大的,所以务必重视基本的代码规范要求,不要让不同的模块有太大的耦合度,也要保证自己的代码对其他模块产生的影响尽可能小。

额外提一下关于功能实现方面的规范问题,我们在写代码的时候不能停留在“能跑就可以”的阶段,虽然有时候确实是这样, 在时间允许的情况下应该多考虑能不能用更优雅的方式实现。

小结

写这篇文章主要是希望大家能了解一下接下来的大致学习路线,以及强化后端开发的安全意识。可能不一定每个都用得上,但知道有这些东西以后,在碰到相关的问题的时候能比较快想到解决方案,希望这篇文章能对大家有一定的帮助。

最后安利几本相关的经典书籍,分别是上野宣的《图解 HTTP》、钟晨鸣/徐少培的《Web前端黑客技术揭秘》还有结城浩的《图解密码技术》。

时间紧迫,行文仓促,可能有写得不太严谨的地方,欢迎指正。