【计算基础|网络04】—— HTTP接口实战(下):接口测试、鉴权与跨域排错

【计算基础|网络04】HTTP 接口实战(下):接口测试、鉴权与跨域排错

上篇你已经能把请求准确发出去了。但真正写后端,天天绊住人的是另外三件事:接口怎么系统化地测(而不是每次手敲)、登录鉴权到底怎么回事(Cookie/Session、Token/JWT 到底选哪个)、前端一调就报的那个红色 CORS 错误怎么解决。

这篇就把这三件事一次讲透,仍然是同一个请求给 Python(requests)和终端 curl 两种发法,每个字段标清它落到报文哪里。最后给一张开发能直接贴墙的状态码排错总表,并用带鉴权的大模型 API、Webhook 两个真实场景收尾。

环境:Python 3.10+、FastAPI、requests、PyJWT;接口测试工具用 Apifox(Postman 操作几乎一致,会并列标注)。依赖一次装齐:

bash 复制代码
pip install fastapi "uvicorn[standard]" requests pyjwt python-multipart

一、先厘清:这篇解决哪三件事

主题 你会遇到的真实场景 看完能做到
接口测试 十几个接口要回归、登录后才能调、同事甩来一条 curl 用 Apifox/Postman 管接口、写断言、登录一次 Token 全自动带
鉴权 登录、记住登录态、接口要登录/要管理员权限 讲清 Cookie+Session 与 JWT 的区别并各写一套,分清 401/403
跨域 前端 localhost:5173 调后端 localhost:8000,浏览器报红 看懂预检 OPTIONS,会在 FastAPI/Nginx 配 CORS,会排查
状态码 接口返回 422/429/502,不知道谁的锅 对着总表 30 秒定位是参数、权限、限流还是网关

先把这一篇要用的"靶子"服务搭好,它同时实现了 Session 登录、JWT 登录、CORS、各种状态码、限流和一个 Webhook 接收端,后面每节都拿它练手。

1.1 实验台 server2.py

python 复制代码
import time
import json
import hmac
import hashlib
import secrets
from typing import Optional
from datetime import datetime, timedelta, timezone

import jwt
from fastapi import FastAPI, Body, Depends, Header, Cookie, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# ---- CORS:只放行这个前端源,且允许带凭证(Cookie) ----
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],   # 前端地址:协议+主机+端口要完全一致
    allow_credentials=True,                    # 允许跨域带 Cookie
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],  # 带凭证时不能用 ["*"]
    allow_headers=["Authorization", "Content-Type", "X-Token"],
    expose_headers=["X-Request-ID"],
    max_age=600,                               # 预检结果缓存秒数
)

# HS256 的密钥至少 32 字节随机串;生产用 secrets.token_urlsafe(32) 生成、
# 从环境变量读取,不要硬编码进代码仓库
JWT_SECRET = "dev-only-please-change-to-a-random-secret-at-least-32-bytes"
JWT_ALG = "HS256"

# 模拟用户库:用户名 -> (密码, 角色)
USERS = {
    "zhangsan": ("123456", "user"),
    "admin": ("admin123", "admin"),
}
SESSIONS = {}   # session_id -> 用户信息(演示服务端 Session 存储)

# ================= Cookie + Session =================
@app.post("/login-session")
def login_session(payload: dict = Body(...)):
    username, password = payload.get("username"), payload.get("password")
    user = USERS.get(username)
    if not user or user[0] != password:
        raise HTTPException(status_code=401, detail="用户名或密码错误")
    sid = secrets.token_hex(16)
    SESSIONS[sid] = {"username": username, "role": user[1]}
    resp = JSONResponse({"msg": "登录成功", "方式": "Cookie+Session"})
    # 通过 Set-Cookie 下发会话标识;HttpOnly 防 JS 读取,SameSite=Lax 防大部分 CSRF
    resp.set_cookie(key="session_id", value=sid,
                    max_age=3600, httponly=True, samesite="lax", path="/")
    return resp

@app.get("/me-session")
def me_session(session_id: Optional[str] = Cookie(None)):
    user = SESSIONS.get(session_id)
    if not user:
        raise HTTPException(status_code=401, detail="未登录或会话已过期")
    return {"登录用户": user, "鉴权方式": "Session Cookie"}

@app.post("/logout-session")
def logout_session(session_id: Optional[str] = Cookie(None)):
    SESSIONS.pop(session_id, None)
    resp = JSONResponse({"msg": "已退出"})
    resp.delete_cookie("session_id", path="/")
    return resp

# ================= Token / JWT =================
def make_token(username: str, role: str, ttl_seconds: int = 3600) -> str:
    payload = {
        "sub": username,      # subject:令牌主体(一般是用户 id)
        "role": role,
        "iat": datetime.now(timezone.utc),                                   # 签发时间
        "exp": datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds),  # 过期时间
    }
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)

@app.post("/login-jwt")
def login_jwt(payload: dict = Body(...)):
    username, password = payload.get("username"), payload.get("password")
    user = USERS.get(username)
    if not user or user[0] != password:
        raise HTTPException(status_code=401, detail="用户名或密码错误")
    token = make_token(username, user[1])
    return {"access_token": token, "token_type": "Bearer", "expires_in": 3600}

def verify_token(authorization: Optional[str] = Header(None)) -> dict:
    """从 Authorization: Bearer <token> 取 token 并校验,失败统一返回 401。"""
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="缺少 Authorization: Bearer 头",
                            headers={"WWW-Authenticate": "Bearer"})
    token = authorization[len("Bearer "):]
    try:
        return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="token 已过期,请重新登录")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="token 无效(签名错误或被篡改)")

