本文主要介绍新项目中第一部分的内容——身份验证接口的设计思路。

身份验证接口主要包括以下模块:

  • 注册
  • 登录
  • 找回密码

基本流程为:

  1. 填写手机号或者邮箱
  2. 填写图片验证码中的内容
  3. 填写数字验证码以完成身份验证
  4. 操作完成

双重验证

在调用接口时,用户需要完成图片验证码 + 短信验证码,我们在日常使用相关的功能时也是采用这样的双重验证的策略。那我们为什么要用这么繁琐的验证方式呢?在了解为什么之前,我们先来看以下这两种验证码的作用。

  • 图片验证码 —— 区分机器人和正常用户
  • 数字验证码——区分用户是否为手机号或邮箱的主人

采用双重验证,主要还是考虑到开放注册登录接口所带来的安全隐患。在知道了这两种验证码的作用后,我们来看缺少了其中一种会带来什么样的安全隐患。

黑客容易利用的地方,大致可以分为两部分:

  • 滥用
  • 暴力破解

滥用(轰炸机)

删除图片验证,保留短信验证。

原理

短信验证码轰炸一般基于web方式,主要有两个模块组成:一个前端web网页,提供输入被攻击者手机号码的输入窗口;一个后台攻击页面(如PHP),利用从各个网站上找到的动态短信URL 和前端输入的被攻击者手机号码,发送HTTP 请求,每次请求给用户发送一个动态短信。原理和实施过程如下:

  1. 恶意攻击者在前端中输入被攻击者的手机号;

  2. 短信炸弹后台服务器,将该手机号与互联网收集的可不需要经过认证即可发送动态短信的URL 进行组合,形成可发送动态短信的URL 请求;

  3. 通过后台请求页面,伪造用户的请求发给不同的业务服务器;
  4. 业务服务器收到该请求后,发送动态短信到被攻击用户的手机上。

img

GitHub 上也有一些轰炸机的项目,例如这个 github.com/shellvon/smsBomb/blob/m… ,门槛非常低。

轰炸机的配置示例:

身份验证接口设计

如何防治

短信炸弹形成的原因是因为非授权的动态短信获取,由于在使用动态短信业务前系统并不能建立业务关联。因此,在未建立业务关联的情况下,需要进一步严格限制保证业务使用的安全性。可以采用增加图形验证码限制单IP请求次数限制用户短信请求间隔等方式,保护短信通道。

img

img

暴力破解

用户使用密码登录时,很容易被黑客暴力破解。

所谓暴力破解,就是针对某个用户名,不断尝试可能出现的密码,直到最终成功登录。

互联网上有大量的常用密码词典,GitHub 上随便一搜就能找到如 这个这个 。也极大的降低了黑客暴力破解的门槛。

防止暴力破解,是一个系统性工程。用户注册时候,我们会要求密码至少六位数(要求太严格也不好,影响用户体验)。

另外 API 也会加入限流措施,不会让黑客无止境的尝试,这也是现在主流的防范机制。

防止暴力破解,一个行之有效的方式,是使用图片验证码。

图片验证码的设计初衷,是用来区分人和机器的输入:

mNTpWgMD4R.png!large

如果一个验证码很容易破解,我们还可以增加其难度:

uDpbpHfEpb.png!large

X1wXrgsdWb.png!large

另外,不止用户密码,有时候短信验证码和邮箱验证码,也很容易被破解,因为一般情况下我们只会提供六位数的数字。

六位数,意味着 999999 种可能性。虽然会加入过期时间,例如说 15 分钟内有效,但是如果接口不做限制的话,黑客写个程序,很容易就能在几分钟内尝试完所有的 999999 个可能性。最终短信验证码形同虚设,黑客可以很轻松的通过验证码来重置用户的密码。最终导致用户账号丢失。

Redis是什么?

OK啊,也是好起来了,新项目用到了 Redis,第一次面试也有被面试官问过类似的问题。

用 Redis 来干吗呢,当然是用来存储图片验证码了,后续也会用 Redis 来完成友情链接的存储。

提到 Redis,相信找过工作的都知道,只要在简历上写了关于 Redis 的内容,就会触发面试官的被动技能,那就是提问 Redis 三兄弟。那么也来简单看一看关于 Redis 的内容吧。


Redis 是 Remote Dictionary Service 三个单词中加粗字母的组合,是一种基于键值对(key-value)的 NoSQL 数据库。

但比一般的键值对,Redis 中的 value 支持 string(字符串)、hash(哈希)、 list(列表)、set(集合)、zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数估算)、GEO(地理信息定位)等多种数据结构。

