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

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

获取 username 和 password

我们将使用 FastAPI 的安全工具来获取 username 和 password。

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

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

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

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

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

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

scope

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

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

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

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

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

注意

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

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

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

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

获取 username 和 password 的代码

现在让我们使用 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 的特殊类。

OAuth2PasswordBearer 让 FastAPI 知道这是一个安全方案。因此它以这种方式被添加到 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_token 和 token_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 实现一个基于 username 和 password 的完整安全系统。

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

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

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

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

相关推荐
在世修行16 小时前
干货:文件上传端点解析
python·fastapi·接收上传
云和数据.ChenGuang21 小时前
JAVAEE AI工程师路线图
java·人工智能·java-ee·fastapi·springai·langchain4j
风早爽太1 天前
Python 学习笔记:SQLModel 使用外键关联查询数据
python·fastapi·sqlmodel
风早爽太2 天前
Python 学习笔记:数据库迁移工具 ‌Alembic
数据库·python·fastapi·alembic
Web3&Basketball2 天前
用FastAPI+Redis 复刻常驻Agent
redis·bootstrap·fastapi
风早爽太5 天前
Python 开发笔记:配置生产环境中 Celery Worker 的独立进程启动方式
python·fastapi
Java的搬运工7 天前
【无标题】
python·机器学习·docker·fastapi·模型部署·mlops·ai工程化
云和数据.ChenGuang7 天前
langchain4j的RAG入门
人工智能·深度学习·机器学习·语言模型·fastapi
风早爽太7 天前
用 ‌Render‌ 快速部署网站和常见问题解决
python·fastapi
艺杯羹8 天前
240GB流量击穿与频繁OOM:从云端瘫痪到客户端自治的本地优先架构重构实录
性能优化·系统架构·fastapi·桌面客户端·本地优先