正如这里所描述的那样,无论您使用什么语言或框架,都不能设置自定义标头并重定向到另一个域。在HTTP协议中,重定向基本上是与响应相关联的标头(即Location
),它不允许添加任何标头到目标位置。当您在示例中添加Authorization
标头时,实际上是为响应设置了该标头,指示浏览器进行重定向,而不是针对重定向本身。换句话说,您正在将该标头发送回客户端。
关于
HTTP cookies,浏览器会使用响应中的
Set-Cookie
报头存储服务器发送的cookie,并在稍后发送同一服务器的请求时,将这些cookie与
Cookie
HTTP报头一起发送。根据
文档的说明:
Set-Cookie
HTTP响应头用于从服务器向用户代理发送cookie,以便用户代理可以稍后将其发送回服务器。要发送多个cookie,请在同一响应中发送多个Set-Cookie
头。
因此,如果这是从一个应用程序(具有子域,例如
abc.example.test
)重定向到另一个应用程序(具有子域,例如
xyz.example.test
),两个应用程序都具有相同的(父)域(并且在创建 cookie 时设置了
domain
标志为
example.test
),则 cookie 将在两个应用程序之间成功共享(如
如果指定了 domain
,则始终包括子域)。浏览器将使 cookie 可用于给定的域,包括任何子域,无论使用哪种协议(HTTP/HTTPS)或端口。您可以使用
domain
和
path
标志限制 cookie 的可用性,以及使用
secure
和
httpOnly
标志
限制访问 cookie(请参见
此处 和
此处,以及
Starlette 文档)。如果未设置
httpOnly
标志,则潜在攻击者可以通过 JavaScript(JS)读取和修改信息,而具有
httpOnly
属性的 cookie 仅发送到服务器,并且对客户端上的 JS 不可访问。
然而,
您无法为不同的域设置cookie。如果允许这样做,将会出现巨大的安全漏洞。因此,由于您正在尝试将用户从一个应用程序(域)重定向到另一个应用程序(域)并设置一些cookie,这是行不通的,因为cookie只会随着发送到
相同域的请求一起发送。
解决方案1
如此处所述,一种解决方案是让域(应用程序)A将用户重定向到域(应用程序)B,并将access-token
作为查询参数传递到URL中。然后,域B将读取令牌并设置自己的cookie,以便浏览器将存储并在以后的每个请求中发送该cookie到域B。
请注意,您应该考虑使用安全的(HTTPS)通信方式,以便令牌被传输时进行加密,并在创建cookie时设置“secure”标志。此外,请注意,在
查询字符串中包含令牌会
带来严重的安全风险,因为敏感数据不应通过查询字符串传递。这是因为查询字符串是URL的一部分,出现在浏览器的地址栏中;因此,允许用户查看并将带有令牌的URL添加到书签中(这意味着它保存在磁盘上)。而且,URL将出现在
浏览历史记录中,这意味着它将被写入磁盘并出现在
History
选项卡中(按
Ctrl+H查看浏览器的历史记录)。以上两者都会允许攻击者(以及与您共享计算机/移动设备的人)窃取此类敏感数据。此外,许多浏览器插件/扩展跟踪用户的浏览活动-您访问的每个URL都会发送到其服务器进行分析,以便检测恶意网站并事先警告您。因此,在使用以下方法之前,您应该考虑所有以上内容(有关此主题的相关帖子,请参见
这里,
这里和
这里)。
为了防止在地址栏中显示URL,以下方法还在域B内使用重定向。一旦域B接收到带有令牌作为查询参数的
/submit
路由的请求,域B将响应重定向到一个没有令牌的裸URL(即其
home
页面)。由于这个重定向,带有令牌的URL不会出现在浏览历史记录中。虽然这提供了一些保护措施来防范前面描述的某些攻击,但这并不意味着浏览器扩展等无法捕获带有令牌的URL。
如果您在本地主机上进行测试,则需要为应用程序B指定不同的域名;否则,如前所述,具有相同域的应用程序之间将共享cookie,因此您最终会收到为域A设置的cookie,并且无法判断该方法是否起作用。要做到这一点,您必须编辑
/etc/hosts
文件(在Windows上位于
C:\Windows\System32\drivers\etc
),并将主机名分配给
127.0.0.1
。例如:
127.0.0.1 example.test
您不应该将方案或端口添加到域名中,也不应该使用常见的扩展名,例如.com
,.net
等,否则可能会与访问互联网上的其他网站发生冲突。
访问下面的A域名后,您需要点击“提交”按钮执行一个POST请求到/submit路由来开始重定向。唯一需要使用POST请求的原因是因为它在您的示例中使用,并且我假设您需要发布一些表单数据。否则,您也可以使用GET请求。在应用程序B中,从POST路由(即/submit)执行RedirectResponse到GET路由(即/)时,响应状态代码会更改为status.HTTP_303_SEE_OTHER,如
此处,
此处和
此处所述。应用程序A正在监听端口8000,而应用程序B正在监听端口8001。
运行下面两个应用程序,然后访问A域名http://127.0.0.1:8000/。
appA.py
from fastapi import FastAPI
from fastapi.responses import RedirectResponse, HTMLResponse
import uvicorn
app = FastAPI()
@app.get('/', response_class=HTMLResponse)
def home():
return """
<!DOCTYPE html>
<html>
<body>
<h2>Click the "submit" button to be redirected to domain B</h2>
<form method="POST" action="/submit">
<input type="submit" value="Submit">
</form>
</body>
</html>
"""
@app.post("/submit")
def submit():
token = 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3'
redirect_url = f'http://example.test:8001/submit?token={token}'
response = RedirectResponse(redirect_url)
response.set_cookie(key='access-token', value=token, httponly=True)
return response
if __name__ == '__main__':
uvicorn.run(app, host='0.0.0.0', port=8000)
appB.py
from fastapi import FastAPI, Request, status
from fastapi.responses import RedirectResponse
import uvicorn
app = FastAPI()
@app.get('/')
def home(request: Request):
token = request.cookies.get('access-token')
print(token)
return 'You have been successfully redirected to domain B!' \
f' Your access token ends with: {token[-4:]}'
@app.post('/submit')
def submit(request: Request, token: str):
redirect_url = request.url_for('home')
response = RedirectResponse(redirect_url, status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(key='access-token', value=token, httponly=True)
return response
if __name__ == '__main__':
uvicorn.run(app, host='0.0.0.0', port=8001)
解决方案2
另一个解决方案是使用Window.postMessage()
,它可以在Window
对象之间启用跨源通信;例如,在页面和它生成的pop-up
之间,或者在嵌入其中的iframe
和页面之间。关于如何添加事件监听器和在窗口之间进行通信的示例可以在这里找到。需要遵循以下步骤:
步骤1:在域A中添加一个隐藏的iframe
到域B。例如:
<iframe id="cross_domain_page" src="http://example.test:8001" frameborder="0" scrolling="no" style="background:transparent;margin:auto;display:block"></iframe>
步骤2:一旦从向域A发出的
异步JS请求的头部获取
Authorization
令牌,立即将其发送到域B。例如:
document.getElementById('cross_domain_page').contentWindow.postMessage(token,"http://example.test:8001");
第三步:在域B中,通过
window.addEventListener("message", (event) ...
接收令牌,并将其存储在
localStorage
中。
localStorage.setItem('token', event.data);
或者,使用JS在cookie中(不推荐,请参见下面的注释):
document.cookie = `token=${event.data}
步骤四:向域A发送令牌已存储的消息,然后将用户重定向到域B。
注意 1:第三步演示了如何使用JS设置cookie,但是当您要存储此类敏感信息时,
不应该真的使用JS,因为
通过JS创建的cookie无法包含HttpOnly
标志,这有助于减轻跨站点脚本(
XSS)攻击。这意味着可能已经将恶意脚本注入到您的网站中的攻击者将能够访问cookie。相反,您应该让服务器设置cookie(通过
fetch
请求),包括
HttpOnly
标志(如下例所示),从而使cookie对JS
Document.cookie
API不可访问。
localStorage
也容易受到XSS攻击,因为数据也可以通过JS访问(例如,
localStorage.getItem('token')
)。
注意2:为了使此解决方案起作用,用户必须在其浏览器中启用
允许所有Cookie
选项——很多用户不会这样做,而一些浏览器默认排除
第三方Cookie(Safari和Chrome的In Private模式已知默认拒绝这些Cookie)——因为内容正在从不同的域加载到一个
iframe
中,因此Cookie被视为
第三方 cookie。对于使用
localStorage
也是如此(即必须启用
允许所有Cookie
才能通过
iframe
使用它)。然而,在这种情况下使用Cookie,您还需要将
SameSite
标志设置为
None
,并且Cookie应包括
Secure
标志,
这是必需的,以便使用SameSite=None
。这意味着Cookie仅会通过
HTTPS连接发送;这不会消除与跨站点访问相关的所有风险,但它将提供对网络攻击的保护(如果您的服务器未运行在HTTPS上,仅用于演示目的,则可以在Chrome浏览器的
chrome://flags/
中使用
'Insecure origins treated as secure'
实验性功能)。设置
SameSite=None
意味着Cookie
将不受保护免受外部访问,因此在使用之前您应该了解风险。
使用 iframe
和 SameSite=None; Secure; HttpOnly
cookie 的示例
运行以下两个应用程序,然后访问域 A http://127.0.0.1:8000/。
appA.py
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get('/', response_class=HTMLResponse)
def home():
return """
<!DOCTYPE html>
<html>
<body>
<h2>Click the "submit" button to be redirected to domain B</h2>
<input type="button" value="Submit" onclick="submit()"><br>
<iframe id="cross_domain_page" src="http://example.test:8001/iframe" frameborder="0" scrolling="no" style="background:transparent;margin:auto;display:block"></iframe>
<script>
function submit() {
fetch('/submit', {
method: 'POST',
})
.then(res => {
authHeader = res.headers.get('Authorization');
if (authHeader.startsWith("Bearer "))
token = authHeader.substring(7, authHeader.length);
return res.text();
})
.then(data => {
document.getElementById('cross_domain_page').contentWindow.postMessage(token, "http://example.test:8001");
})
.catch(error => {
console.error(error);
});
}
window.addEventListener("message", (event) => {
if (event.origin !== "http://example.test:8001")
return;
if (event.data == "cookie is set")
window.location.href = 'http://example.test:8001/';
}, false);
</script>
</body>
</html>
"""
@app.post('/submit')
def submit():
token = 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3'
headers = {'Authorization': f'Bearer {token}'}
response = Response('success', headers=headers)
response.set_cookie(key='access-token', value=token, httponly=True)
return response
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host='0.0.0.0', port=8000)
appB.py
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get('/iframe', response_class=HTMLResponse)
def iframe():
return """
<!DOCTYPE html>
<html>
<head>
<script>
window.addEventListener("message", (event) => {
if (event.origin !== "http://127.0.0.1:8000")
return;
fetch('/submit', {
method: 'POST',
headers: {
'Authorization': `Bearer ${event.data}`
}
})
.then(res => res.text())
.then(data => {
event.source.postMessage("cookie is set", event.origin);
})
.catch(error => {
console.error(error);
})
}, false);
</script>
</head>
</html>
"""
@app.get('/')
def home(request: Request):
token = request.cookies.get('access-token')
print(token)
return 'You have been successfully redirected to domain B!' \
f' Your access token ends with: {token[-4:]}'
@app.post('/submit')
def submit(request: Request):
authHeader = request.headers.get('Authorization')
if authHeader.startswith("Bearer "):
token = authHeader[7:]
response = Response('success')
response.set_cookie(key='access-token', value=token, samesite='none', secure=True, httponly=True)
return response
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host='0.0.0.0', port=8001)
使用 iframe
和 localStorage
的示例
此示例演示了一种使用 localStorage
存储令牌的方法。一旦令牌被存储,域 A 会将用户重定向到域 B 的 /redirect
路由;然后,域 B 从 localStorage
中检索令牌(并随后从中删除它),稍后将其发送到自己的 /submit
路由,以设置 access-token
的 httpOnly
cookie。最后,用户被重定向到域 B 的主页。
appA.py
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get('/', response_class=HTMLResponse)
def home():
return """
<!DOCTYPE html>
<html>
<body>
<h2>Click the "submit" button to be redirected to domain B</h2>
<input type="button" value="Submit" onclick="submit()"><br>
<iframe id="cross_domain_page" src="http://example.test:8001/iframe" frameborder="0" scrolling="no" style="background:transparent;margin:auto;display:block"></iframe>
<script>
function submit() {
fetch('/submit', {
method: 'POST',
})
.then(res => {
authHeader = res.headers.get('Authorization');
if (authHeader.startsWith("Bearer "))
token = authHeader.substring(7, authHeader.length);
return res.text();
})
.then(data => {
document.getElementById('cross_domain_page').contentWindow.postMessage(token, "http://example.test:8001");
})
.catch(error => {
console.error(error);
});
}
window.addEventListener("message", (event) => {
if (event.origin !== "http://example.test:8001")
return;
if (event.data == "token stored")
window.location.href = 'http://example.test:8001/redirect';
}, false);
</script>
</body>
</html>
"""
@app.post('/submit')
def submit():
token = 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3'
headers = {'Authorization': f'Bearer {token}'}
response = Response('success', headers=headers)
response.set_cookie(key='access-token', value=token, httponly=True)
return response
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host='0.0.0.0', port=8000)
appB.py
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get('/iframe', response_class=HTMLResponse)
def iframe():
return """
<!DOCTYPE html>
<html>
<head>
<script>
window.addEventListener("message", (event) => {
if (event.origin !== "http://127.0.0.1:8000")
return;
localStorage.setItem('token', event.data);
event.source.postMessage("token stored", event.origin);
}, false);
</script>
</head>
</html>
"""
@app.get('/redirect', response_class=HTMLResponse)
def redirect():
return """
<!DOCTYPE html>
<html>
<head>
<script>
const token = localStorage.getItem('token');
localStorage.removeItem("token");
fetch('/submit', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => res.text())
.then(data => {
window.location.href = 'http://example.test:8001/';
})
.catch(error => {
console.error(error);
})
</script>
</head>
</html>
"""
@app.get('/')
def home(request: Request):
token = request.cookies.get('access-token')
print(token)
return 'You have been successfully redirected to domain B!' \
f' Your access token ends with: {token[-4:]}'
@app.post('/submit')
def submit(request: Request):
authHeader = request.headers.get('Authorization')
if authHeader.startswith("Bearer "):
token = authHeader[7:]
response = Response('success')
response.set_cookie(key='access-token', value=token, httponly=True)
return response
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host='0.0.0.0', port=8001)
解决方案 3
请看
这里和
这里,了解StackExchange的自动登录是如何工作的;例如,当您已经登录到StackOverflow(SO)时,StackExchange(SE)会自动将您登录。简而言之,他们不使用第三方cookie,而是结合全局认证在他们的集中域
http://stackauth.com中使用
localStorage
。即使不使用第三方cookie,他们也会像
这里所述一样使用
iframe
将令牌存储在
localStorage
中。这意味着,只有当用户的浏览器接受
第三方cookie(如解决方案2中所述),此方法才能起作用。正如解决方案2中所解释的那样,即使您在嵌入主窗口的
iframe
中访问
localStorage
而不是
document.cookie
,您仍然需要用户在其浏览器设置中启用
允许所有cookie
;否则,它将无法工作,并且如果他们尝试访问
SE网络中的任何其他站点,用户将被要求重新登录。
更新
上述方法是 SE 自动登录过去的工作方式。现在,这种方法有些不同,如更近期的帖子所述,实际上描述了 SE 通用登录的工作方式(您可以通过检查浏览器 DevTools 中的网络活动,在登录到其中一个 SE 站点时验证该过程;例如,SO)。
它的工作方式是在您登录到 SE 站点之一时,注入指向其他 Stack Exchange 站点(即 serverfault.com、superuser.com 等)的 <img>
标记。这些 <img>
标记的 src
URL 包括一个唯一的 authToken
作为查询参数,该参数是由通用身份验证系统生成并通过 XMLHttpRequest
POST
请求获得的。这些 <img>
标记的示例如下:
<img src="https://serverfault.com/users/login/universal.gif?authToken=<some-token-value>&nonce=<some-nonce-value>" />
您的浏览器将发送带有
authToken
的
src
URL到其他站点(您当前不在该站点上),并在响应该图像时,为每个给定的域/站点返回两个cookie:
prov
和
acct
。当您稍后切换到其他SE站点之一时,您的浏览器将发送您先前接收到的
prov
和
acct
cookie,以便站点验证令牌并(如果有效)登录您。
注意: 为使此方法生效,您的浏览器需要接受第三方 cookie(如前所述),因为cookie需要设置SameSite=None; Secure
标志(请注意上述风险)。如果不允许第三方cookie——以及不使用HTTPS运行服务器——通用自动登录将无法工作。此外,您正在尝试设置cookie的其他域需要启用CORS,因为当从不同的域加载img
时,会执行跨源请求。此外,由于此方法在URL的查询参数中发送authToken
(即使它是在后台而不是在浏览器的地址栏中进行的),您应该注意解决方案1中描述的风险。
下面使用指向域 B 的
<img>
标签。图片 URL 不必是实际的图片,服务器仍然可以接收
access-token
,因此您可以使用
.onerror()
函数检查请求何时完成(即服务器已响应
Set-Cookie
标头),以便将用户重定向到域 B。
相反,可以使用带有
Authorization
标头的
fetch
请求到域 B,并且服务器可以类似地响应以设置 cookie。在这种情况下,请确保使用
credentials: 'include'
和
mode: 'cors'
,并明确指定服务器端允许的来源,如
此处所述。
在下面同时运行两个应用程序,并随后访问A域名
http://127.0.0.1:8000/。
appA.py
from fastapi import FastAPI, Response
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get('/', response_class=HTMLResponse)
def home():
return """
<!DOCTYPE html>
<html>
<body>
<h2>Click the "submit" button to be redirected to domain B</h2>
<input type="button" value="Submit" onclick="submit()"><br>
<script>
function submit() {
fetch('/submit', {
method: 'POST',
})
.then(res => {
authHeader = res.headers.get('Authorization');
if (authHeader.startsWith("Bearer "))
token = authHeader.substring(7, authHeader.length);
return res.text();
})
.then(data => {
var url = 'http://example.test:8001/submit?token=' + encodeURIComponent(token);
var img = document.createElement('img');
img.style = 'display:none';
img.crossOrigin = 'use-credentials'; // needed for CORS
img.onerror = function(){
window.location.href = 'http://example.test:8001/';
}
img.src = url;
})
.catch(error => {
console.error(error);
});
}
</script>
</body>
</html>
"""
@app.post('/submit')
def submit():
token = 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3'
headers = {'Authorization': f'Bearer {token}'}
response = Response('success', headers=headers)
response.set_cookie(key='access-token', value=token, httponly=True)
return response
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host='0.0.0.0', port=8000)
appB.py
from fastapi import FastAPI, Request, Response
from fastapi.responses import RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
origins = ['http://localhost:8000', 'http://127.0.0.1:8000',
'https://localhost:8000', 'https://127.0.0.1:8000']
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get('/')
def home(request: Request):
token = request.cookies.get('access-token')
print(token)
return 'You have been successfully redirected to domain B!' \
f' Your access token ends with: {token[-4:]}'
@app.get('/submit')
def submit(request: Request, token: str):
response = Response('success')
response.set_cookie(key='access-token', value=token, samesite='none', secure=True, httponly=True)
return response
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host='0.0.0.0', port=8001)