而且因为 Redis 的所有数据都存放在内存当中,所以它的读写性能非常出色

不仅如此,Redis 还可以将内存数据持久化到硬盘上,这样在发生类似断电或者机器故障的时候,内存中的数据并不会“丢失”。

除此之外,Redis 还提供了键过期、发布订阅、事务、流水线、Lua 脚本等附加功能,是互联网技术领域中使用最广泛的缓存中间件。

Redis 有什么用

三分恶面渣逆袭:Redis的作用

  1. 缓存

Redis 最常见的用途就是作为缓存,由于所有数据都存储在内存中,所以 Redis 的读写速度非常快,远超基于磁盘存储的数据库。使用 Redis 缓存可以极大地提高应用的响应速度和吞吐量。

三分恶面渣逆袭:Redis缓存

  1. 排行榜/计数器

Redis 的 ZSet 非常适合用来实现排行榜的功能,同时 Redis 的原子递增操作可以用来实现计数器功能。

  1. 分布式锁

Redis 可以实现分布式锁,用来控制跨多个进程或服务器的资源访问。

实例:

  • Redis 可以用来存储 Token:用户登录成功之后,使用 Redis 的 hash 存储 Token
  • 使用 Redis 的 Zset 计数,登录失败超过一定次数,锁定账号
  • 使用 Redisson 实现分布式环境下的登录、注册等操作

Redis 中的数据类型

Redis 有五种基本数据类型,这五种数据类型分别是:string(字符串)、hash(哈希)、list(列表)、set(集合)、sorted set(有序集合,也叫 zset)。

三分恶面渣逆袭:Redis基本数据类型

string

字符串是最基础的数据类型,key 是一个字符串,不用多说,value 可以是:

  • 字符串(简单的字符串、复杂的字符串(例如 JSON、XML))
  • 数字 (整数、浮点数)
  • 甚至是二进制(图片、音频、视频),但最大不能超过 512MB。

字符串主要有以下几个典型的使用场景:

  • 缓存功能
  • 计数
  • 共享 Session
  • 限速

hash

键值对集合,key 是字符串,value 是一个 Map 集合,比如说 value = {name: '沉默王二', age: 18},name 和 age 属于字段 field,沉默王二 和 18 属于值 value。

哈希主要有以下两个典型应用场景:

  • 缓存用户信息
  • 缓存对象

来感受一下,用户字符串类型存储用户信息和用哈希类型存储用户信息的区别:

img

list

list 是一个简单的字符串列表,按照插入顺序排序。可以添加一个元素到列表的头部(左边)或者尾部(右边)。

列表主要有以下两个使用场景:

  • 消息队列
  • 文章列表

set

集合是字符串的无序集合,集合中的元素是唯一的,不允许重复。和 Java 集合框架中的 Set 有相似之处。

集合主要有以下两个使用场景:

  • 标签(tag)
  • 共同关注

sort set

Zset,有序集合,比 set 多了一个排序属性 score(分值)。

img

主要应用场景有:

  • 用户点赞统计
  • 用户排序

Redis 三兄弟

缓存穿透

缓存穿透是指查询不存在的数据,由于缓存没有命中(因为数据根本就不存在),请求每次都会穿过缓存去查询数据库。如果这种查询非常频繁,就会给数据库造成很大的压力。

三分恶面渣逆袭:缓存穿透

缓存穿透意味着缓存失去了减轻数据压力的意义。

缓存穿透可能有两种原因:

  • 自身业务代码问题
  • 恶意攻击,爬虫造成空命中

它主要有两种解决办法:

  1. 缓存空值/默认值

一种方式是在数据库不命中之后,把一个空对象或者默认值保存到缓存,之后再访问这个数据,就会从缓存中获取,这样就保护了数据库。

三分恶面渣逆袭:缓存空值/默认值

缓存空值有两大问题:

  • 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

  • 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。

例如过期时间设置为 5 分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致。

这时候可以利用消息队列或者其它异步方式清理缓存中的空对象。

  1. 布隆过滤器

除了缓存空对象,我们还可以在存储和缓存之前,加一个布隆过滤器,做一层过滤。

布隆过滤器里会保存数据是否存在,如果判断数据不不能再,就不会访问存储。

布隆过滤器

缓存空对象核布隆过滤器方案对比

缓存击穿

缓存击穿是指某一个或少数几个数据被高频访问,当这些数据在缓存中过期的那一刻,大量请求就会直接到达数据库,导致数据库瞬间压力过大。