@app.get("/me-jwt")
def me_jwt(payload: dict = Depends(verify_token)):
    return {"登录用户": payload["sub"], "角色": payload["role"], "鉴权方式": "JWT Bearer"}

@app.get("/admin")
def admin_only(payload: dict = Depends(verify_token)):
    if payload.get("role") != "admin":     # 已登录但角色不够 -> 403
        raise HTTPException(status_code=403, detail="需要管理员权限")
    return {"msg": "欢迎,管理员", "用户": payload["sub"]}

# ================= 状态码演示 =================
@app.post("/items")
def create_item(payload: dict = Body(...)):
    return {"created": payload}           # 用表单打这个 JSON 接口会得到 422

@app.get("/only-get")
def only_get():
    return {"msg": "我只接受 GET"}        # 对它发 POST 会得到 405

@app.get("/bad")
def bad_request(age: Optional[int] = None):
    if age is None:
        raise HTTPException(status_code=400, detail="缺少必填参数 age")
    return {"age": age}

@app.get("/boom")
def boom():
    raise RuntimeError("演示一个未捕获异常 -> 服务器返回 500")

# ================= 限流 429 =================
_bucket = {"hits": []}
RATE_LIMIT, RATE_WINDOW = 3, 10          # 10 秒窗口内最多 3 次

@app.get("/limited")
def limited():
    now = time.time()
    _bucket["hits"] = [t for t in _bucket["hits"] if now - t < RATE_WINDOW]
    if len(_bucket["hits"]) >= RATE_LIMIT:
        raise HTTPException(status_code=429, detail="请求过于频繁",
                            headers={"Retry-After": str(RATE_WINDOW)})
    _bucket["hits"].append(now)
    return {"ok": True, "窗口内已请求": len(_bucket["hits"]), "上限": RATE_LIMIT}

# ================= Webhook:用 HMAC 签名验真(不登录) =================
WEBHOOK_SECRET = b"webhook-shared-secret"   # 收发双方约定,绝不放进前端

@app.post("/webhook")
async def receive_webhook(request: Request, x_signature: Optional[str] = Header(None)):
    raw = await request.body()   # 必须用【原始字节】重算签名,不能先 json 解析再序列化
    expect = hmac.new(WEBHOOK_SECRET, raw, hashlib.sha256).hexdigest()
    if not x_signature or not hmac.compare_digest(expect, x_signature):
        raise HTTPException(status_code=401, detail="Webhook 签名校验失败")
    return {"ok": True, "收到事件": json.loads(raw)}

启动(这篇统一用 8000 端口):

bash 复制代码
uvicorn server2:app --reload --port 8000

成功标志 :打开 http://127.0.0.1:8000/docs 能看到上面这些接口。下面所有命令默认你已经把服务跑起来了。


二、接口测试三板斧:curl、Apifox、断言

2.1 curl:服务器上唯一一定有的工具

上篇给了发请求的选项,测试/排错时高频的是下面这几个"看结果"的选项:

选项 作用 典型用途
-i 响应里连响应头一起打印 Set-Cookie、CORS 头、AllowRetry-After
-I 发 HEAD 请求,只取响应头 探活、看资源大小/类型
-v 打印完整通信过程(> 发出、< 收到) 排查"我到底发了什么头"
-s 静默,不显示进度条 写脚本
-o 文件 把响应体存文件(-o /dev/null 即丢弃) 只关心状态码/头时
-w "格式" 按模板输出指标 只看状态码、耗时
-c 文件 把响应的 Cookie 到文件(cookie jar) 模拟登录保存会话
-b 文件 从文件上 Cookie 登录后访问
-L 自动跟随重定向 看 301/302 最终落到哪
--max-time N 整个请求最长 N 秒 防卡死

最常用的一条"只看状态码"组合,排错时一秒判断通不通:

bash 复制代码
curl -s -o /dev/null -w "状态码=%{http_code} 耗时=%{time_total}s\n" \
     "http://127.0.0.1:8000/only-get"
# 状态码=200 耗时=0.012345s

字段解释:-o /dev/null 把响应体丢进"黑洞"(Windows 上换成 -NUL),-w%{http_code} 是状态码、%{time_total} 是总耗时,还能取 %{content_type}%{size_download} 等。

2.2 Apifox / Postman:把接口管起来

curl 适合快速复现,但接口一多,你需要一个能保存接口、切换环境、自动带 Token、批量回归 的工具。国内常用 Apifox,海外常用 Postman,两者概念和脚本几乎通用(都用 pm 对象)。

最小工作流(以 Apifox 为例,Postman 对应操作在括号里):

  1. 新建一个 HTTP 项目 / 集合(Collection)。
  2. 新建接口,选方法、填 URL,在 Params / Body / Headers 标签页分别填查询参数、请求体、请求头;Body 选 JSON 后直接写 JSON,工具自动加 Content-Type
  3. 点"发送",下方看状态码、响应头、响应体和耗时。

环境变量:开发、测试、生产的域名不同,别每次手改。在"环境管理"(Postman:Environments)里建两个变量:

变量 开发环境 生产环境
base_url http://127.0.0.1:8000 https://api.example.com

接口 URL 里写 {``{base_url}}/login-jwt,右上角切换环境就整套换域名。

导入 cURL:这是最实用的功能之一。浏览器 F12 的 Network 里任意一条请求右键"Copy as cURL",或同事甩给你一条 curl,不用手填:

  • Apifox:鼠标悬停在左侧 + 上 → 导入 cURL → 粘贴命令 → 确定,自动解析成一个可编辑接口。
  • Postman:Import → Raw text → 粘贴 cURL → Continue。
  • 反过来,已保存的接口也能"导出 cURL",方便你粘到服务器上复现。

2.3 断言:让接口自己判断对错

