JWT、Access Token 与 Refresh Token:深入解析与实战指南
在现代网络应用和API安全领域,JSON Web Token (JWT) 已成为一种主流的身份验证与授权解决方案。它通常与 Access Token 和 Refresh Token 结合使用,以在不牺牲安全性的前提下,提供流畅的用户体验。本文将深入探讨这三者的概念、它们之间的关系、主流认证库的管理方式,以及如何自行实现一套安全的认证系统。
什么是 JWT?
JWT(JSON Web Token)是一种开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息。 这些信息之所以可以被验证和信任,是因为它们经过了数字签名。
一个JWT由三部分组成,通过点(.)分隔,分别是:
- Header (头部):通常由两部分组成:令牌的类型(即JWT)和所使用的签名算法(如HMAC SHA256或RSA)。
- Payload (负载):包含被称为“声明” (claims) 的信息。声明是关于实体(通常是用户)和其他数据的陈述。JWT规定了7个官方字段供选用,如
iss(签发人)、exp(过期时间)、sub(主题)等。 你也可以包含自定义的私有声明。需要注意的是,JWT默认是不加密的,因此不应在负载中存放敏感信息。 - Signature (签名):用于验证消息在传递过程中没有被篡改。签名是通过将编码后的头部、编码后的负载和一个密钥(secret)使用头部指定的算法进行计算生成的。 这个密钥必须保存在服务器端,不能泄露。
其工作原理是,用户登录成功后,服务器会生成一个JWT并返回给客户端。之后,客户端在每次请求需要认证的资源时,都会在请求头中携带这个JWT,服务器通过验证JWT的签名来确认用户的身份。
Access Token 与 Refresh Token 的双令牌机制
为了兼顾安全性和用户体验,通常会采用 Access Token 和 Refresh Token 的双令牌机制。
Access Token (访问令牌)
- 用途:客户端用于访问受保护资源的凭证。 每次请求API时,客户端都需要在HTTP请求的
Authorization头中携带Access Token。 - 特点:生命周期通常很短,例如15分钟到1小时。 这样即使Access Token被窃取,攻击者也只能在很短的时间内利用它,从而降低了安全风险。
Refresh Token (刷新令牌)
- 用途:专门用于获取新的Access Token。 当Access Token过期后,客户端可以使用Refresh Token向服务器请求一个新的Access Token,而无需用户重新登录。
- 特点:生命周期较长,可以是几天、几周甚至更长时间。 Refresh Token的使用频率远低于Access Token,并且只在与特定的授权服务器端点交互时使用。
为什么需要双令牌机制?
这种设计旨在平衡安全与便利:
- 提升安全性:短暂的Access Token生命周期大大降低了令牌泄露所带来的风险。 即使Access Token被截获,其有效期也很短。
- 改善用户体验:通过Refresh Token自动刷新Access Token,用户无需在短时间内频繁地重新输入用户名和密码登录,实现了“无感刷新”。
- 更强的控制力:服务器可以随时撤销某个Refresh Token,从而强制特定用户下线,而不会影响到其他用户。
主流 Auth 库如何管理 Token
市面上流行的认证库(如 Auth0、Okta 的SDK,以及开源的 jwt-auth for Laravel, Auth.js 等)通常会自动化管理Access Token和Refresh Token的整个生命周期,主要包括以下几个方面:
- 令牌的生成与分发:在用户成功登录后,认证库会在服务器端生成一个短期的Access Token和一个长期的Refresh Token,并将它们一同返回给客户端。
- 客户端存储:库通常会提供客户端的SDK来帮助安全地存储这些令牌。一种常见的做法是将Access Token存储在内存中(例如JavaScript变量),而将Refresh Token存储在更安全的地方,如浏览器的
HttpOnlyCookie中,以防止跨站脚本(XSS)攻击。 - 自动刷新:许多客户端库会封装API请求。当检测到API返回401 Unauthorized(表示Access Token过期)的错误时,它们会自动使用存储的Refresh Token向服务器请求新的Access Token,成功获取后再重新发起之前失败的API请求。这一切对用户和开发者来说都是透明的。
- Refresh Token 旋转 (Rotation):为进一步增强安全性,一些先进的认证库支持Refresh Token旋转。 这意味着每次使用Refresh Token获取新的Access Token时,服务器不仅会返回一个新的Access Token,还会返回一个新的Refresh Token,并使旧的Refresh Token失效。 这样一来,即使Refresh Token被盗,它也只能被使用一次,大大降低了风险。
- 服务端验证:在服务器端,认证库提供中间件或函数,可以轻松地保护API路由。这些中间件会自动从请求头中提取JWT,验证其签名和有效期,并解析出用户信息附加到请求对象上,供后续业务逻辑使用。
如何自己实现 JWT 认证
如果你想自己实现一套JWT认证系统,需要考虑以下几个关键步骤:
1. 用户登录与令牌生成
- 创建一个登录接口,接收用户名和密码。
- 验证用户凭据。如果成功,使用一个JWT库(例如Java的
jjwt,Node.js的jsonwebtoken)来创建Access Token和Refresh Token。 - 为Access Token设置一个较短的过期时间(如15分钟),为Refresh Token设置一个较长的过期时间(如7天)。
- 将用户的ID或其他非敏感信息作为Payload的一部分存入JWT中。
- 将两个令牌返回给客户端。
2. 客户端存储令牌
- Access Token: 存储在内存中(如JavaScript变量),因为它需要被频繁读取并添加到API请求头中。
- Refresh Token: 存储在
HttpOnly的Cookie中。这样可以防止客户端的JavaScript脚本读取它,有效防范XSS攻击。同时,Cookie会在后续请求刷新令牌的接口时自动被浏览器发送。
3. 服务器端实现
- 保护API路由: 创建一个中间件(Middleware),用于保护需要认证的API路由。这个中间件会:
- 从
Authorization请求头中提取Access Token。 - 验证Token的签名和有效期。如果验证失败,返回401错误。
- 如果验证成功,从Payload中解析出用户信息,并传递给后续的请求处理函数。
- 从
- 刷新令牌接口: 创建一个专门用于刷新令牌的接口(例如
/api/refresh_token)。这个接口会:- 从
HttpOnlyCookie中获取Refresh Token。 - 验证Refresh Token的有效性。你可能需要一个数据库或缓存来存储有效的Refresh Token列表,以便可以随时撤销它们。
- 如果有效,生成一个新的Access Token(甚至可以实现Refresh Token旋转,同时生成一个新的Refresh Token),并返回给客户端。
- 从
4. 客户端实现
- 发送API请求: 封装一个统一的API请求函数或使用拦截器(如axios interceptors)。在每次发送请求时,从内存中读取Access Token并添加到
Authorization: Bearer <token>请求头中。 - 处理Token过期: 在API请求的响应拦截器中,检查是否收到了401错误。
- 如果收到401错误,调用刷新令牌的接口。
- 成功获取到新的Access Token后,将其更新到内存中。
- 使用新的Access Token重新发送之前失败的那个API请求。
- 如果刷新令牌也失败了(例如Refresh Token也过期了),则清除所有本地存储的认证信息,并将用户重定向到登录页面。
通过以上步骤,你就可以构建一个功能完善且相对安全的JWT认证系统。核心思想在于通过双令牌机制,利用Refresh Token的安全性来弥补Access Token因频繁传输而可能带来的风险,从而在安全性和用户体验之间找到最佳平衡点。
JWT认证流程时序图
下面是一个基本的时序图,描述了用户登录、获取令牌、访问受保护资源以及刷新令牌的完整生命周期。
流程详解
下面是时序图中各个步骤的详细文字说明,以帮助你更好地理解整个认证授权的生命周期。
1. 用户登录 (Login)
- 用户操作: 用户在客户端应用的登录界面输入他们的用户名和密码。
- 客户端请求: 客户端将这些凭据通过一个
POST请求发送到认证服务器的登录端点(例如/api/login)。为了安全,这个过程必须使用 HTTPS 进行加密传输。
2. 服务器验证与令牌生成
- 凭据验证: 认证服务器收到请求后,会查询数据库验证用户名和密码是否匹配。
- 令牌生成: 如果凭据验证成功,认证服务器会执行以下操作:
- 生成 Access Token: 创建一个JWT作为Access Token。这个Token的Payload中会包含用户的ID、角色等非敏感信息,并设置一个较短的过期时间(例如,15分钟)。
- 生成 Refresh Token: 创建一个具有更长生命周期的令牌(例如,7天或更长)。这个令牌通常是一个不透明的随机字符串,其与用户信息的关系存储在服务器端(例如数据库或Redis缓存中),以备后续验证。
- 返回令牌: 服务器将生成的 Access Token 和 Refresh Token 一同返回给客户端。一个常见的做法是将 Access Token 放在响应体(response body)中,而将 Refresh Token 放在一个
HttpOnly的Cookie中,以增强安全性。
3. 客户端存储令牌
- Access Token 存储: 客户端收到令牌后,会将 Access Token 存储在内存中(例如,一个JavaScript变量)。不建议将其存储在
localStorage或sessionStorage中,因为这容易受到XSS攻击。 - Refresh Token 存储: 由于 Refresh Token 已被服务器设置为
HttpOnlyCookie,浏览器会自动存储它,并且客户端的 JavaScript 代码无法访问到这个Cookie,从而有效防止了XSS攻击。
4. 访问受保护资源
- 发起请求: 当用户需要访问受保护的API资源时(例如,获取用户信息
/api/me),客户端会在HTTP请求的Authorization头部(Header)中附加上 Access Token。格式通常为Bearer <access_token>。 - 服务器验证: 资源服务器(API Server)收到请求后,会从请求头中提取 Access Token,并对其进行验证:
- 检查签名的合法性,确保令牌未被篡改。
- 检查令牌是否已过期。
- 返回结果:
- 如果令牌验证通过,服务器会处理该请求并返回所请求的数据。
- 如果令牌无效(例如签名错误或已过期),服务器会拒绝请求,并返回一个
401 Unauthorized的HTTP状态码。
5. 刷新 Access Token
- 检测到过期: 当客户端收到来自资源服务器的
401错误时,它会知道当前的 Access Token 已经过期。 - 发起刷新请求: 客户端会自动向认证服务器的刷新端点(例如
/api/refresh_token)发起一个请求。由于之前 Refresh Token 已被存储在HttpOnlyCookie中,浏览器会在发送这个请求时自动带上它。 - 服务器验证 Refresh Token: 认证服务器接收到请求后,会从Cookie中获取Refresh Token,并验证其是否有效(例如,查询数据库看它是否存在且未被撤销)。
- 返回新令牌:
- 如果 Refresh Token 有效,认证服务器会生成一个新的 Access Token,并将其返回给客户端。为了进一步提高安全性,可以同时生成一个新的 Refresh Token(这种做法称为 Refresh Token Rotation),使旧的Refresh Token失效。
- 如果 Refresh Token 无效(例如也已过期或已被撤销),服务器会返回
401错误。此时,客户端会清除本地所有认证信息,并引导用户返回登录页面重新进行身份验证。
- 更新并重试: 客户端收到新的 Access Token 后,会用它更新内存中的旧Token,然后自动重新发起之前因
401错误而失败的API请求。这个过程对用户是无感的,从而提供了流畅的体验。
第三方认证与本地数据库集成:从零开始的实战指南
本章将阐述如何将第三方认证的JWT与本地数据库集成,实现用户登录和RLS。
1. 总体架构设计
在开始之前,我们需要明确整个系统的架构。大致流程如下:
- 用户发起第三方登录: 用户在你的应用前端点击“使用Google登录”或类似的按钮。
- 第三方认证: 用户被重定向到第三方认证提供商(如Google)。用户在那里登录并授权你的应用访问其基本信息(如邮箱、用户名)。
- 接收授权码: 第三方认证提供商将用户重定向回你的应用,并附带一个授权码。
- 交换令牌: 你的后端服务器使用这个授权码向第三方认证提供商请求Access Token和ID Token(JWT)。
- 验证ID Token: 你的后端服务器验证ID Token的签名和声明(claims)。
- 用户身份关联: 从ID Token中提取用户信息,然后在你的本地数据库中查找或创建该用户。
- 生成会话Token: 你的后端服务器为该用户生成一个你自己的JWT(包含用户ID等信息),用于后续的会话管理和授权。
- 返回会话Token: 将这个会话Token返回给客户端。
- 后续请求: 客户端在后续的请求中携带这个会话Token,你的后端服务器使用它来识别用户,并执行相应的权限检查(包括RLS)。
2. 开发步骤详解
下面我们将详细分解每个步骤,并提供相应的代码示例(伪代码)以供参考。
2.1 前端集成第三方登录
首先,在你的前端应用中集成第三方登录SDK(例如,Google Sign-In JavaScript API)。
// 示例:使用 Google Sign-In JavaScript API
function onSignIn(googleUser) {
const id_token = googleUser.getAuthResponse().id_token;
// 将 ID Token 发送到你的后端服务器
fetch('/api/auth/google', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token: id_token })
})
.then(response => response.json())
.then(data => {
// 存储从后端返回的会话Token
localStorage.setItem('sessionToken', data.token);
});
}
2.2 后端处理第三方登录
在你的后端服务器上,创建一个处理第三方登录请求的API端点(例如 /api/auth/google)。
# 示例:Python (Flask)
from google.oauth2 import id_token
from google.auth.transport import requests
import jwt
from flask import Flask, request, jsonify
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
app = Flask(__name__)
# 数据库配置 (示例:PostgreSQL)
DATABASE_URL = "postgresql://user:password@host:port/database"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# 定义用户模型
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
google_id = Column(String, unique=True, index=True) # 用于存储 Google ID
email = Column(String, unique=True, index=True)
name = Column(String)
Base.metadata.create_all(bind=engine)
# JWT 配置
JWT_SECRET = "your-secret-key"
JWT_ALGORITHM = "HS256"
@app.route("/api/auth/google", methods=["POST"])
def google_auth():
token = request.json.get("token")
try:
# 1. 验证 Google ID Token
idinfo = id_token.verify_oauth2_token(token, requests.Request(), "YOUR_GOOGLE_CLIENT_ID")
# 2. 从 ID Token 中提取用户信息
google_id = idinfo['sub']
email = idinfo['email']
name = idinfo['name']
# 3. 在本地数据库中查找或创建用户
db = SessionLocal()
user = db.query(User).filter(User.google_id == google_id).first()
if not user:
user = User(google_id=google_id, email=email, name=name)
db.add(user)
db.commit()
db.refresh(user)
# 4. 生成你自己的 JWT 作为会话 Token
payload = {
"user_id": user.id, # 将本地用户 ID 放入 Payload
"email": user.email,
"name": user.name
}
session_token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
return jsonify({"token": session_token})
except ValueError:
# Invalid token
return jsonify({"error": "Invalid Google ID token"}), 400
关键步骤解释:
- 验证 Google ID Token: 使用
google-auth库验证从前端接收到的ID Token的签名和声明。确保它是有效的,并且是由Google签发的。 - 提取用户信息: 从验证后的ID Token中提取用户的Google ID、邮箱和姓名。
- 数据库查找或创建: 使用提取的Google ID在你的本地数据库中查找用户。如果用户不存在,则创建一个新用户,并将Google ID、邮箱和姓名存储在数据库中。
- 生成会话Token: 使用
jwt库生成一个你自己的JWT,作为会话Token。在这个Token的Payload中,你需要包含用户的本地ID(数据库中的ID),以及其他你需要的用户信息(例如,邮箱、角色等)。 - 返回会话Token: 将生成的会话Token返回给客户端。
2.3 集成 RLS(Row-Level Security)
现在,你已经有了用户的本地ID,可以将其用于RLS。
假设你的数据库是PostgreSQL,你可以按照以下步骤集成RLS:
-
创建 Policy: 为你需要保护的表创建一个RLS Policy。这个Policy会根据用户的ID来过滤数据。
-- 假设你有一个名为 `products` 的表,其中包含一个 `owner_id` 列
CREATE POLICY user_products ON products
FOR SELECT
TO authenticated_user -- 假设你的应用使用一个名为 "authenticated_user" 的数据库角色
USING (owner_id = current_setting('app.user_id')::integer);解释:
authenticated_user: 这是你的应用连接数据库时使用的数据库角色。current_setting('app.user_id'): 这是一个PostgreSQL函数,用于获取当前会话的自定义变量。我们将使用它来传递用户的ID。
-
设置
app.user_id: 在你的后端代码中,当你验证了JWT之后,设置app.user_id变量。# 示例:在 Flask 中设置 PostgreSQL 的 app.user_id
@app.before_request
def before_request():
token = request.headers.get('Authorization')
if token:
try:
# 从 "Bearer <token>" 中提取 Token
token = token.split(" ")[1]
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
user_id = payload.get("user_id")
# 设置 PostgreSQL 的 app.user_id
db = SessionLocal()
db.execute("SET app.user_id = :user_id", {"user_id": user_id})
db.commit()
request.user_id = user_id # 也可以将 user_id 存储在请求对象中方便后续使用
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token has expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "Invalid token"}), 401
# 在你的 API 路由中使用 RLS
@app.route("/api/products", methods=["GET"])
def get_products():
db = SessionLocal()
# SQLAlchemy 查询会自动应用 RLS Policy
products = db.query(Product).all()
return jsonify([{"id": p.id, "name": p.name} for p in products])解释:
before_request: Flask 的一个钩子函数,在每个请求之前运行。- 从
Authorization头部提取JWT,并验证它。 - 从JWT的Payload中提取用户ID。
- 使用SQLAlchemy的
db.execute()方法执行一个SQL命令,设置PostgreSQL的app.user_id变量。 - 现在,当你的应用执行任何查询
products表的操作时,PostgreSQL会自动应用你之前创建的RLS Policy,只返回当前用户拥有的产品。
3. 安全性注意事项
- 验证第三方令牌: 始终在后端验证从第三方认证提供商收到的令牌。不要信任客户端发送的任何数据。
- 保护你的JWT密钥: 将你的JWT密钥(
JWT_SECRET)保存在安全的地方,例如环境变量或密钥管理系统。 - 使用HTTPS: 始终使用HTTPS来加密所有网络通信,包括登录、令牌交换和API请求。
- 防止重放攻击: 考虑使用一次性令牌(nonce)来防止重放攻击。
- 限制令牌的权限: 只在JWT中包含必要的声明(claims)。不要包含敏感信息。
- 监控和审计: 监控你的认证系统,并记录所有重要的事件(例如登录、令牌刷新)。
总结
通过以上步骤,你可以将第三方认证的JWT与你的本地数据库集成,实现用户身份验证和行级安全。