
在PyJWT基础使用篇用四步实例讲清楚了签发、验签、篡改、过期的原理。但那些都是零散的代码片段,不能直接跑成一个服务。这篇把它们拼成一个完整的 Flask 登录接口:登录签发 Token、装饰器验证 Token、受保护接口取用户信息,从头到尾整个环节的实现。
准备工作
1. 安装包
只需要两个包:
bash
pip install Flask PyJWT
2. 生成密钥
还需要一个密钥。JWT 的安全性全靠它。谁拿到密钥,谁就能伪造任意 Token。所以不能硬编码在代码里,要从环境变量读取:
bash
# 启动前先生成并设置
export JWT_SECRET_KEY=$(openssl rand -hex 32)
为什么是 32?openssl rand -hex 32 生成 32 字节的随机数据,展开成 64 个十六进制字符,信息量 256 bit,对 HS256 签名来说绰绰有余。
-
这里,我使用
secrets.token_hex(32)来生成SECRET_KEY:bashSECRET_KEY = "190d7fb08cbcf7c8b1096bb14ba0daea3306144a58001b97b3423d6a7dfa7103"之后也通过
export将密钥写入环境变量。bash(web_env) user % export JWT_SECRET_KEY=190d7fb08cbcf7c8b1096bb14ba0daea3306144a58001b97b3423d6a7dfa7103 (web_env) user % echo $JWT_SECRET_KEY 190d7fb08cbcf7c8b1096bb14ba0daea3306144a58001b97b3423d6a7dfa7103 -
SECRET_KEY从环境变量读,如果没设置会直接KeyError崩掉,这是故意的。与其用一个默认值悄悄跑起来,不如在启动时就崩掉提醒你配置密钥。 用默认值的代码在开发阶段方便,但上线忘改就是安全事故。pythonimport os SECRET_KEY = os.environ["JWT_SECRET_KEY"]
一、整体结构
整个应用就三块:

登录接口负责"发通行证",装饰器负责"查通行证",业务接口只管自己的事。
1.1 登录接口 POST /login
用户提交用户名和密码,服务器验证通过后签发一个 JWT Token 返回给客户端。这是整个流程的起点,用户只需要在这一步证明"我是谁",后面的请求都不用再传密码了。
1.2 验证装饰器 @login_required
每个需要登录才能访问的接口,都要先检查请求头里有没有合法的 Token。这件事每个接口都要做,但逻辑完全一样。从 Authorization 头取 Token、验签、取出用户信息,所以抽成一个装饰器,往接口上一挂就行,不用每个接口重复写。
1.3 业务接口 GET /profile
挂上 @login_required 之后,这个接口就自动受保护了。它本身只关心业务逻辑,从 request.user 取用户信息、拼响应返回。验签的事装饰器已经做完了,到这一步的请求一定是合法的。
三块之间是单向依赖 的:业务接口依赖装饰器,装饰器依赖登录接口签发的 Token,但它们彼此不知道对方的实现细节。以后要加新的受保护接口,只要挂上 @login_required,一行搞定;要换验签逻辑(比如从 HS256 换成 RS256),只改装饰器,所有接口自动生效。
二、模拟数据
使用用字典模拟数据库,真实项目里这里会换成数据库查询,密码也不会存明文(会用 bcrypt 之类的库做哈希),但那是认证系统的话题,不是 JWT 的,这里只聚焦 Token 的签发和验证。
python
USERS = {
"zhangsan": {"password": "123456", "user_id": 1, "role": "user"},
"admin": {"password": "admin888", "user_id": 2, "role": "admin"},
}
三、登录接口
这个接口是整个流程的入口。用户提交用户名和密码,服务器要做两件事:验证身份 和签发通行证 。

