后端用户认证系统构建详解
一年没更新博客,发现学习到的东西还是要写下来梳理一下,要不很难理解透彻,有遗忘重新学效率也很低
常用的两种用户认证方式
Ⅰ 基于 Session 的身份验证
Session 本身是一个抽象的概念,有多种实现,这里就不讲复杂的多节点服务器下 session 身份验证的实现了,讲最普遍的 Session+Cookie
基于 Session 的身份验证,是最传统的验证方式,工作流程如下:
-
用户首次登陆成功时,后端生成一个 sessionId,储存在缓存中(还储存一些用户信息,以 sessionId 为 key)(比如用 Redis),设置一个过期时间,然后再把这个 sessionId 返回给用户的浏览器,浏览器再把 sessionId 储存在 cookie 中。
-
再次访问时,服务器会取出 HTTP 请求头内 cookie 中的 sessionId ,然后在缓存中寻找该字串,如果存在,那么该用户已登录(A);如果不存在,那么该用户的登陆已经过期(B)。如果根本没收到 session,说明还没登陆过(C)。
对情况A:验证该用户权限,决定是否放行
对情况B、C:均跳转到登陆界面
- 客户端调用后端 logout API时,清除缓存中 sessionId key下所有数据
优点
提供了用户认证功能,使无状态的 HTTP 请求有状态化
缺点
本地储存 cookie 不安全,session 储存占用服务器资源
跨域请求不便/不安全,记个 TODO,下次写写 CORS/CSRF
Ⅱ 基于 Token 的身份验证
基于 Token 的身份验证,近几年越来越常见,特点是服务器不用储存登陆状态信息
基于 Token 的身份验证,工作流程如下:
- 用户首次登陆成功时,后端生成一个 token,不在后端储存,把 token 发送给用户,用户拿到 token 后将 token 储存在 localStorage(不储存在 cookie,因为不安全)
- 再次访问时,服务器会取出 HTTP 请求头内或是 POST 请求体内的 token,对 token 进行解码、校验,判断 token 是否有效,如果有效,那么该用户已登陆;如果无效,那么该用户的登陆已经过期,或者该 token 是伪造的(B)。如果根本没收到 token,说明还没登陆过(C)。
对情况A:验证该用户权限,决定是否放行
对情况B、C:均跳转到登录界面
Token 是什么?
上述验证的可行性依赖于 token 的结构,token 结构分为数据体和签名两个部分,数据体往往是经 Base64 等方式编码生成的字串,其中包含用户名、token 过期时间等信息;签名往往是通过 RS256 等非对称加密算法对数据体信息进行加密得到的。
签名的特点是,只有拥有私钥才可以生成签名,有公钥才可以对签名进行解密(从数学上是严格的,暴力破解的时间花费极大),而私钥会被严密保管在签名者处,公钥则由签名者公开,人人都可以拿到。
上述特点导致一个情况,就是签名无法伪造,但是人人都可以获得签名中的数据,所以有了签名,就可以确认数据体是否被人篡改了
也有使用 SHA256 等 Hash 算法生成签名的,再次对数据体信息进行 Hash 生成的新签名,然后与传递来的签名进行比对,相同则说明没有被篡改,但是这样只有后端可以校验签名,因为只有后端牢牢保管 Hash 的生成密钥,才能确保签名是后端生成而非伪造的。
优点
节省服务器内存,不需要 Session 模式下的大量内存
无 Cookie 不会因此受到 CSRF 攻击
缺点
大量的签名验证 CPU 开销略大
无法主动销毁 token,需要等 token 过期
token 易被劫持攻击
JSON Web Token⭐
JWT的官方简介:
JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。
一个 JWT 标准 token 包含三个部分——header、payload、signature,其中 header 和 payload 使用 Base64 编码,signature 使用 header 中注明的算法生成,未编码的 header 和 payload 格式如下:
- header
header 只有 alg 和 typ 两个 key
{
'alg': "RS256",
'typ': "JWT"
}
alg 注明了 signature 的生成算法,typ 注明了该 token 的类型(JWT)
- payload
payload 的 key 分为三类,分别是 Registered claims、Public claims、Private claims
Registered claims 是 JWT 标准保留的 key,用于 JWT 的验证,这些 key 是可选项
Public claims 和 Private claims 是签发者自定义的,Public claims 的特点是 value 应当都是 唯一标识符 类(Collision resistant),而 Private claims 的特点是允许重复(Not collision resistant)
以上只是个分类,简单记住:payload 除了 Registered claims 外,都是自定义的
{
//以下是 Registered claims
"iss": "https://jnn.icu/",
"aud": "https://pc.jnn.icu/",
"sub": "1035522103",
"exp": "1620108643",
//上面四个是最常见的 Registered claims
"nbf": "1610108643",
"iat": "1600108643",
"jti": "dfd1aa97-6d8d-4575-a0fe-34b96de2bfad",
//以下是 Public claims
"uuid": "37729e92-0bf7-426a-97e4-e4558a1c848b",
//以下是 Private claims
"name": "jnn",
"admin": true
}
Registered claims :
iss(Issuser):代表这个JWT的签发主体;
sub(Subject):代表这个JWT的主体,即它的所有人;
aud(Audience):代表这个JWT的接收对象;
exp(Expiration time):是一个时间戳,代表这个JWT的过期时间;
nbf(Not Before):是一个时间戳,代表这个JWT生效的开始时间,意味着在这个时间之前验证JWT是会失败的;
iat(Issued at):是一个时间戳,代表这个JWT的签发时间;
jti(JWT ID):是JWT的唯一标识。
Session 身份验证的实现
Django 提供了一套拓展性很强的用户认证系统,很香
待补充😅
JWT 身份验证的实现
各个语言下的 JWT 库其实应该都差不太多,大体应该都是两个主要接口,分别生成 JWT 令牌(encode)和解析 JWT 令牌(decode):
- 生成令牌:入参需要有签名方式(对称加密或者非对称加密),还有 payload 内容,输出返回个 JWT 令牌字符串;
- 解析令牌:输入 JWT 令牌字符串,通过抛异常或者返回值的方式告诉你这个令牌可不可以解析,是不是非法,是不是已过期,最终把解析出的 payload 返回给你(当然 抛异常就返回不了了)。
这里以 python 下 django 框架的一个案例为例,使用 PyJWT 2.4.0 库完成 JWT 的生成和解析。
想要详细了解需要去看 PyJWT 的文档,这里为了能让擅长其它语言的读者体验比较好,并且聚焦在 JWT 的概念上,就不详细展开了。
首先从 jwt
中引入编解码函数和解码异常类型:
from jwt import encode, decode, DecodeError
然后定义生成 JWT 令牌的函数:
def gen_token(username: str, role: str) -> str:
payload = {
'username': username,
'role': role
}
token = encode(payload, 'top-secret', algorithm='HS256')
return token
这个函数是我这个业务场景定义的,其实就是通过入参构造了 payload,然后调用 PyJWT 的编码函数生成了 JWT 令牌。这里稍微偷了个懒,没有用 RSA 生成令牌,直接使用 HS256 对称加密了(第二个参数是密钥)。
关于对称和非对称加密的在这里的区别,需要展开讲一下。首先说说非对称加密的 JWT,这种 JWT 常见于 OAuth 等场景,比如 XXX 平台授权第三方登录,就可以用 JWT 实现:payload 里包含一些用户的公开身份信息,然后用 RSA 私钥做个签名,这样获得授权的平台就能用公钥确认(看看签名是不是对的)这些信息是那个平台官方授权的,然后再从 payload 里拿出需要的信息去注册个新账号。
但是呢,如果用对称加密,签名的验证只有持有密钥的那一方才能验证签名了。所以 payload 被篡改了的话,第三方授权登录被授权的那一方根本没法判断自己拿到的 JWT 令牌是不是 OK 的。
以上就是对称加密和非对称加密 JWT 应用场景的区别,平时自己用,也没什么需要持有令牌的人验证令牌正确性的需求的话,使用对称加密是完全 OK 的。
下面来看看负责解析 JWT 的函数,这里使用了 Python 的装饰器,可以非常方便的用在处理 HTTP 请求的函数上:
def preprocess_token(limited_role: Role) -> Callable:
def decorator(request_handler: Callable[[RequestContext, HttpRequest], JsonResponse]):
@functools.wraps(request_handler)
def wrapper(request: HttpRequest):
token: str = request.META.get('HTTP_AUTHORIZATION')
if token is None:
return JsonResponse({
'code': RetCode.FAIL.value,
'message': '需要登录'
})
try:
token = token.removeprefix('Bearer ')
payload = decode(token, 'top-secret', algorithms=['HS256'])
except DecodeError:
return JsonResponse({
'code': RetCode.FAIL.value,
'message': 'JWT损坏'
})
username = payload['username']
role = Role[payload['role']]
if role != limited_role:
return JsonResponse({
'code': RetCode.FAIL.value,
'message': '无权限'
})
context = RequestContext(username, role) # 这个不需要理会,其实就是把 payload 再裹了一层
response: JsonResponse = request_handler(context, request) # 调用被修饰的函数
return response
return wrapper
return decorator
使用方法:
@preprocess_token(limited_role=Role.Admin)
def some_important_operation_handler(req: HttpRequest):
# do your things
pass
没有接触过装饰器的同学可能会不太理解,但其实主要看 wrapper
里面的内容就够了。可以把这个东西理解为一个劫持函数的语法糖,会在调被装饰器修饰的函数时,执行 wrapper
函数 。
在后端框架调用我的 some_important_operation_handler
时,会执行 wrapper
而不是原本的函数,我在 wrapper
里从 HTTP 的 headers 里拿出了 Authorization
字段,是 Bearer <MY_JWT_TOKEN>
的格式。然后我对提取出的 JWT 令牌字符串使用了 decode
方法尝试解码。外面用 try-catch 包裹,如果有问题会返回 JWT 损坏的响应(这里偷懒了,其实过期也是可以捕获到的,返回的状态可以更丰富)。
JWT 令牌没问题会拿到 payload,然后我再确认下 payload 里面的角色是不是我向装饰器传入的 Admin 角色,如果不是则告诉用户权限不足。如果权限没问题,这时这个用户就是符合调接口的条件的,再执行被装饰器修饰的函数,并且把它原本的返回值返回回去(不那么恰当的类比一下,其实这里的作用就像个中间件)。