Fastapi教程: 身份认证与授权03——带有 password 和 Bearer 的简单 OAuth2

现在,让我们以上一章为基础,补充缺失的部分,以构成一个完整的安全流程。

获取 usernamepassword

我们将使用 FastAPI 的安全工具来获取 usernamepassword

OAuth2 规定,当使用"密码流"(也就是我们正在使用的)时,客户端/用户必须将 usernamepassword 字段作为表单数据发送。

并且规范要求这些字段必须这样命名。因此,使用 user-nameemail 是不行的。

但不用担心,你可以在前端按照自己的意愿向最终用户展示。

并且你的数据库模型可以使用任何你想要的其他名称。

但对于登录的 路径操作,我们需要使用这些名称以兼容规范(例如,能够使用集成的 API 文档系统)。

规范还规定,usernamepassword 必须作为表单数据发送(因此,这里不能使用 JSON)。

scope

规范还指出,客户端可以发送另一个名为 "scope" 的表单字段。

该表单字段的名称是 scope(单数形式),但它实际上是一个由空格分隔的包含多个"scope(范围)"的长字符串。

每个"scope"只是一个字符串(不带空格)。

它们通常用于声明特定的安全权限,例如:

  • users:readusers:write 是常见的例子。
  • instagram_basic 被 Facebook / Instagram 使用。
  • https://www.googleapis.com/auth/drive 被 Google 使用。

注意

在 OAuth2 中,"scope"只是一个声明所需特定权限的字符串。

无论它是否包含诸如 : 之类的其他字符,或者它是否是一个 URL,都没有关系。

这些细节是特定于实现的。

对于 OAuth2,它们只是字符串。

获取 usernamepassword 的代码

现在让我们使用 FastAPI 提供的工具来处理它。

OAuth2PasswordRequestForm

首先,导入 OAuth2PasswordRequestForm,并在 /token路径操作 中将其作为具有 Depends 的依赖项使用:

Python 3.10+

复制代码
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel

fake_users_db = {
    "johndoe": {
        "username": "johndoe",
        "full_name": "John Doe",
        "email": "johndoe@example.com",
        "hashed_password": "fakehashedsecret",
        "disabled": False,
    },
    "alice": {
        "username": "alice",
        "full_name": "Alice Wonderson",
        "email": "alice@example.com",
        "hashed_password": "fakehashedsecret2",
        "disabled": True,
    },
}

app = FastAPI()


def fake_hash_password(password: str):
    return "fakehashed" + password


oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


class User(BaseModel):
    username: str
    email: str | None = None
    full_name: str | None = None
    disabled: bool | None = None


class UserInDB(User):
    hashed_password: str


def get_user(db, username: str):
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)


def fake_decode_token(token):
    # This doesn't provide any security at all
    # Check the next version
    user = get_user(fake_users_db, token)
    return user


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    user = fake_decode_token(token)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user


async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)],
):
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="Inactive user")
    return current_user


@app.post("/token")
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user_dict = fake_users_db.get(form_data.username)
    if not user_dict:
        raise HTTPException(status_code=400, detail="Incorrect username or password")
    user = UserInDB(**user_dict)
    hashed_password = fake_hash_password(form_data.password)
    if not hashed_password == user.hashed_password:
        raise HTTPException(status_code=400, detail="Incorrect username or password")

    return {"access_token": user.username, "token_type": "bearer"}


@app.get("/users/me")
async def read_users_me(
    current_user: Annotated[User, Depends(get_current_active_user)],
):
    return current_user

OAuth2PasswordRequestForm 是一个类依赖项,用于声明包含以下内容的表单体:

  • username
  • password
  • 一个可选的 scope 字段作为长字符串,由空格分隔的字符串组成。
  • 一个可选的 grant_type

提示

OAuth2 规范实际上要求 一个固定值为 password 的字段 grant_type,但 OAuth2PasswordRequestForm 并未强制执行此要求。

如果你需要强制执行此要求,请使用 OAuth2PasswordRequestFormStrict 而不是 OAuth2PasswordRequestForm

  • 一个可选的 client_id(我们的示例中不需要它)。
  • 一个可选的 client_secret(我们的示例中不需要它)。

注意

OAuth2PasswordRequestForm 并不像 OAuth2PasswordBearer 那样是 FastAPI 的特殊类。

