一般在开发网站时使用 session
或者 cookie
来处理用户登陆等等权限问题,而在移动应用中要验证用户身份采用登录时给用户生成一个 token(令牌)的方式。每次用户发出需要身份认证的请求时,就需要验证一次 token 是否有效,无效的情况包括 token 无法被解析等。在向不可信环境发送数据时,确保数据经过签名,使用只有自己知道的密钥来签名数据,加密后发送,在取回数据时,确保没有人篡改过。
Python 有个 itsdangerous
包含了很多安全校验 token 验证相关的方案。 itsdangerous 就是这样一个签名校验的工具,内部使用 HMAC 和 SHA1 来签名。基于 Django 签名模块,支持 JSON Web 签名 (JWS), 这个库采用 BSD 协议。
给定字符串签名
发送方和接收方拥有相同的密钥 secret-key
,发送方使用密钥对发送内容进行签名,接收方使用相同的密钥对接收到的内容进行验证,看是否是发送方发送的内容。
>>> from itsdangerous import Signer
>>> s = Signer('secret-key')
>>> s.sign('my string')
'my string.wh6tMHxLgJqB6oY1uT73iMlyrOA'
签名过的字符串使用 .
分割。验证使用 unsign
>>> s.unsign('my string.wh6tMHxLgJqB6oY1uT73iMlyrOA')
带时间戳的签名
签名有一定的时效性,发送方发送时,带上时间信息,接收方判断多长时间内是否失效
>>> from itsdangerous import TimestampSigner
>>> s = TimestampSigner('secret-key')
>>> string = s.sign('foo')
foo.DlGDsw.dpJ37ffyfNAVufH21lH_yoelnKA
>>> s.unsign(string, max_age=5)
如果验证时间不对会抛出异常
序列化
>>> from itsdangerous import Serializer
>>> s = Serializer('secret-key')
>>> s.dumps([1, 2, 3, 4])
>>> s.loads('[1, 2, 3, 4].r7R9RhGgDPvvWl3iNzLuIIfELmo')
带时间戳的序列化
>>> from itsdangerous import TimedSerializer
>>> s=TimedSerializer('secret-key')
>>> s.dumps([1,2,3,4])
>>> s.loads('[1, 2, 3, 4].DlGEjg.1yG-U7iBk92FBYAZLezoBv2mfJs')
URL 安全序列化
如果加密过的字符串需要在 URL 中传输,可以使用这种方式。常见的就是在邮件验证 token 中。
>>> from itsdangerous import URLSafeSerializer
>>> s = URLSafeSerializer('secret-key')
>>> s.dumps([1, 2, 3, 4])
'WzEsMiwzLDRd.wSPHqC0gR7VUqivlSukJ0IeTDgo'
>>> s.loads('WzEsMiwzLDRd.wSPHqC0gR7VUqivlSukJ0IeTDgo')
[1, 2, 3, 4]
JSON Web Signatures
>>> from itsdangerous import JSONWebSignatureSerializer
>>> s = JSONWebSignatureSerializer('secret-key')
>>> s.dumps({'x': 42})
'eyJhbGciOiJIUzI1NiJ9.eyJ4Ijo0Mn0.ZdTn1YyGz9Yx5B5wNpWRL221G1WpVE5fPCPKNuc6UAo'
带时间戳的 JSON Web 签名
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
s = Serializer('secret-key', expires_in=60)
s.dumps({'id': user.id}) # user 为 model 中封装过的对象
在 Flask 中应用
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from itsdangerous import SignatureExpired, BadSignature
from config import config
def gen_token(user, expiration=1440*31*60): # 单位为秒,设定 31 天过期
s = Serializer(config.SECRET_KEY, expires_in=expiration)
return s.dumps({'id': user.id}) # user 为 model 中封装过的对象
装饰器
from functools import wraps
def token_required(func):
@wraps(func)
def wrapper(*args, **kwargs):
token = request.form['token']
s = Serializer(config.SECRET_KEY)
try:
data = s.loads(token)
except SignatureExpired:
return jsonify({'status': 'fail', 'data': {'msg': 'expired token'}})
except BadSignature:
return jsonify({'status': 'fail', 'data': {'msg': 'useless token'}})
kwargs['user_id'] = data['id']
return func(*args, **kwargs)
return wrapper
盐值
不同的盐值,生成的签名或者序列化的数值不一样,这里的盐 (SALT)不同于加密算法中的盐值,这里的盐值是用来避免彩虹表破解。
Hash
哈希(Hash)算法就是单向散列算法,它把某个较大的集合 P 映射到另一个较小的集合 Q 中,假如这个算法叫 H,那么就有 Q = H(P)。对于 P 中任何一个值 p 都有唯一确定的 q 与之对应,但是一个 q 可以对应多个 p。作为一个有用的 Hash 算法,H 还应该满足:H(p) 速度比较快; 给出一个 q,很难算出一个 p 满足 q = H(p);给出一个 p1,很难算出一个不等于 p1 的 p2 使得 H(p1)=H(p2)。正因为有这样的特性,Hash 算法经常被用来保存密码————这样不会泄露密码明文,又可以校验输入的密码是否正确。常用的 Hash 算法有 MD5、SHA1 等。
破解 Hash 的任务就是,对于给出的一个 q,反算出一个 p 来满足 q = H(p)。通常我们能想到的两种办法,一种就是暴力破解法,把 P 中的每一个 p 都算一下 H(p),直到结果等于 q;另一种办法是查表法,搞一个很大的数据 库,把每个 p 和对应的 q 都记录下来,按 q 做一下索引,到时候查一下就知道了。这两种办法理论上都是可以的,但是前一种可能需要海量的时间,后一种需要海量 的存储空间,以至于以目前的人类资源无法实现。
扩展
[[2021-06-29-jwt-authentication]] 是一次性认证完毕加载信息到 token 里的,token 的信息内含过期信息。过期时间过长则被重放攻击的风险太大,而过期时间太短则请求端体验太差(动不动就要重新登录)
第三方认证协议 Oauth2.0 RFC6749 ,它采取了另一种方法:refresh_token
,一个用于更新令牌的令牌。在用户首次认证后,签发两个 token: 一个为 access_token
,用于用户后续的各个请求中携带的认证信息;另一个是 refresh_token
,为 access_token
过期后,用于申请一个新的 access_token
。
由此可以给两类不同 token 设置不同的有效期,例如给 access_token
仅 1 小时的有效时间,而 refresh_token
则可以是一个月。api 的登出通过 access token 的过期来实现(前端则可直接抛弃此 token 实现登出),在 refresh token 的存续期内,访问 api 时可执 refresh token 申请新的 access token(前端可存此 refresh token,access token 过其实进行更新,达到自动延期的效果)。refresh token 不可再延期,过期需重新使用用户名密码登录。
这种方式的理念在于,将证书分为三种级别:
- access token 短期证书,用于最终鉴权
- refresh token 较长期的证书,用于产生短期证书,不可直接用于服务请求
- 用户名密码 几乎永久的证书,用于产生长期证书和短期证书,不可直接用于服务请求