前言 #
在开发 Web 应用时,用户认证是一个避不开的话题。最近在用 FastAPI 做后端项目的时候,接触到了 JWT1 这种认证方式,觉得挺有意思的,于是做了一些研究,在这里记录一下。
JWT 是什么 #
JWT 的全称是 JSON Web Token,简单来说它是一种令牌格式。令牌的内容是编码后的 JSON,且常用在 Web 领域的身份校验,所以称之为 JWT。
一般来说,用户先用自己的用户名和密码(OAuth 之类的第三方认证暂时略去)发送到服务器,服务器验证通过后给用户签发一个 Token。这个 Token 中包含了必要的信息,比如签发人、主题(一般是用户 ID)、有效时间等。之后服务器便不再需要用户名和密码,单靠这个 Token 就可以确认用户的身份。
JWT 解决了什么问题 #
很多人可能会问:既然用户已经有了用户名和密码,何必还要多此一举?先生成 Token 再使用,而不是直接用用户名密码?就好像 HTTP Basic Authentication2 那样,简单粗暴,靠 HTTPS 也能保证基本的安全。
主要有两个原因:第一个是安全,第二个是压力。
安全比较好理解。与经常把明文的用户名和密码放在请求里传输相比,可以带有失效机制的 JWT 显然更加安全。
第二个原因我觉得更为主要,那就是压力。我们不妨先想想用户名和密码的鉴权过程是什么样的:
- 用户发出请求,在 HTTP 请求中带上 Base64 编码后的用户名和密码。
- 服务器解码请求,将用户名、密码与数据库中的内容(一般密码部分只存 Hash)作比对,返回通过或不通过。
如果每秒只有几个请求,这样的设计当然没问题。但如果这是一个每秒请求量数万的服务,仅做鉴权校验就会让数据库承受非常大的压力。而且目前相对成熟的数据库往往是单点的(涉及到事务的东西,即使能扩容也要脱一层皮)。
那有没有可能,我们签发一个令牌给用户,以后不管是本服务器的其他副本(Replica),还是跨服务器,都能进行验证,而且验证的过程不需要查数据库?这样的话,服务器就真正变成了无状态的。熟悉分布式系统的小伙伴都知道,无状态可太省事了。
JWT 的安全性是如何保障的 #
既然就是 Base64 编码过的 JSON,那用户自己捏一个骗服务器说这是 Token 可以吗?
当然不可以。JWT 会进行数字签名,最后一部分会使用服务器独有的密钥和前两部分的内容一起生成签名。由于这个密钥只有服务器才有,而且通过内容也几乎不可能反推出密钥,所以攻击者无法伪造 JWT。
JWT 的结构详解 #
JWT 的结构分为三块:
- 头部 Header
- 载荷 Payload
- 签名 Signature
三个部分都是 Base64 编码的字符串,用 . 连接起来。一个典型的 JWT 如下所示:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.cThIIoDvwdueQB468K5xDc5633seEFoqwxjF_xSJyQQ其中头部解码后为:
{
"alg": "HS256",
"typ": "JWT"
}载荷解码后为:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}最后一部分是签名,用于校验前两部分是否被篡改。
如何使用 JWT #
HTTP Header #
理论上,只要把 JWT 传给服务器就行了。RFC 75193 也没有规定必须在哪个位置使用 JWT。但考虑到它是个 Token,一般的使用方式是放在 Request Header 里:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.cThIIoDvwdueQB468K5xDc5633seEFoqwxjF_xSJyQQ以 FastAPI 为例 #
这里用的是 python-jose4,直接 pip install "python-jose[cryptography]" 即可。也可以使用其他库,详见 https://jwt.io/libraries。
出于简洁考虑,这里略去 FastAPI 的框架代码,只展示核心部分。
首先创建一个 Endpoint 来签发 Token:
from fastapi.security import HTTPBasic, HTTPBasicCredentials
http_basic = HTTPBasic()
def create_jwt_access_token(
data: dict,
expires_delta: timedelta,
) -> str:
to_encode = data.copy()
to_encode.update({"exp": datetime.utcnow() + expires_delta})
return jwt.encode(
to_encode,
"jwt_secret", # 请替换为你自己的密钥
algorithm="HS256",
)
@app.post("/auth/issue-new-token")
def issue_new_token(
credentials: HTTPBasicCredentials = Depends(http_basic),
):
username = basic_authentication(credentials) # 验证用户名密码
access_token = create_jwt_access_token(
data={"sub": username},
expires_delta=timedelta(seconds=1234),
)
return {"access_token": access_token, "token_type": "bearer"}这是一个极简版的 JWT 生成接口。需要注意的是,这里既没有 Rate Limit,也没有针对错误进行处理,只是一个基本的演示。
接下来是验证 JWT 的函数:
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
http_bearer = HTTPBearer()
def jwt_authentication(
credentials: HTTPAuthorizationCredentials = Depends(http_bearer),
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
if credentials.scheme.lower() != "bearer":
raise credentials_exception
return jwt.decode(
credentials.credentials,
"jwt_secret", # 请替换为你自己的密钥
algorithms=["HS256"],
)
except JWTError:
raise credentials_exception在需要认证的接口中加入依赖即可:
# 方式一:只验证,不获取 payload
@app.get("/api/protected", dependencies=[Depends(jwt_authentication)])
def protected_api():
...
# 方式二:获取 payload 做进一步处理
@app.get("/api/protected")
def protected_api(jwt_payload: dict = Depends(jwt_authentication)):
# 处理 jwt_payload
...前端使用思路 #
由于很多组件都会依赖后端的 API,且这些 API 需要 JWT,所以一般会用 Context 来管理 JWT,以便于跨组件传递。
在制作 AuthStateContext.Provider 的时候,我的思路是:
- 由于用了 OAuth,JWT 会从后端通过 query string 传入,首先检查 query string 有没有新的 Token
- 如果有,提取出来存到 localStorage
- 如果没有,进入下一步
- 检查 localStorage 里有没有存储的 Token
- 如果有,检查是否有效
- 如果没有,或上一步的结果为无效,进入下一步
- 调用 “who am I” 接口,让后端校验 JWT(这一步可选,但可以增加可靠性)
- 如果所有校验都通过,将 JWT 状态通过 Provider 传递给其他组件
- 否则跳转到相应页面提示用户
其他组件使用 useContext(AuthStateContext) 即可获取已验证的 Token。
JWT 的几个缺陷 #
JWT 还是有几个小缺陷的,在设计系统时需要考虑:
- JWT 一旦签发就难以撤回,这是无状态带来的代价。
- 考虑到第一点,往往需要设置较短的有效期,客户端需要维护 Token 的刷新。
- JWT 中的信息默认不加密,任何人都可以读取,所以不要把敏感信息放在里面。
- JWT 推荐在 HTTPS 环境下使用。