OAuth2PasswordBearerFastAPI 知道这是一个安全方案。因此它以这种方式被添加到 OpenAPI 中。

但是 OAuth2PasswordRequestForm 只是一个你可以自己编写的类依赖项,或者你可以直接声明 Form 参数。

但由于这是一个常见的用例,FastAPI 直接提供了它,以简化操作。

使用表单数据

提示

依赖类 OAuth2PasswordRequestForm 的实例将不会拥有包含由空格分隔的长字符串的属性 scope,相反,它将拥有一个 scopes 属性,其中包含发送的每个范围的实际字符串列表。

我们在本示例中并未使用 scopes,但如果你需要,该功能已经存在。

现在,使用表单字段中的 username 从(伪)数据库中获取用户数据。

如果不存在这样的用户,我们将返回一个错误,提示"用户名或密码错误"。

对于此错误,我们使用异常 HTTPException

Python 3.10+

复制代码
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel

fake_users_db = {
    "johndoe": {
        "username": "johndoe",
        "full_name": "John Doe",
        "email": "johndoe@example.com",
        "hashed_password": "fakehashedsecret",
        "disabled": False,
    },
    "alice": {
        "username": "alice",
        "full_name": "Alice Wonderson",
        "email": "alice@example.com",
        "hashed_password": "fakehashedsecret2",
        "disabled": True,
    },
}

app = FastAPI()


def fake_hash_password(password: str):
    return "fakehashed" + password


oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


class User(BaseModel):
    username: str
    email: str | None = None
    full_name: str | None = None
    disabled: bool | None = None


class UserInDB(User):
    hashed_password: str


def get_user(db, username: str):
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)


def fake_decode_token(token):
    # This doesn't provide any security at all
    # Check the next version
    user = get_user(fake_users_db, token)
    return user


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    user = fake_decode_token(token)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user


async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)],
):
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="Inactive user")
    return current_user


@app.post("/token")
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user_dict = fake_users_db.get(form_data.username)
    if not user_dict:
        raise HTTPException(status_code=400, detail="Incorrect username or password")
    user = UserInDB(**user_dict)
    hashed_password = fake_hash_password(form_data.password)
    if not hashed_password == user.hashed_password:
        raise HTTPException(status_code=400, detail="Incorrect username or password")

    return {"access_token": user.username, "token_type": "bearer"}


@app.get("/users/me")
async def read_users_me(
    current_user: Annotated[User, Depends(get_current_active_user)],
):
    return current_user

检查密码

此时,我们已经从数据库中获取了用户数据,但尚未检查密码。

让我们先将该数据放入 Pydantic UserInDB 模型中。

你绝对不应该保存明文密码,因此,我们将使用(伪)密码哈希系统。

如果密码不匹配,我们将返回相同的错误。

密码哈希

"哈希"是指:将某些内容(在本例中为密码)转换为看似乱码的字节序列(仅仅是一个字符串)。

每当你传递完全相同的内容(完全相同的密码)时,你都会得到完全相同的乱码。

但你无法将乱码转换回密码。

为什么使用密码哈希

如果你的数据库被盗,小偷将不会拥有你用户的明文密码,而只有哈希值。

因此,小偷将无法尝试在其他系统中使用这些相同的密码(由于许多用户在所有地方都使用相同的密码,这将是非常危险的)。

Python 3.10+

复制代码
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel

fake_users_db = {
    "johndoe": {
        "username": "johndoe",
        "full_name": "John Doe",
        "email": "johndoe@example.com",
        "hashed_password": "fakehashedsecret",
        "disabled": False,
    },
    "alice": {
        "username": "alice",
        "full_name": "Alice Wonderson",
        "email": "alice@example.com",
        "hashed_password": "fakehashedsecret2",
        "disabled": True,
    },
}

app = FastAPI()


def fake_hash_password(password: str):
    return "fakehashed" + password


oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


class User(BaseModel):
    username: str
    email: str | None = None
    full_name: str | None = None
    disabled: bool | None = None


class UserInDB(User):
    hashed_password: str


def get_user(db, username: str):
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)


def fake_decode_token(token):
    # This doesn't provide any security at all
    # Check the next version
    user = get_user(fake_users_db, token)
    return user


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    user = fake_decode_token(token)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user


async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)],
):
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="Inactive user")
    return current_user


