[Token实战]Flask JWT 登录接口

​ 在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:

    bash 复制代码
    SECRET_KEY = "190d7fb08cbcf7c8b1096bb14ba0daea3306144a58001b97b3423d6a7dfa7103"

    之后也通过export将密钥写入环境变量。

    bash 复制代码
    (web_env) user % export JWT_SECRET_KEY=190d7fb08cbcf7c8b1096bb14ba0daea3306144a58001b97b3423d6a7dfa7103
    (web_env) user % echo $JWT_SECRET_KEY 
    190d7fb08cbcf7c8b1096bb14ba0daea3306144a58001b97b3423d6a7dfa7103
  • SECRET_KEY 从环境变量读,如果没设置会直接 KeyError 崩掉,这是故意的。与其用一个默认值悄悄跑起来,不如在启动时就崩掉提醒你配置密钥。 用默认值的代码在开发阶段方便,但上线忘改就是安全事故。

    python 复制代码
    import 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_idusernamerole)和过期时间打包成 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_idusernamerole 这些)。把它挂到 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。
  • 异常分两层捕获。 ExpiredSignatureErrorInvalidTokenError 的子类,但单独捕获是为了返回不同的错误信息。"过期"和"无效"对客户端来说需要不同的处理逻辑(过期可以尝试刷新,无效只能重新登录)。

五、受保护接口

体现装饰器的用法

​ 前面写了一大段装饰器,现在看看用起来有多简单:

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 /ordersPOST /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】 会给出方案。

相关推荐
strayCat232552 小时前
4. Spring Boot 数据持久化(JPA)
java·spring boot·后端
杰杰7982 小时前
一文掌握在Flask使用SQLAlchemy(上)
后端·python·flask
荧焰2 小时前
Spring定时任务设计
后端
火锅鸡的味道2 小时前
解决AOSP工程Android Studio打开卡顿
android·python·android studio
strayCat232552 小时前
2. Spring Boot 自动配置原理深度解析
java·spring boot·后端
SimonKing2 小时前
每月500 Credits+不限频对话,这款IDEA插件的免费版诚意拉满
java·后端·程序员
我叫黑大帅2 小时前
PHP mysqli 实用开发指南
后端·面试·php
纤纡.2 小时前
从基础 CNN 到优化模型:食品图像分类全流程对比实战
人工智能·python·深度学习
kronos.荒2 小时前
图论之岛屿数量(python)
python·图论