作者 | 写代码的明哥
来源 | Python编程时光
这篇文章本应该是属于 HTTP 里的一部分内容,但是我看内容也挺多的,就单独划分一篇文章来讲下。
什么是跨域请求
要明白什么叫跨域请求,首先得知道什么叫域。
域,是指由 协议
+域名
+端口号
组成的一个虚拟概念。
如果两个域的协议、域名、端口号都一样,就称他们为同域,但是只要有其中一个不一样,就不是同域。
那么 跨域请求
又是什么意思呢?
简单来说,就是在一个域内请求了另一个域的资源,由于域不一致,会有安全隐患。
有一个词,叫 CSRF (Cross-site request forgery)攻击,中文名是 跨站请求伪造
。
简单来说呢,就是攻击者盗用了你的身份,以你的名义发送恶意请求,它能做的坏事有很多,比如以你的名义发邮件,发消息,购物,盗取帐号等。
CSRF 的实际工作原理是怎样的?
比如现在有两个网站,A 网站是真实受信息的网站,而 B网站是危险网站。
当你登陆 A 网站后,浏览器会存储 A 网站服务器给你生成的 sessionid 存入 cookie,有了这个 cookie ,就拥有了你的帐号权限,以后请求资料,就不用再次登陆啦。
对于真实用户来说,是便利,可对于攻击者来说,却是可乘之机。
他们可以使用各种社工学引导你点击他们的链接/网站,然后利用你的浏览器上存储的 cookie ,然后在自己的 网站B 发起对 网站A 的请求,获取一些隐私信息,做一些侵害用户权益的事情。这便是一个完整的 CSRF 攻击。
完成一次完整的 CSRF 攻击,只要两个步骤:
登录受信任网站A,并本地已经存储了 Cookie
在不登出A的情况下,访问危险网站B,网站 B 诱导你发 A 发请求。
很多浏览器用户对于网络安全是无意识的,因此我们不能指望通过规范用户行为来避免CSRF攻击。
那如何从技术手段规避一定的 CSRF 攻击的风险呢?
利用浏览器的同源策略:最基础的安全策略
对请求的来源进行验证:Referer Check
使用验证码强制使用户进行交互确认,保证请求是用户发起
CSRF Token,注意不要使用 cookie 来存储token
JSON Web Token
以上是我知道的历史上用来抵御 CSRF 攻击的方法
有的虽然实现简单,但是不够安全
有的虽然安全,但是用户体验不好
有的虽然安全,用户体验好,但是有缺点
具体应该选哪一种呢,不妨继续往下看。
浏览器上有一个同源策略(SOP,全称 Same origin policy),它会在一定程度上禁止这种跨域请求的发生。
但同源策略是最基本的安全策略,对于低级的 CSRF 攻击 ,它是很有效果的。
可以说 Web 是构建在同源策略基础之上的,浏览器只是针对同源策略的一种实现。
同源策略在提升了 Web前端的安全性的同时,也牺牲了Web拓展上的灵活性。
设想若把html、js、css、flash,image等文件全部布置在一台服务器上,小网站这样凑活还行,大中网站如果这样做服务器根本受不了的,因此同源策略,就像是双刃剑。不过这些都是有解的。
在 HTTP 协议中,有一个字段叫做 Referer,它记录了HTTP 请求的来源地址。
当发生 CSRF 攻击时,这个来源地址,会变成危险网站 B,因此只要在服务端校验这个 Referer 是不是和自己同一个域就可以判断这个请求是跨站请求。
但这种方法,也是有局限性的,在一些非主流的浏览器,或者使用了那些非常古老的浏览器版本,这个 Referer 字段,是有可能会被篡改的。
退一步讲,假设你使用了最安全的最新版本的浏览器,这个值无法被篡改,依旧还是有安全隐患。
因为有些用户出于某些隐私考虑,会在浏览器设置关闭这个 Referer 字段,也有的网站会使用一些技术手段使用请求不携带 Referer 字段。
因此,当你要使用 Referer Check 来做为 防御 CSRF 攻击的主要手段时,请确保你的用户群体使用的一定是最安全的最新版本的浏览器,并且默认用户不会手动关闭 Referer 。
验证码,强制用户必须与应用进行交互,才能完成最终请求。
其实加验证码,是能很好遏制 CSRF 攻击,但是网站总不能给所有的操作都加上验证码吧,那样的话,用户估计都跑光光了,因此为了保证用户体验,验证码只能作为一种辅助手段,不能作为主要解决方案。
CSRF 攻击之所以能够成功,是因为黑客可以完全伪造用户的请求,该请求中所有的用户验证信息都是存在于 cookie 中,因此黑客可以在不知道这些验证信息的情况下,直接利用用户自己的 cookie 来通过安全验证。
所以要抵御 CSRF,关键在于要在请求中放入黑客所不能伪造的信息,并且该信息不存在于 cookie 之中(不然黑客又能拿到了)。
业界普遍的防御方案是使用 CSRF Token
使用 CSRF Token 根据token验证方式的不同,也可以分为两种:
第一种:如图所示
当用户请求一个更新用户名的页面时,由服务端生成一个随机数 Token,然后放入HTML表单中传给浏览器,并且存入 session 中。
当用户提交表单请求时,表单数据会带上这个 Token 发送给服务端 ;
服务端收到表单请求后,会从表单数据里取出 Token,然后和 session 里的 token 进行对比,如果是一样的,就是合法的用户请求,将新的用户名存入数据库,如果不一样,那就是非法的请求,应当拒绝。
第二种:
当用户请求一个更新用户名的页面时,由服务端生成一个随机数 Token,然后放入HTML表单中,并且会把这个 Token 放在 cookie 里发给浏览器。
当用户提交表单请求时,表单数据会带上这个 Token 发送给服务端,并且带上携带 token 的 cookie ;
服务端收到表单请求后,会从表单数据里取出 Token,与 cookie 里的 token 进行对比,如果是一样的,就是合法的用户请求,将新的用户名存入数据库,如果不一样,那就是非法的请求,应当拒绝。
使用上面的 CSRF Token 已经可以避免 CSRF 攻击,但是它却有可能又引入了另一个问题。
若 CSRF Token 没有使用 cookie,就必须要将 Token 存储在服务端的 Session 中,这样就会面临几个问题
服务端每生成一个 Token,都会存放入 session 中,而随着用户请求的增多,服务端的开销会明显增大。
如果网站有多个子域,分别对应不同的服务器,比如 taobao.com 后台是服务器 a,zhibo.baotao.com 后台是 服务器b, 不同子域要想使用同一个 Token,就要求所有的服务器要能共享这个 Token。一般要有一个中心节点(且应是一个集群)来存储这个Token,这样看下来,架构就变得更加复杂了。
想要解决这些问题,可以使用我们接下来要讲的 JWT(全称:JSON Web Token)
使用了 JWT 后,有了哪些变化呢
服务器只负责生成 Token和校验Token,而不再存储Token
将服务器的压力分摊给了所有的客户端。
服务端的 鉴权不使用 cookie ,而是由新增的 Header 字段:Authorization 里的 JWT 。
JWT 是本篇文章重要知识点之一,下面我会详细说说关于 JWT 的内容。
为了让你直观感受 JWT 的工作原理,我画了下面这张图
用户以 Web表单 的形式,将自己的用户名和密码 POST 到后端的接口。
后端核对用户名和密码成功后,会计算生成JWT Payload 字符串(具体计算方法,后续会讲),然后返回 response 给浏览器。
浏览器收到 JWT 后,将其保存在 cookie 里或者 localStorage 或者 sessionStorage 里(具体如何选,后面会说)。
后续在该域上发出的请求,都会将 JWT放入HTTP Header 中的 Authorization 字段。
后端收到新请求后,会使用密钥验证 JWT 签名。
验证通过后后端使用 JWT 中包含的用户信息进行其他相关操作,返回相应结果。
JWT 的诞生并不是解决 CSRF 跨域攻击,而是解决跨域认证的难题。
举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,这应该如何实现呢?
一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。
另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。
JWT 就是这种方案的一个优秀代表。
JWT 其实就是一个字符串,比如下面这样
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
仔细观察,会发现它里面有三个 .
,以.
为分界,可以将 JWT 分为三部分。
第一部分:头部(Header)
第二部分:载荷(Payload)
第三部分:签名(Signature)
JWT 的头部承载两部分信息:
声明类型:这里是 JWT
声明加密的算法:通常直接使用 Hmac SHA256
完整的头部就像下面这样的JSON:
{
"typ": "JWT",
"alg": "HS256"
}
然后将头部进行 Base64URL 算法编码转换,构成了第一部分
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
载荷,同样也是个 JSON 对象,它是存放有效信息的地方,但不建议存放密码等敏感信息。
JWT 规定了7个官方字段,供选用:
iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号
除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。
注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后将其进行 Base64URL 算法转换,得到 JWT 的第二部分。
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
Signature 部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.
)分隔,就可以返回给用户。
如果你想手动生成一个 JWT 用于测试,有两种方法
第一种:使用 https://jwt.io/ 这个网站 。
我使用前面的 header 和 payload,然后使用 secret 密钥:Python
最后生成的 JWT 结果如下
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.3wGDum3_A8tAt1bdal5CpYbIUlpHfPQxs96Ijx883kI
第二种:使用 Python 代码生成
首先安装一下 pyjwt 这个库
$pip install pyjwt
然后就可以在代码中使用它
import jwt
import datetime
import uuid
salt = 'minggezuishuai'
# 构造header , 这里不写默认的也是
headers = {
'typ': 'JWT',
'alg': 'HS256'
}
# 构造payload
payload = {
'user_id': str(uuid.uuid4), # 自定义用户ID
'username': "wangbm", # 自定义用户名
'exp': datetime.datetime.utcnow + datetime.timedelta(minutes=5) # 超时时间,取现在时间,五分钟后token失效
}
token = jwt.encode(payload=payload, key=salt, algorithm="HS256", headers=headers).decode('utf-8')
# token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiODg4ZjIwZDktMDdlZC00MWJkLWIzMjktMTdjNmYwNThhMTRlIiwidXNlcm5hbWUiOiJ3YW5nYm0iLCJleHAiOjE1OTQ0MzQzMjZ9.kkEMhSx732lO6HWWNPNVQDHR9WuCEVxKgNol-LTbCP8
如果你只是测试使用,完全不用写那么多代码,用命令行即可
$ pyjwt --key="minggezuishuai" encode user_id=888f20d9-07ed-41bd-b329-17c6f058a14e username=wangbm exp=+120
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiODg4ZjIwZDktMDdlZC00MWJkLWIzMjktMTdjNmYwNThhMTRlIiwidXNlcm5hbWUiOiJ3YW5nYm0iLCJleHAiOjE1OTQ0MzQ4NTl9.A792th12kY1YnBWyVgbr5l6OQ5emRiETIjsnmIl4Ji8
前面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。
JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法。
JWT 如何保存?
关于浏览器应该将 JWT 保存在哪?这个问题,其实也困扰了我很久。
如果使用搜索引擎去查,我相信你也一定会被他们绕晕。
比如在这篇帖子(When and how to use it )里,作者的观点是,不应该保存在 localstorage 和 session storage,因为这样,第三方的脚本就能直接获取到。
作者推荐的做法是,将 JWT 保存在 cookie 里,并设置 HttpOnly。
再比如这一篇帖子(JWT(JSON Web Token) : Implementation with Node)提到了要把 JWT 保存到 local-storage。
因此,我决定不再看网络上关于 『应将 JWT 保存的哪?』的文章。而是自己思考,以下是我个人观点,不代表一定正确,仅供参考 。
JWT 的保存位置,可以分为如下四种
保存在 localStorage
保存在 sessionStorage
保存在 cookie
保存在 cookie 并设置 HttpOnly
第一种和第二种其实可以归为一类,这一类有个特点,就是该域内的 js 脚本都可以读取,这种情况下 JWT 通过 js 脚本放入 Header 里的 Authorization 字段,会存在 XSS 攻击风险。
第三种,与第四种相比,区别在于 cookie 有没有标记 HttpOnly,没有标记 HttpOnly 的 cookie ,客户端可以将 JWT 通过 js 脚本放入 Header 里的 Authorization 字段。这么看好像同时存在CSRF 攻击风险和 XSS 攻击风险,实则不然,我们虽然将 JWT 存储在 cookie 里,但是我们的服务端并没有利用 cookie 里的 JWT 直接去鉴权,而是通过 header 里的 Authorization 去鉴权,因此这种方法只有 XSS 攻击风险,而没有 CSRF 攻击风险。
而第四种,加了 HttpOnly 标记,意味着这个 cookie 无法通过js脚本进行读取和修改,杜绝了 XSS 攻击的发生。与此同时,网站自身的 js 脚本也无法利用 cookie 设置 header 的Authorization 字段,因此只能通过 cookie 里的 JWT 去鉴权,所以不可避免还是存在 CSRF 攻击风险。
如此看来,好像不管哪一种都有弊端,没有一种完美的解决方案。
是的,事实也确实如此。
所以我的观点是,开发人员应当根据实际情况来选择 JWT 的存储位置。
当访问量/业务量不是很大时,可以使用 CSRF Token 来防止 CSRF 攻击
而如果访问量/业务量对服务器造成很大压力,或觉得服务器共享 token 对架构要求太高了,那就抛弃CSRF Token 的方式,而改用 JWT。选择了 JWT ,就面临着要将 JWT 存储在哪的问题。
若选择了 JWT ,那么请不要使用 cookie HttpCookie 来存储它,因为使用它还是会有 CSRF 攻击风险。
那另外三种如何选择呢?这三种无论使用哪种,都不可避免有 XSS 攻击风险。我的思路是,XSS 攻击通过其他的手段来规避,这里使用JWT 只有 防御 CSRF 攻击与服务器性能的优化,这两个目标。
那我剩下的三种,我建议是使用 cookie 存储,但不使用 cookie 来鉴权。服务器鉴权还是通过请求里的 Authorization 字段(通过js写入 Header 的)。
当然,如果你觉得你通过 Referer Check
、加验证码
等其他手段,已经可以保证不受 CSRF 攻击的威胁,此时你使用 JWT ,就可以选择使用 JWT + cookie HttpOnly,扼杀 XSS 攻击的可能。
通过上面第七节的描述,其实我也讲到了 JWT 根据不同场景可以选择两种发送方式
第一种:将 JWT 放在 Header 里的 Authorization
字段,并使用Bearer
标注
'Authorization': 'Bearer ' + ${token}
第二种:把 JWT 放入 cookie ,发送给服务端,虽然发送。但是不使用它来鉴权。
后端收到请求后,从 Header 中取出 Authorization
里的 JWT ,使用之前的签名算法对 header 和 payload 再次计算生成新的签名,并与 JWT 里的签名进行对比,如果一样,说明校验通过,是个合法的 Token。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
验证是个合法的 Token 后,还要检查这个 Token 是否过期,在 JWT 里的 payload 中,有 Token 的过期时间,可以通过它来检查 Token 是否可以用?
payload 里同时还有用户的相关信息,有了这些信息后,后端就可以知道这是哪个用户的请求了,到这里一切都验证通过,就可以执行相关的业务逻辑了。
前面我使用了 pyjwt
这个来生成 JWT ,事实上,这个库也可以用来验证 token。
使用 jwt 的 decode 会先验签再解码取得 payload 的信息。
>>> import jwt
>>> token="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiODg4ZjIwZDktMDdlZC00MWJkLWIzMjktMTdjNmYwNThhMTRlIiwidXNlcm5hbWUiOiJ3YW5nYm0iLCJleHAiOjE1OTQ0MzQzMjZ9.kkEMhSx732lO6HWWNPNVQDHR9WuCEVxKgNol-LTbCP8"
>>> jwt.decode(token, 'minggezuishuai', algorithms=['HS256'])
{'user_id': '888f20d9-07ed-41bd-b329-17c6f058a14e', 'username': 'wangbm', 'exp': 1594434326}
>>>
验签同样也可以使用命令行
$ pyjwt --key="minggezuishuai" decode eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiODg4ZjIwZDktMDdlZC00MWJkLWIzMjktMTdjNmYwNThhMTRlIiwidXNlcm5hbWUiOiJ3YW5nYm0iLCJleHAiOjE1OTQ0MzQ4NTl9.A792th12kY1YnBWyVgbr5l6OQ5emRiETIjsnmIl4Ji8
{"user_id": "888f20d9-07ed-41bd-b329-17c6f058a14e", "username": "wangbm", "exp": 1594434859}
如果不想验证签名及有效期,而只是想取下payload,只需加个--no-verify
参数即可
$pyjwt --key="minggezuishuai" decode --no-verify {token}
更的详细使用方法,可以执行 pyjwt --help
学习或者前往官方文档:https://pyjwt.readthedocs.io/en/latest/index.html
在真正的业务中,是有可能使用 payload 来存放一些用户的敏感信息的,由于 payload 是采用 Base64URL 转换而成,它是可逆的,因此当你在 payload 存放敏感信息时,需要保证 JWT 的安全性,不能让其暴露在 『阳光』下。
为此,JWT 最好与 HTTPS 配合使用,利用 HTTPS 的非对称加密来保证 JWT 的安全。
具体是如何保障的呢?
HTTPS 是基于 SSL/TLS 的非对称加密算法工作的。
在非对称加密算法的规则下,服务器会拥有一个叫做『私钥』的东西,它是私有的,除了服务器之外,不能再有第二个人知道它。
而相对的,所有的客户端(浏览器)同时也会有一个叫做『公钥』的东西,它是对所有人公开的,任何人都可以拥有它。它与『私钥』合称为一个密钥对。
公钥和私钥的规则是:
公钥加密的东西,只有私钥能解。因此如果 JWT 的 payload 里有你的敏感信息,那也不要紧,只要把 JWT 用公钥(前提是这个公钥得是正确的,下面会说到)加密一下,那黑客就算拿到了这个密文,也无法解密,因为私钥只有服务器才有。
私钥加密的东西,所有的公钥也都能解。因此服务器发给客户端的 JWT 的payload 尽量不要有敏感信息。
那么问题又来了,如果客户端拿到的公钥,是黑客伪造的,客户端拿着这个假公钥加密自己的敏感信息,然后发出去,黑客在拿到这个用自己伪造的公钥加密的数据,非常开心,因为这个公钥对应的私钥在自己手里,自己是可以解密得到里面的数据的。
因此如何保证服务器发给客户端(浏览器)的公钥是正确的呢?
答案是通过数字证书来保证。但是由于这个不是本文的重点,因此我将这块内容放在后面的文章详细解释。
最后,我总结一下,本文的要点:
CSRF 攻击的产生,需要cookie 的『助攻』,否则无法完成。
CRSF 是利用 cookie,而不是盗取 cookie,这点一定要明白。
但也并不是使用了 cookie 就会有 CSRF 风险,而应该说是用 cookie 去做鉴权才会有 CSRF 风险,参考 CSRF Token (把 token 存储在 cookie 的情况)和 JWT (把 token 存储在 cookie 的情况)。
CSRF Token 和 JWT 虽然都可以做到防御 CSRF 攻击,但其实无论是哪个都无法同时做到防御 CSRF 和 XSS 攻击,在阻止了 CSRF 攻击后, 需要再通过其他手段来减少 XSS 攻击的可能性。
JWT 就是一个由服务端按照一定的规则生成的字符串,
JWT 的目的是为了做一个无状态的 session,避免去频繁查询 session,减少了对服务器产生的压力,简化后端架构模型。它的主要用途是解决跨域认证的问题,而解决 CSRF 跨域攻击只是它的附带功能。
payload 是经过 base64URL 算法转换而成的字符串,是可逆的,因此尽量不要存放敏感数据,如若非要存放敏感数据,最好与 HTTPS 协议搭配使用,避免数据泄露。
JWT 的保存位置与方式,没有绝对的方案,具体如何选择要视情况而定。