@app.post("/token")
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user_dict = fake_users_db.get(form_data.username)
    if not user_dict:
        raise HTTPException(status_code=400, detail="Incorrect username or password")
    user = UserInDB(**user_dict)
    hashed_password = fake_hash_password(form_data.password)
    if not hashed_password == user.hashed_password:
        raise HTTPException(status_code=400, detail="Incorrect username or password")

    return {"access_token": user.username, "token_type": "bearer"}


@app.get("/users/me")
async def read_users_me(
    current_user: Annotated[User, Depends(get_current_active_user)],
):
    return current_user
关于 user_dict

UserInDB(**user_dict) 意味着:

user_dict 的键和值直接作为键值参数传递,相当于:

复制代码
UserInDB(
    username = user_dict["username"],
    email = user_dict["email"],
    full_name = user_dict["full_name"],
    disabled = user_dict["disabled"],
    hashed_password = user_dict["hashed_password"],
)

注意

有关 user_dict 的更完整说明,请回顾额外模型的文档

返回令牌

token 端点的响应必须是一个 JSON 对象。

它应该包含一个 token_type。在我们的示例中,由于我们使用的是 "Bearer" 令牌,令牌类型应为 "bearer"。

并且它应该包含一个 access_token,其值为包含我们访问令牌的字符串。

在这个简单的示例中,为了方便,我们将采取完全不安全的方式,直接返回相同的 username 作为令牌。

提示

在下一章中,你将看到一个真正安全的实现,包含密码哈希和 JWT 令牌。

但现在,让我们专注于我们需要的具体细节。

Python 3.10+

复制代码
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel

fake_users_db = {
    "johndoe": {
        "username": "johndoe",
        "full_name": "John Doe",
        "email": "johndoe@example.com",
        "hashed_password": "fakehashedsecret",
        "disabled": False,
    },
    "alice": {
        "username": "alice",
        "full_name": "Alice Wonderson",
        "email": "alice@example.com",
        "hashed_password": "fakehashedsecret2",
        "disabled": True,
    },
}

app = FastAPI()


def fake_hash_password(password: str):
    return "fakehashed" + password


oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


class User(BaseModel):
    username: str
    email: str | None = None
    full_name: str | None = None
    disabled: bool | None = None


class UserInDB(User):
    hashed_password: str


def get_user(db, username: str):
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)


def fake_decode_token(token):
    # This doesn't provide any security at all
    # Check the next version
    user = get_user(fake_users_db, token)
    return user


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    user = fake_decode_token(token)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user


async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)],
):
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="Inactive user")
    return current_user


@app.post("/token")
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user_dict = fake_users_db.get(form_data.username)
    if not user_dict:
        raise HTTPException(status_code=400, detail="Incorrect username or password")
    user = UserInDB(**user_dict)
    hashed_password = fake_hash_password(form_data.password)
    if not hashed_password == user.hashed_password:
        raise HTTPException(status_code=400, detail="Incorrect username or password")

    return {"access_token": user.username, "token_type": "bearer"}


@app.get("/users/me")
async def read_users_me(
    current_user: Annotated[User, Depends(get_current_active_user)],
):
    return current_user

提示

根据规范,你应该返回包含 access_tokentoken_type 的 JSON,与本示例相同。

这是你必须在代码中自己完成的事情,并确保使用这些 JSON 键。

这几乎是你必须记住并正确执行的唯一操作,以符合规范。

至于其余的部分,FastAPI 会为你处理。

更新依赖项

现在我们将更新我们的依赖项。

我们希望 在该用户处于活动状态时获取 current_user

因此,我们创建了一个额外的依赖项 get_current_active_user,它反过来将 get_current_user 用作依赖项。

如果用户不存在或未激活,这两个依赖项都将仅返回一个 HTTP 错误。

因此,在我们的端点中,只有在用户存在、已通过身份验证且处于活动状态时,我们才能获取到用户:

Python 3.10+

复制代码
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel

fake_users_db = {
    "johndoe": {
        "username": "johndoe",
        "full_name": "John Doe",
        "email": "johndoe@example.com",
        "hashed_password": "fakehashedsecret",
        "disabled": False,
    },
    "alice": {
        "username": "alice",
        "full_name": "Alice Wonderson",
        "email": "alice@example.com",
        "hashed_password": "fakehashedsecret2",
        "disabled": True,
    },
}

app = FastAPI()