手动点"发送"再用眼睛看结果,十个接口还行,一百个接口回归就是灾难。断言(测试脚本)让工具自动校验"状态码对不对、返回字段在不在、耗时超不超标"。

在接口的"后置操作 → 自定义脚本"(Postman:Tests 标签)里写,两者都用 pm 对象:

javascript 复制代码
// 1. 状态码必须是 200
pm.test("状态码为 200", function () {
    pm.response.to.have.status(200);
});

// 2. 返回体里必须有 access_token,且是字符串
pm.test("登录返回了 token", function () {
    const json = pm.response.json();
    pm.expect(json).to.have.property("access_token");
    pm.expect(json.access_token).to.be.a("string");
});

// 3. 响应时间不超过 500ms
pm.test("响应时间小于 500ms", function () {
    pm.expect(pm.response.responseTime).to.be.below(500);
});

字段解释:pm.response 是这次响应对象,.to.have.status(n) 校验状态码,pm.response.json() 把响应体解析成对象,pm.expect(...) 是断言语法。Apifox 还提供零代码的"断言"后置操作(下拉选"响应状态码 等于 200"即可),不想写脚本可以用它。

2.4 登录一次,Token 自动带到每个接口

需要登录的接口,难道每次都先手动登录、复制 token、再粘到别的接口?不用。让登录接口在成功后自动把 token 存进环境变量 ,再让所有接口自动引用它。

第一步,在"登录接口"的后置脚本里提取并保存 token:

javascript 复制代码
// Apifox 后置操作 → 自定义脚本;Postman 在 Tests 里写,语法相同
const json = pm.response.json();
pm.environment.set("token", json.access_token);   // 存到环境变量 token
console.log("已保存 token:", json.access_token);

Apifox 也可以完全不写代码:后置操作 → 提取变量 ,变量名填 token,提取表达式填 JSONPath $.access_token

第二步,让整个集合的请求自动带上鉴权头:

  • Postman :集合右键 Edit → Authorization → Type 选 Bearer Token ,Token 填 {``{token}}
  • Apifox :在项目/目录的"全局参数 → Header"加一条 Authorization: Bearer {``{token}}(或在"全局鉴权"里选 Bearer Token 填 {``{token}})。

之后的流程就是:先点一次登录接口 → token 自动存好 → 后面所有接口自动带 Authorization: Bearer {``{token}},过期了再点一次登录即可。

🔔 Cookie 形式的登录更省事:Apifox/Postman 和浏览器一样会自动维护 Cookie ------你调一次 /login-session,响应里的 Set-Cookie 被工具存下,之后请求自动带,无需任何配置。


三、鉴权:Cookie/Session 和 Token/JWT 到底怎么选

3.1 先分清两个词:认证与授权

  • 认证(Authentication,你是谁) :验证身份,比如账号密码登录、出示 token。失败一般是 401 Unauthorized (注意标准里这个词字面是"未授权",但语义其实是"未认证/没登录")。
  • 授权(Authorization,你能干什么) :已经知道你是谁了,判断你有没有权限做这件事。权限不够是 403 Forbidden

🔴 401 和 403 的区别是面试和实战双高频

状态码 含义 典型场景 正确处理
401 没登录 / token 缺失、过期、伪造 没带 token、token 过期 跳登录、刷新 token
403 登录了,但没权限 普通用户访问管理员接口 提示无权限,别再重复登录

下面两套主流方案,都在实验台里实现了。

3.2 方案一:Cookie + Session(有状态)

思路 :HTTP 是无状态的,服务器记不住你。那就让你登录后,服务器在自己这边 建一条会话(Session),给这条会话发一个随机的 session_id,通过响应头 Set-Cookie 交给浏览器;浏览器之后每次请求自动用 Cookie 头把它带回来,服务器拿它查表确认你是谁。
渲染错误: Mermaid 渲染失败: Parse error on line 6: ...ion_id=sid; HttpOnly C->>S: GET /me- -----------------------^ Expecting '()', 'SOLID_OPEN_ARROW', 'DOTTED_OPEN_ARROW', 'SOLID_ARROW', 'SOLID_ARROW_TOP', 'SOLID_ARROW_BOTTOM', 'STICK_ARROW_TOP', 'STICK_ARROW_BOTTOM', 'SOLID_ARROW_TOP_DOTTED', 'SOLID_ARROW_BOTTOM_DOTTED', 'STICK_ARROW_TOP_DOTTED', 'STICK_ARROW_BOTTOM_DOTTED', 'SOLID_ARROW_TOP_REVERSE', 'SOLID_ARROW_BOTTOM_REVERSE', 'STICK_ARROW_TOP_REVERSE', 'STICK_ARROW_BOTTOM_REVERSE', 'SOLID_ARROW_TOP_REVERSE_DOTTED', 'SOLID_ARROW_BOTTOM_REVERSE_DOTTED', 'STICK_ARROW_TOP_REVERSE_DOTTED', 'STICK_ARROW_BOTTOM_REVERSE_DOTTED', 'BIDIRECTIONAL_SOLID_ARROW', 'DOTTED_ARROW', 'BIDIRECTIONAL_DOTTED_ARROW', 'SOLID_CROSS', 'DOTTED_CROSS', 'SOLID_POINT', 'DOTTED_POINT', got 'NEWLINE'

Cookie 是什么 :服务器通过 Set-Cookie 响应头"种"在客户端的一小段数据,客户端之后访问同站会自动用 Cookie 请求头带回。它的几个安全属性必须认识:

