HTTP 基本认证¶
对于最简单的情况,你可以使用 HTTP 基本认证。
在 HTTP 基本认证中,应用程序期望一个包含用户名和密码的请求头。
如果未收到该请求头,它将返回一个 HTTP 401 "未授权" 错误。
并返回一个值为 Basic
的 WWW-Authenticate
头,以及一个可选的 realm
参数。
这会告诉浏览器显示一个用于输入用户名和密码的集成提示。
然后,当你输入用户名和密码时,浏览器会自动将它们发送在请求头中。
简单的 HTTP 基本认证¶
- 导入
HTTPBasic
和HTTPBasicCredentials
。 - 使用
HTTPBasic
创建一个 "security
方案"。 - 在你的 路径操作 中使用该
security
作为依赖项。 - 它返回一个
HTTPBasicCredentials
类型的对象:- 该对象包含发送的用户名和密码。
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
security = HTTPBasic()
@app.get("/users/me")
def read_current_user(credentials: Annotated[HTTPBasicCredentials, Depends(security)]):
return {"username": credentials.username, "password": credentials.password}
from fastapi import Depends, FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from typing_extensions import Annotated
app = FastAPI()
security = HTTPBasic()
@app.get("/users/me")
def read_current_user(credentials: Annotated[HTTPBasicCredentials, Depends(security)]):
return {"username": credentials.username, "password": credentials.password}
Tip
如果可能,建议使用 Annotated
版本。
from fastapi import Depends, FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
security = HTTPBasic()
@app.get("/users/me")
def read_current_user(credentials: HTTPBasicCredentials = Depends(security)):
return {"username": credentials.username, "password": credentials.password}
当你第一次尝试打开该 URL(或在文档中点击 "执行" 按钮)时,浏览器会要求你输入用户名和密码:
检查用户名¶
这里是一个更完整的示例。
使用依赖项来检查用户名和密码是否正确。
为此,使用 Python 标准模块 secrets
来检查用户名和密码。
secrets.compare_digest()
需要接受 bytes
或仅包含 ASCII 字符(即英文中的字符)的 str
,这意味着它无法处理像 á
这样的字符,例如 Sebastián
。
为了处理这种情况,我们首先将 username
和 password
转换为 bytes
,使用 UTF-8 编码。
然后我们可以使用 secrets.compare_digest()
来确保 credentials.username
是 "stanleyjobson"
,并且 credentials.password
是 "swordfish"
。
import secrets
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
security = HTTPBasic()
def get_current_username(
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
):
current_username_bytes = credentials.username.encode("utf8")
correct_username_bytes = b"stanleyjobson"
is_correct_username = secrets.compare_digest(
current_username_bytes, correct_username_bytes
)
current_password_bytes = credentials.password.encode("utf8")
correct_password_bytes = b"swordfish"
is_correct_password = secrets.compare_digest(
current_password_bytes, correct_password_bytes
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
@app.get("/users/me")
def read_current_user(username: Annotated[str, Depends(get_current_username)]):
return {"username": username}
import secrets
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from typing_extensions import Annotated
app = FastAPI()
security = HTTPBasic()
def get_current_username(
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
):
current_username_bytes = credentials.username.encode("utf8")
correct_username_bytes = b"stanleyjobson"
is_correct_username = secrets.compare_digest(
current_username_bytes, correct_username_bytes
)
current_password_bytes = credentials.password.encode("utf8")
correct_password_bytes = b"swordfish"
is_correct_password = secrets.compare_digest(
current_password_bytes, correct_password_bytes
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
@app.get("/users/me")
def read_current_user(username: Annotated[str, Depends(get_current_username)]):
return {"username": username}
Tip
如果可能,建议使用 Annotated
版本。
import secrets
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
security = HTTPBasic()
def get_current_username(credentials: HTTPBasicCredentials = Depends(security)):
current_username_bytes = credentials.username.encode("utf8")
correct_username_bytes = b"stanleyjobson"
is_correct_username = secrets.compare_digest(
current_username_bytes, correct_username_bytes
)
current_password_bytes = credentials.password.encode("utf8")
correct_password_bytes = b"swordfish"
is_correct_password = secrets.compare_digest(
current_password_bytes, correct_password_bytes
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
@app.get("/users/me")
def read_current_user(username: str = Depends(get_current_username)):
return {"username": username}
这类似于:
if not (credentials.username == "stanleyjobson") or not (credentials.password == "swordfish"):
# 返回一些错误
...
但通过使用 secrets.compare_digest()
,它将能够抵御一种称为 "计时攻击" 的攻击。
计时攻击¶
但什么是 "计时攻击" 呢?
假设一些攻击者正在尝试猜测用户名和密码。
他们发送一个包含用户名 johndoe
和密码 love123
的请求。
然后你的应用程序中的 Python 代码将类似于:
if "johndoe" == "stanleyjobson" and "love123" == "swordfish":
...
但在 Python 比较 johndoe
中的第一个 j
和 stanleyjobson
中的第一个 s
的那一刻,它会返回 False
,因为它已经知道这两个字符串不同,认为 "没有必要浪费更多计算来比较其余的字母"。然后你的应用程序会说 "用户名或密码不正确"。
但随后攻击者尝试使用用户名 stanleyjobsox
和密码 love123
。
你的应用程序代码会做类似的事情:
if "stanleyjobsox" == "stanleyjobson" and "love123" == "swordfish":
...
Python 将不得不比较 stanleyjobsox
和 stanleyjobson
中的整个 stanleyjobso
,然后才意识到这两个字符串不同。因此,它将花费一些额外的微秒时间来回复 "用户名或密码不正确"。
响应时间帮助了攻击者¶
在这一点上,通过注意到服务器花费了更长时间来发送 "用户名或密码不正确" 的响应,攻击者将知道他们猜对了 某些东西,一些初始字母是正确的。
然后他们可以再次尝试,知道它可能更类似于 stanleyjobsox
而不是 johndoe
。
一次 "专业" 的攻击¶
当然,攻击者不会手动尝试所有这些,他们会编写一个程序来完成,可能每秒进行数千或数百万次测试。并且每次只猜对一个额外的字母。
但通过这样做,在几分钟或几小时内,攻击者将猜出正确的用户名和密码,借助我们的应用程序,仅仅通过响应时间来帮助他们。
使用 secrets.compare_digest()
修复¶
但在我们的代码中,实际上使用的是 secrets.compare_digest()
。
简而言之,将 stanleyjobsox
与 stanleyjobson
进行比较所需的时间与将 johndoe
与 stanleyjobson
进行比较所需的时间相同。密码的比较也是如此。
这样一来,在你的应用程序代码中使用 secrets.compare_digest()
,就能安全抵御这一系列的安全攻击。
返回错误¶
在检测到凭证不正确后,返回一个状态码为 401 的 HTTPException
(与未提供凭证时返回的状态码相同),并添加 WWW-Authenticate
头,使浏览器再次显示登录提示:
import secrets
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
security = HTTPBasic()
def get_current_username(
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
):
current_username_bytes = credentials.username.encode("utf8")
correct_username_bytes = b"stanleyjobson"
is_correct_username = secrets.compare_digest(
current_username_bytes, correct_username_bytes
)
current_password_bytes = credentials.password.encode("utf8")
correct_password_bytes = b"swordfish"
is_correct_password = secrets.compare_digest(
current_password_bytes, correct_password_bytes
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
@app.get("/users/me")
def read_current_user(username: Annotated[str, Depends(get_current_username)]):
return {"username": username}
import secrets
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from typing_extensions import Annotated
app = FastAPI()
security = HTTPBasic()
def get_current_username(
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
):
current_username_bytes = credentials.username.encode("utf8")
correct_username_bytes = b"stanleyjobson"
is_correct_username = secrets.compare_digest(
current_username_bytes, correct_username_bytes
)
current_password_bytes = credentials.password.encode("utf8")
correct_password_bytes = b"swordfish"
is_correct_password = secrets.compare_digest(
current_password_bytes, correct_password_bytes
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
@app.get("/users/me")
def read_current_user(username: Annotated[str, Depends(get_current_username)]):
return {"username": username}
Tip
如果可能,建议使用 Annotated
版本。
import secrets
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
security = HTTPBasic()
def get_current_username(credentials: HTTPBasicCredentials = Depends(security)):
current_username_bytes = credentials.username.encode("utf8")
correct_username_bytes = b"stanleyjobson"
is_correct_username = secrets.compare_digest(
current_username_bytes, correct_username_bytes
)
current_password_bytes = credentials.password.encode("utf8")
correct_password_bytes = b"swordfish"
is_correct_password = secrets.compare_digest(
current_password_bytes, correct_password_bytes
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
@app.get("/users/me")
def read_current_user(username: str = Depends(get_current_username)):
return {"username": username}