def fake_hash_password(password: str):
    return "fakehashed" + password


oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


class User(BaseModel):
    username: str
    email: str | None = None
    full_name: str | None = None
    disabled: bool | None = None


class UserInDB(User):
    hashed_password: str


def get_user(db, username: str):
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)


def fake_decode_token(token):
    # This doesn't provide any security at all
    # Check the next version
    user = get_user(fake_users_db, token)
    return user


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    user = fake_decode_token(token)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user


async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)],
):
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="Inactive user")
    return current_user


@app.post("/token")
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user_dict = fake_users_db.get(form_data.username)
    if not user_dict:
        raise HTTPException(status_code=400, detail="Incorrect username or password")
    user = UserInDB(**user_dict)
    hashed_password = fake_hash_password(form_data.password)
    if not hashed_password == user.hashed_password:
        raise HTTPException(status_code=400, detail="Incorrect username or password")

    return {"access_token": user.username, "token_type": "bearer"}


@app.get("/users/me")
async def read_users_me(
    current_user: Annotated[User, Depends(get_current_active_user)],
):
    return current_user

注意

我们在此处返回的值为 Bearer 的附加标头 WWW-Authenticate 也是规范的一部分。

任何 HTTP(错误)状态码 401 "UNAUTHORIZED" 都应该返回一个 WWW-Authenticate 标头。

对于 Bearer 令牌(我们的情况),该标头的值应该是 Bearer

你实际上可以跳过那个额外的标头,它仍然可以工作。

但这里提供它是为了符合规范。

此外,可能会有工具(现在或将来)期望并使用它,这对你或你的用户(现在或将来)可能很有用。

这就是标准的好处......

查看其实际效果

打开交互式文档:http://127.0.0.1:8000/docs。

认证

点击"Authorize"按钮。

使用以下凭据:

用户名:johndoe

密码:secret

在系统中进行身份验证后,你会看到如下所示:

获取你自己的用户数据

现在使用路径为 /users/me 的操作 GET

你将获得你的用户数据,例如:

复制代码
{
  "username": "johndoe",
  "email": "johndoe@example.com",
  "full_name": "John Doe",
  "disabled": false,
  "hashed_password": "fakehashedsecret"
}

如果你单击锁形图标并注销,然后再次尝试相同的操作,你将收到以下 HTTP 401 错误:

复制代码
{
  "detail": "Not authenticated"
}

非活跃用户

现在尝试使用非活跃用户进行身份验证,使用以下凭据:

用户:alice

密码:secret2

并尝试使用路径为 /users/me 的操作 GET

你将收到一个"非活跃用户"错误,例如:

复制代码
{
  "detail": "Inactive user"
}

回顾

现在你已经掌握了相关工具,可以为你的 API 实现一个基于 usernamepassword 的完整安全系统。

使用这些工具,你可以使安全系统兼容任何数据库以及任何用户或数据模型。

唯一遗漏的细节是它实际上还不是"安全"的。

在下一章中,你将了解如何使用安全的密码哈希库和 JWT 令牌。

https://fastapi.tiangolo.com/tutorial/security/simple-oauth2

相关推荐
java1234_小锋1 天前
FastAPI python web开发- 表单数据与模型 & 请求表单与文件 & 响应类型 - 返回文件类型
fastapi
hboot1 天前
AI工程师第五课 - 大语言模型基础
python·llm·fastapi
喜欢的名字被抢了2 天前
Python实战:SQLAlchemy ORM与FastAPI项目集成
开发语言·python·sql·教程·fastapi
阿豪只会阿巴2 天前
两小时快速入门 FastAPI--第二回
windows·python·fastapi
阿豪只会阿巴2 天前
两小时快速入门 FastAPI--第一回
开发语言·python·fastapi
迷途呀3 天前
新闻头条后端:新闻缓存模块
前端·redis·python·缓存·fastapi
CaffeinePro4 天前
FastAPI数据库集成SQLAlchemy异步ORM全方案
后端·fastapi
能有时光4 天前
从卡顿到丝滑:FastAPI 调用外部 API 的正确姿势(httpx 实战)
fastapi·httpx
Mr.朱鹏4 天前
【FastAPI 全栈实战 | 第2篇】Pydantic 数据校验与响应模型 —— 让 Bug 死在请求进来的那一刻
人工智能·python·fastapi