属性 作用 建议
HttpOnly 禁止 JavaScript 通过 document.cookie 读取 会话 Cookie 必开,防 XSS 偷取
Secure 只在 HTTPS 下发送(localhost 例外) 生产开启
SameSite 跨站请求带不带 Cookie:Strict/Lax/None 默认 Lax,防大部分 CSRF;跨站要用 None 且必须配 Secure
Max-Age/Expires 有效期;不设则为会话期 Cookie(关浏览器失效) 按需
Domain/Path Cookie 发给哪些域名/路径 默认当前主机,别乱放宽

亲手发一遍 。登录并把 Cookie 存进文件(-c = cookie jar 写入):

bash 复制代码
# -c cookies.txt:把响应 Set-Cookie 存到文件;-i 顺便看响应头
curl -s -i -c cookies.txt -X POST "http://127.0.0.1:8000/login-session" \
     -H "Content-Type: application/json" \
     -d '{"username":"zhangsan","password":"123456"}' | grep -i "set-cookie"
# set-cookie: session_id=2d5b...; HttpOnly; Max-Age=3600; Path=/; SameSite=lax

不带 Cookie 直接访问是 401;带 Cookie(-b = 从文件读并带上)就能拿到用户:

bash 复制代码
curl -s -o /dev/null -w "不带Cookie: %{http_code}\n" "http://127.0.0.1:8000/me-session"   # 401
curl -s -b cookies.txt "http://127.0.0.1:8000/me-session"; echo
# {"登录用户":{"username":"zhangsan","role":"user"},"鉴权方式":"Session Cookie"}

Python 里用 requests.Session(),它像浏览器一样自动管 Cookie,登录后不用手动塞:

python 复制代码
import requests

s = requests.Session()                         # 一个会话对象,自动保存/回传 Cookie
print(s.get("http://127.0.0.1:8000/me-session").status_code)          # 401(还没登录)

s.post("http://127.0.0.1:8000/login-session",
       json={"username": "zhangsan", "password": "123456"})
print(s.cookies.get_dict())                    # {'session_id': '...'},已自动存下

print(s.get("http://127.0.0.1:8000/me-session").json())  # 之后请求自动带 Cookie
# {'登录用户': {'username': 'zhangsan', 'role': 'user'}, ...}

字段解释:-c/-b 操作的 session_id 落在响应的 Set-Cookie 和请求的 Cookie 头里;requests.Session() 内部就是帮你维护了这么一个 cookie jar,和浏览器行为一致。

Session 方案的短板 :会话存在服务器内存/Redis 里,是有状态的------单机没问题,一旦是多台服务器做负载均衡,你这台登录、下一台没你的会话,就得搞 Session 共享(存 Redis)或会话粘滞。前后端分离、移动端、开放 API 场景下,更常用下面的 Token 方案。

3.3 方案二:Token / JWT(无状态)

思路 :登录后服务器不存会话,而是签发一张"自证身份的令牌"(Token)交给客户端;客户端把它保存在本地,之后每次请求放在 Authorization: Bearer <token> 头里带来;服务器只凭令牌本身 + 密钥 就能验明正身,不必查表,所以叫无状态,天然适合分布式和多端。

JWT(JSON Web Token)长什么样 :它就是一个用两个 . 分成三段的字符串 xxxxx.yyyyy.zzzzz

复制代码
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ6aGFuZ3NhbiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzkwMDAyODg0LCJleHAiOjE3OTAwMDY0ODR9.GKWY05j36r5KctSaaq5ZKLN1vQQPm2CPeBjemF58F5I
└────────── header ──────────┘ └──────────────────── payload ────────────────────┘ └─────── signature ───────┘
  • Header :令牌类型和签名算法,Base64Url 编码,解出来是 {"alg":"HS256","typ":"JWT"}
  • Payload :载荷,放声明(claims),如 sub(主体)、role(角色)、iat(签发时间)、exp(过期时间),也是 Base64Url 编码。
  • Signature:用密钥对头和体算的签名,防篡改。

🔴 最关键的认知:前两段只是 Base64 编码,不是加密,任何人都能解开看内容。 上面那个 token 的 payload 解出来就是:

json 复制代码
{"sub": "zhangsan", "role": "user", "iat": 1790002884, "exp": 1790006484}

所以两条铁律:① payload 里绝不能放密码、身份证等敏感信息;② 防篡改靠第三段签名------你改了 payload 哪怕一个字符,签名就对不上,服务器验签直接 401。 你可以把 token 粘到 jwt.io 的 Debugger 里,亲眼看到三段解码。

亲手发一遍。登录拿 token:

bash 复制代码
# 用 python 从返回 JSON 里取出 access_token
TOKEN=$(curl -s -X POST "http://127.0.0.1:8000/login-jwt" \
  -H "Content-Type: application/json" \
  -d '{"username":"zhangsan","password":"123456"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
echo "$TOKEN"

带 token 访问受保护接口;不带、伪造、过期分别是 401,普通用户访问管理员接口是 403:

bash 复制代码
curl -s "http://127.0.0.1:8000/me-jwt" -H "Authorization: Bearer $TOKEN"; echo        # 200
curl -s -o /dev/null -w "不带token: %{http_code}\n" "http://127.0.0.1:8000/me-jwt"     # 401
curl -s -o /dev/null -w "普通用户/admin: %{http_code}\n" "http://127.0.0.1:8000/admin" \
     -H "Authorization: Bearer $TOKEN"                                                  # 403
curl -s -o /dev/null -w "伪造token: %{http_code}\n" "http://127.0.0.1:8000/me-jwt" \
     -H "Authorization: Bearer ${TOKEN%?}X"                                             # 401

Python 侧(生产里建议用环境变量传密钥和 token):

python 复制代码
import requests

B = "http://127.0.0.1:8000"
token = requests.post(f"{B}/login-jwt",
                      json={"username": "zhangsan", "password": "123456"}
                      ).json()["access_token"]
h = {"Authorization": f"Bearer {token}"}          # 之后每个请求带上这个头

print(requests.get(f"{B}/me-jwt", headers=h).json())   # 200,拿到用户
print(requests.get(f"{B}/me-jwt").status_code)         # 401,没带头
print(requests.get(f"{B}/admin", headers=h).status_code)  # 403,角色不够

⚠️ JWT 几个实战坑:

  1. 密钥要够长够随机 :HS256 建议密钥至少 32 字节,用 secrets.token_urlsafe(32) 生成、放环境变量;密钥一旦泄露,任何人都能伪造管理员 token。
  2. 一定要设 exp 并校验过期:无状态令牌签发后默认"永远有效",不设过期风险极大。
  3. 无状态的代价是难主动吊销:token 在过期前始终有效,想"立刻踢人下线"不如 Session 干脆,通常要用短期 access token + 刷新 token(refresh token)或黑名单弥补。

3.4 Session 和 JWT 怎么选

维度 Cookie + Session Token / JWT
状态 有状态(服务器存会话) 无状态(令牌自证,服务器不存)
横向扩展 多机要共享 Session(Redis) 天然适合多机/微服务
适合端 浏览器 Web 为主 前后端分离、App、小程序、开放 API、跨域
存放位置 浏览器 Cookie(可 HttpOnly) 客户端存储;放头里携带,跨域更省心
主动注销/踢人 删服务器会话即可,干脆 较难,需黑名单/短有效期+刷新机制
敏感信息 只存 sid,安全 payload 明文可读,禁止放敏感信息
常见隐患 CSRF(靠 SameSite 缓解) XSS 偷 token、密钥泄露、不设过期

一句话:传统同站 Web 用 Cookie+Session 省心;前后端分离、多端、开放接口用 JWT 灵活。 没有绝对优劣,看场景。

3.5 Authorization 头的三种常见写法

鉴权信息统一放 Authorization 请求头,值是"方案 + 凭证":

方案 头长这样 用途
Basic Authorization: Basic base64(用户名:密码) 简单基础认证,curl 用 -u 用户:密码 自动生成(上篇演示过)
Bearer Authorization: Bearer <token> JWT、OAuth2、大模型 API 的 sk-xxx最常见
API Key 常放自定义头 X-API-Key: sk-xxx 很多开放平台的习惯写法

四、跨域 CORS:一道只有浏览器才设的坎

4.1 什么是"同源"和同源策略

源(Origin)= 协议 + 主机(域名/IP)+ 端口 ,三者完全一致才算同源。下面两两都不同源

  • http://localhost:5173(前端 Vite 默认端口)
  • http://localhost:8000(后端,端口不同)
  • https://localhost:5173(协议 http/https 不同)
  • http://127.0.0.1:5173(主机名 localhost/127.0.0.1 不同)

浏览器有同源策略 :页面里的 JavaScript 用 fetch/XMLHttpRequest 去请求不同源的接口时,默认会被拦截,这就是你在控制台看到的 CORS 报错。它是浏览器的安全机制,目的是防止一个恶意网页偷偷用你的登录态去调别的网站。

🔴 一个救命认知:跨域是浏览器拦的,不是服务器拦的,更不是 HTTP 协议本身的限制。

  • curl、Postman、Apifox、Python requests、服务器之间互调,永远不会报跨域------它们不执行同源策略。
  • 所以"我用 curl 调明明通的,浏览器却报 CORS"是完全正常的。这也给了你排查手段:curl 能通而浏览器不通,问题 100% 在 CORS 配置,而不是接口逻辑。

4.2 简单请求 vs 预检请求(OPTIONS)

浏览器对跨域请求分两种处理:

简单请求------不发预检,直接发真实请求,但响应里必须有正确的 CORS 头浏览器才把结果给 JS。要同时满足(要点):

  • 方法是 GET/HEAD/POST 之一;
  • 没有自定义请求头(只含少数安全头);
  • Content-Type 仅限 application/x-www-form-urlencodedmultipart/form-datatext/plain

需预检的请求 ------只要不满足上面条件,浏览器会自动先发一个 OPTIONS 请求"探路",问服务器"我待会儿想用 POST、带 Authorization 和 JSON,可以吗?",服务器在响应头里表态允许,浏览器才发真正的请求。

🔴 这些情况一定触发预检 :用了 PUT/DELETE/PATCHContent-Type: application/json(JSON 接口几乎必触发);带了 AuthorizationX-Token 等自定义头。

预检交互时序:
后端(localhost:8000) 浏览器(前端 localhost:5173) 后端(localhost:8000) 浏览器(前端 localhost:5173) #mermaid-svg-SwkwfTdcZQKj8ahU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SwkwfTdcZQKj8ahU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SwkwfTdcZQKj8ahU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SwkwfTdcZQKj8ahU .error-icon{fill:#552222;}#mermaid-svg-SwkwfTdcZQKj8ahU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SwkwfTdcZQKj8ahU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SwkwfTdcZQKj8ahU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SwkwfTdcZQKj8ahU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SwkwfTdcZQKj8ahU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SwkwfTdcZQKj8ahU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SwkwfTdcZQKj8ahU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SwkwfTdcZQKj8ahU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SwkwfTdcZQKj8ahU .marker.cross{stroke:#333333;}#mermaid-svg-SwkwfTdcZQKj8ahU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SwkwfTdcZQKj8ahU p{margin:0;}#mermaid-svg-SwkwfTdcZQKj8ahU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SwkwfTdcZQKj8ahU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-SwkwfTdcZQKj8ahU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-SwkwfTdcZQKj8ahU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-SwkwfTdcZQKj8ahU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-SwkwfTdcZQKj8ahU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-SwkwfTdcZQKj8ahU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-SwkwfTdcZQKj8ahU .sequenceNumber{fill:white;}#mermaid-svg-SwkwfTdcZQKj8ahU #sequencenumber{fill:#333;}#mermaid-svg-SwkwfTdcZQKj8ahU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-SwkwfTdcZQKj8ahU .messageText{fill:#333;stroke:none;}#mermaid-svg-SwkwfTdcZQKj8ahU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SwkwfTdcZQKj8ahU .labelText,#mermaid-svg-SwkwfTdcZQKj8ahU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-SwkwfTdcZQKj8ahU .loopText,#mermaid-svg-SwkwfTdcZQKj8ahU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-SwkwfTdcZQKj8ahU .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-SwkwfTdcZQKj8ahU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-SwkwfTdcZQKj8ahU .noteText,#mermaid-svg-SwkwfTdcZQKj8ahU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-SwkwfTdcZQKj8ahU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SwkwfTdcZQKj8ahU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SwkwfTdcZQKj8ahU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SwkwfTdcZQKj8ahU .actorPopupMenu{position:absolute;}#mermaid-svg-SwkwfTdcZQKj8ahU .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-SwkwfTdcZQKj8ahU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SwkwfTdcZQKj8ahU .actor-man circle,#mermaid-svg-SwkwfTdcZQKj8ahU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-SwkwfTdcZQKj8ahU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} JS 要发 POST application/json + Authorization(非简单请求) 预检通过,才发真实请求 OPTIONS /me-jwtOrigin: http://localhost:5173Access-Control-Request-Method: POSTAccess-Control-Request-Headers: authorization,content-type200Access-Control-Allow-Origin: http://localhost:5173Access-Control-Allow-Methods: GET,POST,...Access-Control-Allow-Headers: Authorization,Content-TypeAccess-Control-Allow-Credentials: true真实 POST(带 Origin 和业务头/体)200 + Access-Control-Allow-Origin

预检请求里两个特殊请求头的含义:Access-Control-Request-Method 告诉服务器"真实请求想用什么方法",Access-Control-Request-Headers 告诉服务器"真实请求会带哪些头"。注意这两个头只出现在 OPTIONS 预检里,真实请求不带。

4.3 CORS 相关响应头逐字段

响应头 作用
Access-Control-Allow-Origin 允许哪个源访问,可填具体源 http://localhost:5173*(任意)
Access-Control-Allow-Methods 允许的方法列表(预检响应里)
Access-Control-Allow-Headers 允许携带的请求头列表(预检响应里)
Access-Control-Allow-Credentials true 表示允许跨域带 Cookie/Authorization 凭证
Access-Control-Max-Age 预检结果缓存秒数,期内不再重复发 OPTIONS
Access-Control-Expose-Headers 默认 JS 只能读少数响应头,自定义响应头要在这里"放行"才读得到

4.4 在 FastAPI 里配 CORS(实验台已配好)

就是开头 server2.py 里的 CORSMiddleware。关键参数(FastAPI 官方默认值偏保守,要显式开):

参数 含义 默认值 / 注意
allow_origins 允许的源列表 ['*'] 表示全允许
allow_methods 允许的方法 默认只允许 GET,要显式列出或 ['*']
allow_headers 允许的请求头 默认 [],JSON/鉴权要放开 Content-TypeAuthorization
allow_credentials 是否允许带凭证 默认 False
max_age 预检缓存秒数 Starlette 默认 600

用 curl 模拟预检,检查服务器有没有正确回 CORS 头(这就是不依赖浏览器的排查法):

bash 复制代码
curl -s -i -X OPTIONS "http://127.0.0.1:8000/me-jwt" \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: authorization,content-type" | grep -i "access-control"

实测返回:

http 复制代码
access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
access-control-max-age: 600
access-control-allow-headers: Accept, Accept-Language, Authorization, Content-Language, Content-Type, X-Token
access-control-allow-credentials: true
access-control-allow-origin: http://localhost:5173

再对比一个不在白名单的源 ,服务器不会回 Allow-Origin,浏览器就会拦截:

bash 复制代码
curl -s -i "http://127.0.0.1:8000/only-get" -H "Origin: http://evil.com" | grep -i "access-control-allow-origin"
# (无输出)→ 浏览器判定该源不被允许,拦截响应

字段解释:curl 不会像浏览器那样拦截,它只是老老实实把响应头给你看------Access-Control-Allow-Origin 且值匹配你的源,浏览器才放行;没有或不匹配,浏览器就报红。

4.5 跨域还要带 Cookie:三个条件缺一不可

这是最容易配错的组合。想让跨域请求带上 Cookie:

  1. 前端fetch 要设 credentials: 'include'(axios 是 withCredentials: true);
  2. 后端Access-Control-Allow-Credentials: true
  3. 后端的 Access-Control-Allow-Origin 不能是 *,必须是具体源Allow-Methods/Allow-Headers 同理不能用 *),否则浏览器直接拒绝。