三分恶面渣逆袭:缓存击穿

解决⽅案:

  1. 加锁更新,⽐如请求查询 A,发现缓存中没有,对 A 这个 key 加锁,同时去数据库查询数据,写⼊缓存,再返回给⽤户,这样后⾯的请求就可以从缓存中拿到数据了。

三分恶面渣逆袭:加锁更新

  1. 将过期时间组合写在 value 中,通过异步的⽅式不断的刷新过期时间,防⽌此类现象。

缓存雪崩

缓存雪崩是指在某一个时间点,由于大量的缓存数据同时过期或缓存服务器突然宕机了,导致所有的请求都落到了数据库上(比如 MySQL),从而对数据库造成巨大压力,甚至导致数据库崩溃的现象。

总之就是,崩了,崩的非常严重,就叫雪崩了(电影电视里应该看到过,非常夸张)。

三分恶面渣逆袭:缓存雪崩

解决方案:

第一种:提高缓存可用性

  1. 集群部署:采用分布式缓存而不是单一缓存服务器,可以降低单点故障的风险。即使某个缓存节点发生故障,其他节点仍然可以提供服务,从而避免对数据库的大量直接访问。

可以利用 Redis Cluster。

Rajat Pachauri:Redis Cluster

或者第三方集群方案 Codis。

极客时间:Codis

  1. 备份缓存:对于关键数据,除了在主缓存中存储,还可以在备用缓存中保存一份。当主缓存不可用时,可以快速切换到备用缓存,确保系统的稳定性和可用性。

第二种:过期时间

对于缓存数据,设置不同的过期时间,避免大量缓存数据同时过期。可以通过在原有过期时间的基础上添加一个随机值来实现,这样可以分散缓存过期时间,减少同一时间对数据库的访问压力。

第三种:限流和降级

通过设置合理的系统限流策略,如令牌桶或漏斗算法,来控制访问流量,防止在缓存失效时数据库被打垮。

此外,系统可以实现降级策略,在缓存雪崩或系统压力过大时,暂时关闭一些非核心服务,确保核心服务的正常运行。


本项目中对 Redis 的使用并不多,主要是用来存储一些不会经常发生变化的内容,所以更加深层次的内容会在后面继续学习,在这里就不再写了。

JWT 授权

互联网服务离不开用户认证。一般流程是下面这样。

  1. 用户向服务器发送用户名和密码。
  2. 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
  3. 服务器向用户返回一个 session_id,写入用户的 Cookie。
  4. 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。
  5. 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

这种模式的问题在于,扩展性(scaling)不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。

举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?

一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。

另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。

JWT 的原理

JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。

1
2
3
4
5
{
"姓名": "张三",
"角色": "管理员",
"到期时间": "2018年7月1日0点0分"
}

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。

服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。

JWT 的数据结构

实际的 JWT 大概就像下面这样:

img

它是一个很长的字符串,中间用点(.)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。

JWT 的三个部分依次如下。

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

写成一行,就是下面的样子。

img

Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

上面代码中,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT

最后,将上面的 JSON 对象使用 Base64URL 算法转成字符串。

Payload

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。

1
2
3
4
5
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

这个 JSON 对象也要使用 Base64URL 算法转成字符串。

Signature

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用”点”(.)分隔,就可以返回给用户。

Base64URL

前面提到,HeaderPayload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。

JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+/=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-/替换成_ 。这就是 Base64URL 算法。

JWT 的使用方式

客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage

此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

1
Authorization: Bearer <token>

另一种做法是,跨域的时候,JWT 就放在 POST 请求的数据体里面。

JWT 的几个特点

(1)JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。

(2)JWT 不加密的情况下,不能将秘密数据写入 JWT。

(3)JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。

(4)JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。

(5)JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。

(6)为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。


说了这么多的基础内容,JWT 在本项目中又起到了什么作用呢。使用 JWT 主要用于产生 token,以此来进行授权,未被授权的用户不能执行相关操作,如 A 发布的话题,只有 A 才能对话题进行修改和删除,其他用户只能引用这个话题。

总结

记录完上面的这三个问题,其实已经解决了我在做这个项目时的一大部分疑问了,因为项目主题其实很类似,这也是为什么后面会使用模板文件进行自动化生成模型、控制器、验证请求、请求授权等这些机制的原因。

当然,本文中所写到的也都只是相关技术的一些皮毛,后续的深入学习还会进行记录。关于本项目的还会有包括日志、命令行模式、make命令以及关于数据库方面的东西。

参考资料