FastAPI 教程

Chapter 05 · 登录与权限:守门人与通行证

📚 学完本章,你将能够:

  • 解释"密码不能直接存明文"的原因,并用 bcrypt 哈希密码
  • 理解 JWT 的工作原理:为什么服务端"无状态"也能识别用户
  • 实现完整的登录/注册流程:注册→哈希存密码→登录→发令牌→访问受保护接口
  • 用 RBAC 模型控制"谁能做什么"(普通用户 vs 管理员)
  • 在 Swagger UI 中测试带认证的接口

💡 场景引入

你的图书 API 现在任何人都能删除书籍。一个恶作剧用户写了个脚本,把你数据库里的书全删光了。

你需要两扇安全门:认证(Authentication)——确认"你是谁";授权(Authorization)——确认"你能做什么"。本章教你搭建这两扇门。

5.1 密码:永远不要存明文

假设你的数据库被黑客拖走了。如果密码是明文存储的,所有用户的密码直接暴露。更可怕的是,很多人在不同网站用相同密码——一个网站泄露,其他网站也危险了。

哈希(Hash)是密码存储的标准方案。它不是"加密"(因为加密可逆),而是"单向变换"——你可以把密码变成乱码,但无法从乱码反推出原密码。

密码哈希
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# 注册时:把明文密码变成哈希
hashed = pwd_context.hash("my_password")
# 结果类似:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYA.qGZvKG6

# 登录时:验证用户输入的密码是否匹配哈希
is_valid = pwd_context.verify("my_password", hashed)  # True
is_valid = pwd_context.verify("wrong_password", hashed)  # False
常见陷阱

md5 和 sha1 已经过时,现代计算机几秒就能破解。一定要用 bcrypt、argon2 或 scrypt。另外,永远不要自己发明哈希算法。

5.2 JWT:服务端的"无状态"通行证

传统方案:用户登录后,服务端在内存或 Redis 里存一份"session 表",每次请求都查表确认你是谁。这在单机上简单,但多服务器部署时就麻烦了——A 服务器不知道 B 服务器创建的 session。

JWT(JSON Web Token)的思路不同:服务器不存任何状态,所有信息都写在令牌里。令牌由服务器签名,无法伪造。

JWT 工作流程
1

用户登录

提交用户名 + 密码

2

服务端验证

密码哈希比对通过

3

生成 JWT

把用户 ID、角色、过期时间写入 JSON,用密钥签名

4

后续请求

客户端在 Header 中携带 Authorization: Bearer <token>

5

服务端验签

用密钥验证签名,解析出用户 ID,不需要查数据库

JWT 工具函数
from datetime import datetime, timedelta, timezone
from jose import jwt

SECRET_KEY = "change-this-in-production"  # 生产环境从环境变量读取
ALGORITHM = "HS256"

def create_access_token(user_id: str, expires_minutes: int = 30) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": user_id,                    # subject:主题,这里放用户 ID
        "exp": now + timedelta(minutes=expires_minutes),  # 过期时间
        "iat": now,                        # 签发时间
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def verify_token(token: str) -> dict | None:
    try:
        return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.JWTError:
        return None  # 令牌无效或过期
JWT 不是加密的

JWT 的内容只是 Base64 编码,任何人都能读取。不要在 JWT 中存放密码、身份证号等敏感信息。JWT 的价值在于签名不可伪造,而非内容保密。

5.3 完整登录流程

认证依赖
from fastapi.security import OAuth2PasswordBearer

# 声明 token 获取地址,FastAPI 会自动在 Swagger 中生成登录按钮
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")

async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: Annotated[AsyncSession, Depends(get_db)]
) -> User:
    payload = verify_token(token)
    if not payload:
        raise HTTPException(401, "Invalid token")
    user = await db.get(User, int(payload["sub"]))
    if not user:
        raise HTTPException(401, "User not found")
    return user
登录路由
from fastapi.security import OAuth2PasswordRequestForm

@app.post("/auth/token")
async def login(
    form: Annotated[OAuth2PasswordRequestForm, Depends()],
    db: Annotated[AsyncSession, Depends(get_db)]
):
    # OAuth2PasswordRequestForm 自动解析 username + password
    user = await get_user_by_username(db, form.username)
    if not user or not pwd_context.verify(form.password, user.hashed_password):
        raise HTTPException(401, "Incorrect username or password")

    token = create_access_token(str(user.id))
    return {"access_token": token, "token_type": "bearer"}
受保护的路由
@app.get("/me")
async def read_current_user(user: Annotated[User, Depends(get_current_user)]):
    return {"id": user.id, "username": user.username}

5.4 RBAC:角色权限控制

认证解决"你是谁",授权解决"你能做什么"。RBAC(Role-Based Access Control)是最简单的授权模型:给用户分配角色,角色拥有权限。

权限控制
async def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
    if user.role != "admin":
        raise HTTPException(403, "Admin access required")
    return user

@app.delete("/books/{book_id}")
async def delete_book(
    book_id: int,
    admin: Annotated[User, Depends(require_admin)],  # 只有管理员能删除
    db: Annotated[AsyncSession, Depends(get_db)]
):
    ...
403 vs 401

401 Unauthorized:"我不知道你是谁,请登录"。403 Forbidden:"我知道你是谁,但你没权限做这个操作。"很多初学者会混淆这两个状态码。

🎯 知识检测

问题 1
为什么密码要用 bcrypt 哈希存储,而不是 MD5?
因为 bcrypt 生成的字符串更短
因为 bcrypt 计算慢且带随机盐,难以暴力破解
因为 MD5 是加密,bcrypt 是压缩
因为 bcrypt 是 Python 内置的
解析:bcrypt 故意设计得计算慢(可调成本因子),且每次哈希都加入随机盐值,使彩虹表攻击失效。MD5/SHA1 计算极快,现代 GPU 每秒可试算数十亿次,极易暴力破解。
问题 2
JWT 令牌的签名作用是什么?
加密令牌内容,防止被读取
证明令牌是由持有密钥的服务器签发的,防止伪造
让令牌体积更小
存储用户的密码
解析:JWT 的 payload 只是 Base64 编码,没有加密。签名的作用是防篡改——如果客户端改了 payload,签名就对不上,服务器会拒绝。只有持有密钥的服务器才能生成合法签名。
问题 3
用户已登录但尝试删除他人资源,应该返回什么状态码?
401 Unauthorized
403 Forbidden
404 Not Found
500 Internal Server Error
解析:401 表示"未认证"(不知道你是谁)。403 表示"已认证但无权限"(知道你是谁,但你不准做)。404 是资源不存在,500 是服务器内部错误。

本章小结

✓

密码必须哈希存储

bcrypt 带随机盐,计算慢,有效抵抗暴力破解。

✓

JWT 是无状态通行证

服务器不存 session,所有信息在令牌里。适合分布式部署。

✓

OAuth2PasswordBearer 集成 Swagger

FastAPI 自动生成登录按钮,直接在文档界面测试受保护接口。

✓

RBAC 控制操作权限

通过依赖链组合认证和授权,401 和 403 分工明确。

🛠️ 动手实验

实现完整的用户系统:注册(密码 bcrypt 哈希存储)、登录(返回 JWT)、获取个人信息(需认证)、删除书籍(仅管理员)。
在 Swagger UI 中点击 Authorize 按钮测试认证流程。尝试用普通用户的 Token 访问管理员接口,验证是否返回 403。