FastAPI 里一旦 allow_credentials=True,就必须把 allow_origins 写成明确的源列表(实验台就是这么配的)。此外跨站 Cookie 还要看 SameSite,第三方场景往往要 SameSite=None; Secure

4.6 跨域报错排查清单

控制台报错关键词 根因 解决
No Access-Control-Allow-Origin header 后端压根没配 CORS,或源不在白名单 配 CORSMiddleware / Nginx 加 CORS 头,把前端源加进白名单
Method xxx not allowed by CORS Allow-Methods 没放开该方法 allow_methods 加上 PUT/DELETE 等
Request header xxx is not allowed Allow-Headers 没放开自定义头 allow_headers 加上 AuthorizationX-Token
* 配 credentials 报错 带凭证却用了通配符 改成具体源 + allow_credentials=True
每次请求前都多一个 OPTIONS 那是正常预检;嫌多可设缓存 Access-Control-Max-Age

⚠️ 还有个常见做法是让前后端同源 :用 Nginx 把前端和 /api 反代到同一个域名下,浏览器看来就是同源,根本不触发 CORS(网络 16 篇会实操)。开发期 Vite/Webpack 的 devServer 也有"代理(proxy)"功能,原理一样,把 /api 转发给后端绕过跨域。


五、状态码排错总表与限流