简单说,分为四步:取参数 → 查用户比密码(不对就 401 打回)→ 签发 Token → 返回给客户端。密码错误在第二步就被拦掉了,后面的步骤不会执行。
3.1 过程分析
(1) 从请求体中取出用户名和密码
客户端发过来的是 JSON 格式的请求体({"username": "zhangsan", "password": "123456"}),Flask 的 request.json 会自动解析成 Python 字典,直接用 .get() 取值就行。
(2) 拿用户名去查用户,比对密码
在 USERS 字典里查有没有这个用户,有的话再比密码。如果用户不存在或者密码不对,直接返回 401(HTTP 状态码,表示"未授权")。
(3) 签发Token
密码对了,用 jwt.encode() 签发 Token。 把用户信息(user_id、username、role)和过期时间打包成 Payload,用密钥签名生成 JWT。
(4) 返回Token给客户端
客户端拿到Token后保存起来(通常存在 localStorage 或内存里),后续请求带上它就不用再传密码了。
3.2 实现
理解了上述流程,代码就是将这四步翻译成 Python。
python
@app.route("/login", methods=["POST"])
def login():
# 第一步:取用户名密码
username = request.json.get("username")
password = request.json.get("password")
# 第二步:查用户、比密码
user = USERS.get(username)
if not user or user["password"] != password:
return jsonify({"error": "用户名或密码错误"}), 401
# 第三步:签发 Token
token = jwt.encode(
{
"user_id": user["user_id"],
"username": username,
"role": user["role"],
"exp": datetime.now(timezone.utc) + timedelta(hours=2),
},
SECRET_KEY,
algorithm="HS256",
)
# 第四步:返回 Token
return jsonify({"token": token})
-
@app.route("/login", methods=["POST"])告诉 Flask,当客户端向
/login发送 POST 请求时,调用下面这个函数处理。为什么用 POST 不用 GET?因为登录要提交密码,GET 请求的参数会出现在 URL 里(/login?password=123456),浏览器历史记录、服务器日志都能看到。POST 把数据放在请求体里,不会暴露在 URL 中。 -
exp 过期时间 设成
datetime.now(timezone.utc) + timedelta(hours=2),意思是"当前 UTC 时间往后加 2 小时"。PyJWT 收到 datetime 对象后会自动转成 Unix 时间戳写进 Token。验签时如果发现当前时间超过了exp,就抛ExpiredSignatureError,这里不需要你自己写时间判断逻辑。 -
return jsonify({"error": "..."}), 401这种写法是 Flask 的惯用语法。jsonify把字典变成 JSON 响应,后面的401是 HTTP 状态码。逗号分隔的两个值,Flask 会自动组装成完整的 HTTP 响应。
四、验证装饰器:把"查 Token"抽出来
每个需要登录的接口都要做同一件事:从请求头取 Token → 验签 → 取出用户信息。这段逻辑在每个受保护接口里都一模一样,如果每个接口都写一遍,代码会重复得很难看。Python 的装饰器(decorator)就是解决这种问题的,把通用逻辑写成一个函数,往接口上一挂就行。
如果你不熟悉装饰器语法也没关系,这里只需要知道一件事:@login_required 加在接口函数上面,意思是"先跑 login_required 里的逻辑,通过了再跑接口本身"。
python
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "缺少 Token"}), 401
token = auth_header.split(" ")[1]
try:
data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token 已过期"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "无效的 Token"}), 401
request.user = data
return f(*args, **kwargs)
return decorated
4.1 入场检查
前两行是"门卫检查入场券"。
python
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "缺少 Token"}), 401
request.headers.get("Authorization", "") 从请求头里取 Authorization 字段。按照 HTTP 的约定,客户端应该把 Token 放在这个头里,格式是 Bearer eyJhbGciOi...。如果这个头不存在或者格式不对(不是以 Bearer 开头),直接 401 拒绝,连门都不让进。
4.2 验票过程
中间的 try/except 是"验票"。
python
token = auth_header.split(" ")[1]
try:
data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token 已过期"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "无效的 Token"}), 401
auth_header.split(" ")[1] 用空格切割,取第二部分就是纯 Token 字符串。因为原始的Authorization 头长这样:
bash
Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjN9.abc123signature
用空格切割后得到两部分:
| 索引 | 值 | 说明 |
|---|---|---|
| [0] | Bearer | 固定前缀,表示认证方式 |
| [1] | eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjN9.abc123signature | 完整的JWT |
然后交给 jwt.decode 验签,用密钥重新算一次签名,和 Token 里的签名比对。对不上就说明 Token 是假的或者被篡改过。
4.3 通过验签
最后request.user = data 是"放行并盖章"。
request.user = data
return f(*args, **kwargs)
验签通过后,jwt.decode 返回的就是 Payload 里的用户信息(user_id、username、role 这些)。把它挂到 request.user 上,后面的接口函数就能直接用,不需要再解析一次 Token。return f(*args, **kwargs) 是"放行",调用被装饰的那个接口函数。
4.4 注意事项
@wraps(f)不能省。 没有它,被装饰的函数会"丢掉"自己的名字。profile.__name__会变成decorated而不是profile。Flask 用函数名做路由映射,名字重复会报错。@wraps把原函数的名字和文档字符串保留下来。algorithms=["HS256"]必须显式传。 这是安全设计,防止攻击者在 Token 的 Header 里把算法改成none,绕过签名验证。request.user = data把用户信息挂到请求对象上。 这样后面的接口函数可以直接通过request.user拿到用户信息,不需要再解析一次 Token。- 异常分两层捕获。
ExpiredSignatureError是InvalidTokenError的子类,但单独捕获是为了返回不同的错误信息。"过期"和"无效"对客户端来说需要不同的处理逻辑(过期可以尝试刷新,无效只能重新登录)。
五、受保护接口
体现装饰器的用法
前面写了一大段装饰器,现在看看用起来有多简单:
python
@app.route("/profile")
@login_required
def profile():
return jsonify({
"message": f"你好,{request.user['username']}",
"role": request.user["role"],
})
if __name__ == "__main__":
app.run(debug=True)
就这么几行。@login_required 往上一挂,这个接口就自动受保护了。没有 Token 的请求根本走不到 profile() 函数体里,在装饰器那一层就被 401 打回去了。
函数体里直接用 request.user['username'] 取用户信息,不需要自己解析 Token。这个 request.user 是装饰器在验签成功后挂上去的,到这一步你可以放心地假设:用户身份已经确认过了,数据是可信的。
这就是装饰器模式的好处,权限校验和业务逻辑彻底分开 。profile 函数不知道 Token 是怎么验证的,login_required 也不关心接口要返回什么数据。以后要加新接口,比如 GET /orders、POST /settings,只要需要登录,挂上 @login_required 就行,业务代码里一行验证逻辑都不用写:
python
@app.route("/orders")
@login_required
def orders():
# 直接用 request.user,不用操心验签
return jsonify({"user": request.user["username"], "orders": [...]})
六、跑起来试试
代码写完了,启动服务验证一下。
6.1 启动
先设密钥环境变量,再启动 Flask:
bash
export JWT_SECRET_KEY=190d7fb08cbcf7c8b1096bb14ba0daea3306144a58001b97b3423d6a7dfa7103
python jwt_login_app.py
6.2 登录拿 Token
bash
curl -X POST http://localhost:5000/login \
-H "Content-Type: application/json" \
-d '{"username": "zhangsan", "password": "123456"}'
json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6InpoYW5nc2FuIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NzQ1MTUwMDd9.747sDLBOTPLUwXkwzVdAUjl3tC-nud3LIHpgxajgXRQ"
}
6.3 带 Token 访问
bash
curl http://localhost:5000/profile \
-H "Authorization: Bearer eyJhbGciOi..."
json
{"message": "你好,zhangsan", "role": "user"}
6.4 不带 Token
bash
curl http://localhost:5000/profile
json
{"error": "缺少 Token"}
6.5 错误密码
bash
curl -X POST http://localhost:5000/login \
-H "Content-Type: application/json" \
-d '{"username": "zhangsan", "password": "wrong"}'
json
{"error": "用户名或密码错误"}
每个场景都能对上预期。登录成功发 Token,带 Token 能访问,不带或错误就被拦。
七、和理论篇的对照
| 理论概念 | 对应代码 |
|---|---|
| 服务器用密钥签名 | SECRET_KEY(从环境变量读取) |
| Header + Payload + Signature | jwt.encode() 自动构建三段 |
| 验签:重算签名并比对 | jwt.decode() 自动完成 |
| Token 过期机制 | exp 字段 + ExpiredSignatureError |
Authorization: Bearer xxx |
装饰器从 request.headers 取出 |
| 服务器不存状态 | 没有 Session 表、没有 Redis,纯靠 Token |
整个登录系统没有任何"状态存储",不需要数据库记 Session,不需要 Redis 缓存。服务器只靠密钥就能验证每一个请求。这就是 Token 理论篇 里讲的"无状态"的代码体现。
当然,无状态也有代价。Token 签出去就收不回来,不能主动踢人下线。怎么解决?下篇【Refresh Token】 会给出方案。