后端用户认证系统构建详解

Django 发布于 Jan 24, 2021 更新于 Jul 19, 2022

一年没更新博客,发现学习到的东西还是要写下来梳理一下,要不很难理解透彻,有遗忘重新学效率也很低

常用的两种用户认证方式

Ⅰ 基于 Session 的身份验证

Session 本身是一个抽象的概念,有多种实现,这里就不讲复杂的多节点服务器下 session 身份验证的实现了,讲最普遍的 Session+Cookie

基于 Session 的身份验证,是最传统的验证方式,工作流程如下:

  1. 用户首次登陆成功时,后端生成一个 sessionId,储存在缓存中(还储存一些用户信息,以 sessionId 为 key)(比如用 Redis),设置一个过期时间,然后再把这个 sessionId 返回给用户的浏览器,浏览器再把 sessionId 储存在 cookie 中。

  2. 再次访问时,服务器会取出 HTTP 请求头内 cookie 中的 sessionId ,然后在缓存中寻找该字串,如果存在,那么该用户已登录(A);如果不存在,那么该用户的登陆已经过期(B)。如果根本没收到 session,说明还没登陆过(C)。

对情况A:验证该用户权限,决定是否放行

对情况B、C:均跳转到登陆界面

  1. 客户端调用后端 logout API时,清除缓存中 sessionId key下所有数据

优点

提供了用户认证功能,使无状态的 HTTP 请求有状态化

缺点

本地储存 cookie 不安全,session 储存占用服务器资源

跨域请求不便/不安全,记个 TODO,下次写写 CORS/CSRF

Ⅱ 基于 Token 的身份验证

基于 Token 的身份验证,近几年越来越常见,特点是服务器不用储存登陆状态信息

基于 Token 的身份验证,工作流程如下:

  1. 用户首次登陆成功时,后端生成一个 token,不在后端储存,把 token 发送给用户,用户拿到 token 后将 token 储存在 localStorage(不储存在 cookie,因为不安全)
  2. 再次访问时,服务器会取出 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 角色,如果不是则告诉用户权限不足。如果权限没问题,这时这个用户就是符合调接口的条件的,再执行被装饰器修饰的函数,并且把它原本的返回值返回回去(不那么恰当的类比一下,其实这里的作用就像个中间件)。

标签