RedwoodJS 请求 Python 后端时,携带 Authorization 为何出现 CORS 错误?
报错说明,浏览器发出的 OPTIONS /profile 预检请求没有收到符合要求的 CORS 响应。先检查这条请求经过的路由、鉴权中间件和代理,再调整后端的 CORS 配置。
Authorization 和 Content-Type: application/json 都会触发预检。删除这些请求头后能访问 /login,可能只是因为请求避开了预检,不能据此判断后端已正确处理 CORS。
为什么 /profile 会多一次预检
页面来自 http://localhost:8910,接口位于 http://localhost:8087。两者端口不同,属于不同的 origin,浏览器会执行 CORS 检查。
发送当前的 /profile 请求前,浏览器通常会先发送:
OPTIONS /profile HTTP/1.1
Origin: http://localhost:8910
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization,content-type
这条请求用来确认:后端是否允许该页面发送携带这些请求头的 GET 请求。预检本身不携带业务请求中的 Bearer token,因此不能要求它先通过 token 鉴权。
后端需要返回成功状态,并在响应头中给出相应的允许信息,例如:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:8910
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: Origin
预检通过后,浏览器才会发送实际的 GET /profile。实际响应也必须包含正确的 Access-Control-Allow-Origin,返回 401 时也一样。
原来的 /login 使用字符串作为请求体,没有显式设置 Content-Type,Fetch 通常会为它生成 text/plain;charset=UTF-8。如果没有其他触发预检的条件,这个 POST 就可以作为简单请求发送。
JSON.stringify() 只生成字符串,不会自动将请求的媒体类型设为 application/json。如果 /login 接收 JSON,就应该正确声明类型,并让后端处理由此产生的预检。
先检查浏览器实际收到的响应
打开开发者工具的 Network 面板,找到 OPTIONS /profile,检查:
- 请求地址是否为
http://localhost:8087/profile,是否发生了重定向。 - 响应是否返回成功状态,还是
401、403、404、405或500。 - 响应头是否包含正确的
Access-Control-Allow-Origin。 Access-Control-Allow-Headers是否明确允许Authorization和Content-Type。
也可以用以下命令模拟预检:
curl -i -X OPTIONS 'http://localhost:8087/profile' \
-H 'Origin: http://localhost:8910' \
-H 'Access-Control-Request-Method: GET' \
-H 'Access-Control-Request-Headers: authorization,content-type'
用 curl 时,需要查看响应状态和响应头;它不会像浏览器一样执行 CORS 拦截。
根据预检的返回结果排查:
401:检查鉴权中间件是否拦截了OPTIONS。404或405:检查预检路由是否注册成功、路径是否匹配,以及请求是否到达预期服务。3xx:检查尾部斜杠、HTTP/HTTPS 跳转或登录跳转,尽量直接请求最终地址。500:先查看服务端异常,错误响应可能没有经过添加 CORS 头的逻辑。- 成功状态但缺少 CORS 头:检查响应是否由代理或其他中间件生成。
展示的路由没有调用原代码中的 handle_cors_response(),所以修改它的白名单不会自动改变 handle_cors() 的行为。
如果 handle_cors() 确实执行成功,也收到了正确的 Origin,就应当返回 Access-Control-Allow-Origin。浏览器仍然报告缺少该响应头时,可以通过服务端访问日志确认请求是否进入这个处理函数,并确认 8087 端口上运行的服务是否已包含修改。
Bruno 通常不执行浏览器的同源策略,也不一定自动发送预检。在 Bruno 中请求成功,只能说明它发出的请求得到了接口响应,无法据此确认 CORS 配置是否正确。
后端统一使用 CORS 中间件
题目没有给出 Python 框架,也没有说明 Request、Response 从哪里导入,仅凭装饰器无法确定框架。应优先使用实际框架提供的 CORS 中间件,统一处理预检和实际响应。
下面以 FastAPI、Starlette 的 CORSMiddleware 和 PyJWT 为例。settings 沿用项目现有配置;使用其他框架时,需要换成对应的 API。
import jwt
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.middleware.cors import CORSMiddleware
# settings 沿用项目现有的导入和配置。
api = FastAPI()
@api.get("/profile")
async def profile(request: Request):
auth_header = request.headers.get("Authorization", "")
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return JSONResponse(
status_code=401,
content={"error": "Unauthorized"},
)
token = parts[1]
try:
decoded_token = jwt.decode(
token,
settings.JWT_SECRET,
algorithms=["HS256"],
audience="authenticated",
)
except jwt.ExpiredSignatureError:
return JSONResponse(
status_code=401,
content={"error": "Token expired"},
)
except jwt.InvalidTokenError:
return JSONResponse(
status_code=401,
content={"error": "Invalid token"},
)
return JSONResponse(
content={"data": {"user": decoded_token}},
)
# 其他路由,例如 /login,也注册在 api 上。
app = CORSMiddleware(
app=api,
allow_origins=["http://localhost:8910"],
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
allow_credentials=False,
)
ASGI 服务入口应指向包装后的 app。把 CORS 中间件放在应用外层,也有助于让应用内部生成的错误响应带上 CORS 头。如果错误响应由外部代理生成,仍需检查代理配置。
使用这个示例时,需要注意:
- 前端通过请求头手动发送 token,没有使用跨域 Cookie,因此这里设置
allow_credentials=False。 HS256、密钥和audience沿用题目中的验证方式,实际值必须与 token 的签发配置一致。- 所有路由的 CORS 都交给中间件处理,无需再手写通配
OPTIONS路由和 CORS 响应头。
如果使用跨域 Cookie,前端需要按需求设置 credentials: 'include',后端需要启用 allow_credentials=True,并配置明确的 origin 白名单。
如果继续手写 CORS,至少要先校验 origin 白名单,让预检跳过业务鉴权,并确保允许的实际响应和错误响应都带有正确的 CORS 头。按 origin 动态返回响应时,还应设置 Vary: Origin。原来的 get_response_headers() 直接回显任意 Origin,没有检查白名单,不适合作为正式配置。
前端保留 Authorization,删除无用途的请求头
GET /profile 没有请求体,无需设置 Content-Type,可以改为:
const response = await fetch(
`${config.REDWOOD_ENV_API_ENDPOINT}/profile`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
}
)
删除 Content-Type 能减少一个不必要的请求头,但 Authorization 仍会触发预检,后端必须正确处理。
mode: 'cors' 是普通跨域 fetch 的默认模式,可以保留,也可以省略。设置它无法替后端生成 CORS 响应头。
接收 JSON 的登录接口应明确设置请求类型:
const response = await fetch(
`${config.REDWOOD_ENV_API_ENDPOINT}/login`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
}
)
这样调整后,/login 也可能产生预检,属于正常行为。
为什么改成 * 仍然无效
如果响应没有经过 CORS 处理逻辑,修改通配符也无济于事。通配符本身还有使用限制:
- 请求使用
credentials: 'include'时,Access-Control-Allow-Origin: *不符合要求,必须返回具体的 origin。 Authorization必须在Access-Control-Allow-Headers中明确列出,不能依赖*。Access-Control-Allow-Methods、Access-Control-Allow-Headers中通配符的语义也受凭据模式影响。当前场景直接列出方法和请求头,更容易验证。
手动设置 Authorization 并不要求同时设置 credentials: 'include'。仅使用 Bearer token 时,无需为了发送这个请求头而开启跨域 Cookie。
也不要改用 mode: 'no-cors'。该模式限制可设置的请求头,无法按当前方式发送 Authorization;返回的 opaque response 也不允许 JavaScript 读取状态和 JSON 内容,无法完成身份验证流程。
CORS 修复后,再检查鉴权逻辑
原后端还有一处独立的鉴权问题:
auth_header.startswith("bearer ")
这个判断区分大小写,会拒绝常见的 Bearer <token> 写法。认证方案名称应按大小写不敏感的方式比较,前面的示例使用 parts[0].lower() 处理了这一点。HTTP 请求头字段名本身也不区分大小写,通常无需分别读取 Authorization 和 authorization。
如果实际使用 FastAPI,还应注意:
- 路由参数应声明为
request: Request。 - FastAPI/Starlette 的
Response不使用description=设置响应体;JSON 响应可以使用JSONResponse(content=...)。
具体是否需要这样修改,应以项目实际使用的框架和导入为准。
验证时,先确认预检成功,再携带有效 token 请求 /profile,同时检查无效 token 返回的 401 能否被前端正常读取。实际响应也通过 CORS 检查后,前端才能区分鉴权失败和网络错误。
原前端在所有 catch 中直接调用 logOut(),会把 CORS 故障、服务不可用等情况都当作登录失效。应在明确收到表示 token 无效的响应时清理登录状态,并单独处理网络错误。登录代码中记录包含密码的完整请求体的语句也应删除。
备注:内容仅供参考。
