Python 现代开发学习笔记

(十一)账号、密码摘要与访问令牌

1. 登录后交回一份身份凭据

上一章让浏览器通过 Cookie 自动带上会话编号。这次 API 使用 Authorization: Bearer <令牌> 请求头:登录后由调用者保存令牌,再主动添加到后续请求。我们仍然在数据库中查找它对应的用户。

用户名使用 3 至 30 个英文字母、数字或下划线;密码保留原有文本,长度为 8 至 128 个字符。账号输出只保留编号与用户名。

# 续写同一文件:api_project/src/bookapi/schemas.py
Username = Annotated[str, Field(strict=True, pattern=r"^[a-zA-Z0-9_]{3,30}$")]
Password = Annotated[str, Field(strict=True, min_length=8, max_length=128)]


class Credentials(BaseModel):
    # 密码保留原有空格,不沿用书名的去空白配置。
    model_config = ConfigDict(extra="forbid")
    username: Username
    password: Password


class UserOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    username: str


class TokenOut(BaseModel):
    access_token: str
    token_type: Literal["bearer"] = "bearer"
    expires_in: int


class ExportOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: str
    status: Literal["queued", "done", "failed"]
    items: list[BookOut] | None

Literal["bearer"] 表示这个字段采用指定文本值。Credentials 直接继承 BaseModel,因此没有沿用书名的去空白配置;密码中的空格参与密码核对。TokenOut.expires_in 是令牌从签发起的有效秒数,服务器还会保存实际到期时间。

# 续写同一文件:api_project/src/bookapi/models.py
class AccessToken(Base):
    __tablename__ = "access_tokens"
    # 数据库保存令牌摘要;真正的令牌只在登录响应里交回一次。
    digest: Mapped[str] = mapped_column(String(64), primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
    expires_at: Mapped[int]


class ExportJob(Base):
    __tablename__ = "export_jobs"
    id: Mapped[str] = mapped_column(String(32), primary_key=True)
    owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
    status: Mapped[str] = mapped_column(String(10), default="queued")
    items: Mapped[list[dict] | None] = mapped_column(JSON, nullable=True)

AccessToken 保存令牌摘要、用户编号和到期时间。ExportJob 留给后面的导出动作,记录归属、进度和完成结果。它们都通过外键关联用户。

expires_at: Mapped[int] 没有显式调用 mapped_column,SQLAlchemy 也会根据映射注解建立整数列;没有写 None,该列要求保存值。items 则允许 None,让尚未完成的导出先保存空结果。

2. 区分密码摘要与令牌摘要

用户密码可能很短,需要使用上一章的 generate_password_hash 和 check_password_hash。访问令牌由 secrets.token_urlsafe(32) 随机产生,服务器对它计算 SHA-256,用摘要作数据库查找键。登录响应交回原始令牌,数据库保存摘要;后续收到令牌,再算一次摘要查表。

# 项目文件:api_project/src/bookapi/security.py
import hashlib
import secrets
import time
from dataclasses import dataclass

from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.security import check_password_hash, generate_password_hash

from .errors import commit_changes
from .models import AccessToken, User
from .schemas import Credentials, TokenOut, UserOut


def token_digest(raw: str) -> str:
    # 随机令牌已有高熵,用 SHA-256 作查找摘要;用户密码仍用慢哈希。
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def unauthorized():
    return HTTPException(
        401, "请提供有效的登录令牌", headers={"WWW-Authenticate": "Bearer"}
    )


@dataclass
class Identity:
    user: User
    token: AccessToken


def register(session: Session, data: Credentials) -> UserOut:
    with commit_changes(session):
        user = User(
            username=data.username, password_hash=generate_password_hash(data.password)
        )
        session.add(user)
        session.flush()
        result = UserOut.model_validate(user)
    return result

hashlib.sha256(...).hexdigest() 得到十六进制摘要文本。用户密码仍使用带盐的慢哈希,不能用这个快速摘要函数替换。Identity 把本次用户记录和令牌记录放在一起,退出登录时可以删除正在使用的那一份令牌。

HTTPException 在应用处理途中中断当前操作,并交回状态与错误说明。401 的 WWW-Authenticate: Bearer 告诉客户端使用 Bearer 凭据。这里的 register 在 flush 后取得用户编号,先形成公开结果,再提交事务;没有把密码摘要加入公开模型。

# 续写同一文件:api_project/src/bookapi/security.py
# 原始令牌只交给客户端,数据库保存摘要;后续请求用摘要查找身份和过期时间。
def issue_token(session: Session, data: Credentials, seconds: int) -> TokenOut:
    user = session.scalar(select(User).where(User.username == data.username))
    if user is None or not check_password_hash(user.password_hash, data.password):
        raise unauthorized()
    raw = secrets.token_urlsafe(32)
    with commit_changes(session):
        session.add(
            AccessToken(
                digest=token_digest(raw),
                user_id=user.id,
                expires_at=int(time.time()) + seconds,
            )
        )
    return TokenOut(access_token=raw, expires_in=seconds)


def resolve_identity(session: Session, raw: str) -> Identity:
    token = session.get(AccessToken, token_digest(raw))
    if token is None or token.expires_at <= int(time.time()):
        raise unauthorized()
    user = session.get(User, token.user_id)
    if user is None:
        raise unauthorized()
    return Identity(user, token)

select(User).where(...) 构造查询,session.scalar(...) 取第一项标量结果:这里是 User 对象,没找到则为 None。session.get(AccessToken, digest) 按主键查令牌。校验顺序是找到摘要、核对到期时间、取得用户,再形成 Identity。

登录时核对密码生成随机令牌,只存摘要与到期时间;请求时从原令牌重新计算摘要查出用户,退出删除摘要记录后旧令牌得到 401

本章的随机令牌没有携带可解码的用户信息,也没有 JWT 的签名结构。身份来自服务器查表,所以删除记录就能使该令牌失效。它会直接授予调用权限,实际部署时通过 HTTPS 传输;终端演示把它放在变量里,不写进代码、Git 或日志。

笔记正文与全部题解 · Ctrl / ⌘ K