5.1 开发高频状态码对照表

含义 典型原因 谁的锅 / 怎么查
200/201/204 成功 / 已创建 / 成功无返回体 --- POST 新建更规范用 201
301/302/307/308 永久/临时重定向 网址迁移、登录跳转 curl 加 -L 跟随;307/308 不改方法
400 Bad Request 请求有误 缺必填参数、JSON 语法错、参数格式错 客户端,curl -v 看发出的体
401 未认证(没登录) 没带 token、token 过期/伪造 重新登录/刷新 token
403 已认证但无权限 普通用户访问管理接口、IP 不在白名单 权限问题,重复登录没用
404 资源/路径不存在 URL 写错、资源 id 不存在 先确认路径和方法对不对
405 方法不允许 GET 接口用了 POST;响应会带 Allow Allow 头里允许的方法
409 冲突 重复创建、乐观锁版本冲突 客户端按冲突提示处理
415 媒体类型不支持 Content-Type 服务器不收 核对该用 JSON 还是 multipart
422 请求格式对但语义/校验失败(源自 WebDAV,FastAPI 用作参数校验失败) 表单打了 JSON 接口、字段类型不符、缺字段 看响应体 detail 定位具体字段(上篇 422 专讲)
429 请求过多,被限流 触发频率限制 Retry-After,退避重试
500 服务器内部错误 后端代码抛异常、空指针、数据库挂了 后端锅,看服务端日志/traceback
502 网关收到上游的无效响应 后端进程崩了/没起来 Nginx 后的应用挂了,查应用
503 服务不可用 过载、维护、被限流 稍后重试,看容量/维护公告
504 网关等上游超时 后端处理太慢、卡死 查后端耗时/依赖(数据库、外部 API)

🔴 区分 500 与 502/503/504:500 是你的应用代码自己报错;502/503/504 往往是网关/Nginx/负载均衡这一层和上游之间出的问题------你本地直连应用可能是好的,过了 Nginx 才 502,多半是上游没起来或超时。

⚠️ 还有一类根本没有状态码 的错误:Connection refused(端口没服务)、DNS 解析失败、请求超时------连接都没建立,HTTP 无从谈起。requests 里对应 ConnectionError/Timeout,curl 直接报 Failed to connect,这类要查端口、防火墙、服务是否启动,而不是去翻业务代码。

5.2 限流 429 与 Retry-After

实验台的 /limited 用一个固定窗口做了最简单的限流:10 秒内最多 3 次,超出返回 429 并告诉客户端多久后重试。

bash 复制代码
for n in 1 2 3 4; do
  curl -s -o /dev/null -w "第${n}次: %{http_code}\n" "http://127.0.0.1:8000/limited"
done
# 第1次: 200 / 第2次: 200 / 第3次: 200 / 第4次: 429

curl -s -i "http://127.0.0.1:8000/limited" | grep -iE "HTTP/|retry-after"
# HTTP/1.1 429 Too Many Requests
# retry-after: 10

字段解释:Retry-After: 10 是服务器在告诉客户端"至少等 10 秒再来"。客户端正确姿势是指数退避重试(等 1s、2s、4s......并尊重 Retry-After),而不是立刻疯狂重发,否则只会被限得更死。真实系统的限流维度通常是用户 ID / IP / API Key,算法有固定窗口、滑动窗口、令牌桶等,Redis 篇会展开。


六、串起来:调带鉴权的真实接口

6.1 调大模型 API(Bearer + JSON 的典型)

大模型开放接口几乎是一套标准动作:POST 一个 JSON,Authorization: Bearer sk-xxx 带上密钥。它和实验台的 /me-jwt 在 HTTP 层面完全同构------你可以先在本地把 Bearer+JSON 这套练熟,再换真实地址。

终端 curl(把 $OPENAI_API_KEY 换成你的密钥,base_url 可换成国内兼容网关):

bash 复制代码
curl -s "https://api.openai.com/v1/chat/completions" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  --max-time 30 \
  -d '{
    "model": "gpt-4o-mini",
    "messages": [{"role": "user", "content": "用一句话介绍 HTTP"}]
  }'

Python(密钥从环境变量读,别硬编码、别提交到 Git;务必加 timeout):

python 复制代码
import os
import requests

resp = requests.post(
    "https://api.openai.com/v1/chat/completions",
    headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
    json={
        "model": "gpt-4o-mini",
        "messages": [{"role": "user", "content": "用一句话介绍 HTTP"}],
    },
    timeout=30,
)
resp.raise_for_status()
print(resp.json()["choices"][0]["message"]["content"])

字段解释:sk-xxx 走的就是 Bearer 方案,落在 Authorization 头;对话内容是 JSON,落在请求体并由 json=/-d+JSON 头处理;401 通常是密钥错/欠费,429 是触发限流或额度用尽,5xx 多为服务端波动(适合退避重试)。

6.2 接收 Webhook:方向反过来,靠签名而不是登录

Webhook 是"别人(如支付平台、GitHub、消息平台)在事件发生时主动 POST 回调你的服务器 "。它不是用户登录,调用方是另一台服务器,没法也不该用 Cookie/Session,通行做法是双方约定一个密钥,调用方对请求体算 HMAC 签名放在头里,你验签------确认这个请求确实来自对方、内容没被篡改。

实验台 /webhook 就是接收端。发送方(这里用 openssl 算 HMAC-SHA256 模拟):

bash 复制代码
BODY='{"event":"order.paid","order_id":1001}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "webhook-shared-secret" | awk '{print $2}')

# 签名正确 -> 200
curl -s -X POST "http://127.0.0.1:8000/webhook" \
  -H "Content-Type: application/json" -H "X-Signature: $SIG" -d "$BODY"; echo
# {"ok":true,"收到事件":{"event":"order.paid","order_id":1001}}

# 签名错误/缺失 -> 401
curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://127.0.0.1:8000/webhook" \
  -H "Content-Type: application/json" -H "X-Signature: deadbeef" -d "$BODY"   # 401

接收端验签的核心(已含在 server2.py):用原始请求体字节 和约定密钥重算 HMAC,用 hmac.compare_digest 做常量时间比较,防时序攻击:

python 复制代码
import hmac, hashlib
expect = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expect, x_signature):
    raise HTTPException(status_code=401, detail="Webhook 签名校验失败")

⚠️ 两个易错点:① 必须对收到的原始字节 算签名,如果你先 json.loadsjson.dumps,空格/分隔符/中文转 encode 的细微变化都会让签名对不上;② 密钥只在两台服务器之间保存,绝不能下发给前端。


七、总结:你真正需要记住的 10 件事

  1. 接口测试三件套:curl 快速复现、Apifox/Postman 管理接口和环境、断言脚本自动回归;同事的 curl 用"导入 cURL"一键还原。
  2. 登录接口后置脚本 pm.environment.set("token", ...) + 集合级 Authorization: Bearer {``{token}},实现登录一次、全自动带 token;Cookie 工具会自动维护。
  3. 认证(你是谁)失败是 401,授权(你能不能干)失败是 403;401 去登录,403 别重复登录。
  4. Cookie+Session 是有状态 (服务器存会话,适合传统 Web);JWT 是无状态(令牌自证,适合前后端分离/多端/开放 API)。
  5. JWT 三段 header.payload.signature,前两段 Base64 人人可解码、不是加密,payload 绝不放敏感信息;签名防篡改,改一个字符就 401。
  6. JWT 密钥至少 32 字节随机串、放环境变量,务必设 exp;无状态令牌难主动吊销,用短有效期+刷新 token 弥补。
  7. 跨域是浏览器拦的,curl/Postman/服务端互调永不跨域;curl 能通而浏览器报红,问题就在 CORS 配置。
  8. JSON 请求、带 Authorization、PUT/DELETE 都会触发 OPTIONS 预检 ;后端要回对 Access-Control-Allow-Origin/Methods/Headers
  9. 跨域带 Cookie 三要素:前端 credentials:include、后端 Allow-Credentials:trueAllow-Origin 必须是具体源不能是 *;也可用 Nginx/开发代理让前后端同源彻底绕过。
  10. 500 是应用自己崩,502/503/504 是网关与上游的问题,429 看 Retry-After 退避重试;Connection refused 这类没状态码的是连接层问题,别去翻业务代码。

验证清单

  • 能用 curl 的 -i/-v/-w/-c/-b 复现一个接口并只打印状态码和耗时
  • 能在 Apifox/Postman 导入一条 cURL,配置 base_url 环境变量
  • 能写后置脚本自动保存登录 token,并让集合里其他接口自动带 Authorization
  • 能用 -c/-b(curl)和 requests.Session()(Python)各走通一遍 Cookie+Session 登录
  • 能用 JWT 登录拿 token、访问受保护接口,并复现 401(不带/伪造/过期)和 403(权限不足)
  • 能徒手说出 JWT 三段分别是什么,并解码一个真实 token 的 payload
  • 能用 curl 发一个 OPTIONS 预检请求,并读懂返回的每个 Access-Control-*
  • 能说清为什么 curl 通而浏览器报跨域,以及带 Cookie 跨域的三个条件
  • 能复现 429 并读懂 Retry-After,能区分 500 和 502/504
  • 能用 HMAC 签名正确/错误各调一次 Webhook,看到 200 和 401

参考资源


至此 HTTP 接口的"发请求(上)→ 测接口、鉴权、跨域、排错(下)"就闭环了。下一篇《HTTP/1.1 优化、HTTP/2 与 HTTP/3》回到协议本身,讲清长连接、队头阻塞,以及 HTTP/2 多路复用、HTTP/3 QUIC 到底解决了什么。

相关推荐
慧都小项1 小时前
Python 跨文件重构怎么验证?PyCharm 用法查找、重构预览与测试流程
python·重构·pycharm·单元测试
BryceBorder1 小时前
Agent Memory 不只是聊天记录:手搓三大记忆系统
后端·agent·面经·codex·claude code·agent memory
suaizai_1 小时前
AI编程新范式:6大MCP工具实战揭秘
人工智能
Old Uncle Tom1 小时前
评测即生死:Agent 时代的可靠性重构
人工智能·软件工程·agent
SEO_juper1 小时前
2026年用Python批量审计XML Sitemap:揪出sitemap.xml中的“配置错误/死链/过期页“(附完整代码)
java·python·seo·外贸独立站·谷歌优化
一条泥憨鱼1 小时前
苍穹外卖【day11| 用户统计,订单统计,销量排名统计功能实现】
java·后端·苍穹外卖
huaweichenai1 小时前
spring boot操作PDF
java·spring boot·pdf
钱栈up1 小时前
自动化多平台发布:脚本报FAIL时用三个信号判断真实状态
python
余槐i1 小时前
拆解 Agent 核心原理|从零动手实现简易 AI 智能体(三)
人工智能·python·fastapi·ai agent