第17课:FastAPI|JWT令牌认证|登录授权与接口路由权限控制实战

在这里插入图片描述


1. 课前导读

本节课学习目标

在前16课中,我们已经构建了一个功能完整的API系统,包含数据库CRUD、分页筛选、异步并发优化等。但一个真实的应用必须解决安全问题——如何识别用户身份,并控制不同用户对不同资源的访问权限? 如果任何人都能随意创建、修改、删除数据,系统将毫无安全性可言。

本节课将带你实现基于JWT(JSON Web Token) 的用户认证与授权机制。学完本节课,你将:

  • 理解JWT原理:了解JWT的结构、签名算法、以及为什么适合分布式系统
  • 实现登录接口:验证用户名密码,生成并返回JWT access token
  • 保护API接口:使用依赖注入验证JWT,获取当前登录用户
  • 实现角色权限控制:区分普通用户和管理员,限制接口访问
  • 实现Token刷新机制:使用refresh token延长登录状态
  • 掌握安全最佳实践:密码加密存储、Token过期时间、HTTP-only Cookie传输(可选)

前置知识

  • 已完成第15课,掌握数据库用户模型和密码加密(passlib)
  • 了解依赖注入(第10课)和异步编程基础(第16课)
  • 对HTTP认证头(Authorization: Bearer )有基本概念

学完能掌握什么

学完本节课后,你将具备以下能力:

  1. 设计安全的登录/注册流程:密码加密、JWT生成与验证
  2. 使用FastAPI的HTTPBearer安全方案:自动处理Authorization头,并生成Swagger文档的“Authorize”按钮
  3. 编写可复用的认证依赖get_current_user依赖自动从请求中提取并验证token,返回用户对象
  4. 实现基于角色的权限控制:通过Depends组合,限制特定接口仅管理员可访问
  5. 部署Token刷新机制:使用refresh token实现无感刷新,提升用户体验

适用人群

  • 需要为API添加用户登录和权限控制的开发者
  • 准备构建多用户系统(如电商、社交、管理后台)的工程师
  • 对JWT认证原理和最佳实践感兴趣的后端开发
  • 希望学习FastAPI中Security模块用法的进阶学员

2. 核心理论讲解

2.1 为什么需要认证与授权?

  • 认证(Authentication):确认用户身份。例如,登录时验证用户名和密码,返回令牌。
  • 授权(Authorization):确定用户能做什么。例如,普通用户可以查看自己的订单,管理员可以查看所有订单。

在Web API中,最常见的认证方式是基于Token的无状态认证。服务器不保存用户登录状态,而是签发一个加密的Token给客户端,客户端在后续请求中携带Token,服务器验证Token的合法性。

2.2 JWT(JSON Web Token)详解

JWT结构xxxxx.yyyyy.zzzzz,由三部分组成,用.分隔:

  1. Header(头部):包含令牌类型(JWT)和签名算法(如HS256)。
  2. Payload(负载):包含声明(claims),例如用户ID、用户名、过期时间等。
  3. Signature(签名):对Header和Payload使用密钥签名,防止篡改。

为什么选择JWT?

  • 自包含:用户信息存储在Token中,无需查询数据库即可获取用户标识。
  • 分布式友好:服务器不存储session,便于横向扩展。
  • 跨语言:任何语言都有JWT库。

注意:Payload是Base64Url编码的,不要存储敏感信息(如密码),因为客户端可以解码查看。

2.3 JWT在FastAPI中的工作流程

  1. 用户通过POST /login提交用户名和密码。
  2. 服务器验证凭据,生成JWT(包含用户ID、角色、过期时间),返回给客户端。
  3. 客户端将JWT存储在localStorage或Cookie中,后续请求在Authorization: Bearer <token>头中携带。
  4. FastAPI使用HTTPBearer安全方案提取token,通过依赖函数验证签名和过期时间,解析出用户信息。
  5. 路由函数通过Depends(get_current_user)获取当前用户对象,进行权限判断。

2.4 密码加密:为什么不存明文?

数据库泄露是常见安全事件。如果密码明文存储,攻击者可获取所有用户密码。应使用单向哈希加盐存储。本课使用passlib库,它封装了bcrypt算法,自动加盐。

2.5 Access Token与Refresh Token

  • Access Token:短期有效(如15分钟),用于访问受保护资源。
  • Refresh Token:长期有效(如7天),用于在Access Token过期后获取新的Access Token。

为什么要区分?Access Token暴露在每次请求中,有效期短可降低泄露风险;Refresh Token只在刷新端点传输,更安全。

2.6 与Flask/Django的认证对比

框架常用认证方案JWT集成权限控制文档支持
FastAPIHTTPBearer + 自定义依赖python-jose依赖注入组合Swagger自动集成
FlaskFlask-JWT-Extended有扩展装饰器手动
Djangodjango-rest-framework-simplejwt有库权限类DRF文档

FastAPI的安全方案与OpenAPI规范无缝集成,自动生成文档中的“Authorize”按钮。


3. 环境搭建 & 实操准备

3.1 复用项目环境

继续使用第16课的项目。确保虚拟环境已激活。

cd ~/fastapi_course/lesson03_first_project
source venv/bin/activate

3.2 安装JWT和密码加密依赖

pip install python-jose[cryptography]  # JWT生成与验证
pip install passlib[bcrypt]             # 密码哈希(已在之前安装,确保)
pip install python-multipart            # 用于OAuth2密码流程(依赖)

3.3 更新数据库模型(如果需要)

我们已经在用户模型中有hashed_passwordusernamerole等字段,无需修改。但需要确保用户表中有测试数据。可以使用之前注册接口创建。

3.4 新增代码文件

创建app/api/v1/auth.py,并在main.py中注册。

touch app/api/v1/auth.py

同时,创建app/core/security.py存放安全相关工具函数。

mkdir -p app/core
touch app/core/__init__.py
touch app/core/security.py

main.py中注册:

from app.api.v1 import auth
app.include_router(auth.router, prefix="/api/v1/auth", tags=["认证授权"])

3.5 环境变量配置

.env中添加JWT相关配置:

SECRET_KEY=your-secret-key-please-change-in-production-at-least-32-chars
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7

注意:生产环境SECRET_KEY必须随机且足够长,不能泄露。可以使用openssl rand -hex 32生成。

更新app/config.py读取这些变量:

class Settings(BaseSettings):
    # ... 原有配置 ...
    secret_key: str = "your-secret-key-change-me"
    algorithm: str = "HS256"
    access_token_expire_minutes: int = 30
    refresh_token_expire_days: int = 7

4. 手把手代码实战

4.1 实现密码加密和验证工具

app/core/security.py中:

# app/core/security.py
from passlib.context import CryptContext
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from app.config import settings

# 密码加密上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
    """对密码进行哈希"""
    return pwd_context.hash(password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """验证密码是否匹配"""
    return pwd_context.verify(plain_password, hashed_password)

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
    """生成Access Token"""
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
    return encoded_jwt

def create_refresh_token(data: dict) -> str:
    """生成Refresh Token(有效期更长)"""
    expire = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days)
    to_encode = data.copy()
    to_encode.update({"exp": expire, "type": "refresh"})
    return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)

def decode_token(token: str) -> Optional[dict]:
    """解码并验证JWT,返回payload或None(如果无效)"""
    try:
        payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
        return payload
    except JWTError:
        return None

4.2 实现登录接口(生成Token)

app/api/v1/auth.py中创建登录路由:

# app/api/v1/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional

from app.database import get_db
from app.services.user_service import user_service
from app.core.security import verify_password, create_access_token, create_refresh_token, decode_token
from app.config import settings

router = APIRouter()

# OAuth2密码流方案,用于Swagger文档中的授权按钮
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")

# 响应模型
class Token(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str = "bearer"

class TokenData(BaseModel):
    username: Optional[str] = None
    user_id: Optional[int] = None

@router.post("/login", response_model=Token)
async def login(
    form_data: OAuth2PasswordRequestForm = Depends(),
    db: AsyncSession = Depends(get_db)
):
    """
    OAuth2兼容的登录接口,支持表单格式
    - username: 用户名
    - password: 密码
    """
    # 查找用户
    user = await user_service.get_by_username(db, form_data.username)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或密码错误",
            headers={"WWW-Authenticate": "Bearer"},
        )
    
    # 验证密码
    if not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或密码错误",
            headers={"WWW-Authenticate": "Bearer"},
        )
    
    # 生成Token
    access_token = create_access_token(
        data={"sub": user.username, "user_id": user.id, "role": user.role}
    )
    refresh_token = create_refresh_token(
        data={"sub": user.username, "user_id": user.id}
    )
    
    return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}

说明

  • OAuth2PasswordRequestForm是FastAPI提供的依赖,自动解析application/x-www-form-urlencoded格式的表单(usernamepassword字段)。
  • 该依赖也会让Swagger UI自动生成“Authorize”按钮,方便测试。

4.3 实现获取当前用户的依赖

为了在受保护的路由中获取当前用户,我们需要编写依赖函数,从请求头提取Token,验证并返回用户对象。

app/api/v1/auth.py中继续添加:

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from jose import JWTError

# 重写oauth2_scheme(或使用全局定义)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")

async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: AsyncSession = Depends(get_db)
):
    """
    从请求头中提取JWT,验证并返回当前用户对象
    如果无效,抛出401异常
    """
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="无效的认证凭据",
        headers={"WWW-Authenticate": "Bearer"},
    )
    
    payload = decode_token(token)
    if payload is None:
        raise credentials_exception
    
    user_id: int = payload.get("user_id")
    if user_id is None:
        raise credentials_exception
    
    # 可选:从数据库获取最新用户信息(确保用户仍存在且激活)
    user = await user_service.get(db, id=user_id)
    if user is None or not user.is_active:
        raise credentials_exception
    
    return user

async def get_current_active_user(current_user = Depends(get_current_user)):
    """验证用户是否激活(额外的安全检查)"""
    if not current_user.is_active:
        raise HTTPException(status_code=400, detail="用户已被禁用")
    return current_user

async def get_current_admin_user(current_user = Depends(get_current_active_user)):
    """仅管理员可访问的依赖"""
    if current_user.role != "admin":
        raise HTTPException(status_code=403, detail="权限不足,需要管理员角色")
    return current_user

4.4 使用依赖保护接口

创建一个受保护的路由,只需要登录即可访问:

@router.get("/me", response_model=UserResponse)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
    """获取当前登录用户信息,需要认证"""
    return current_user

创建一个仅管理员可访问的路由:

@router.get("/admin-only")
async def admin_endpoint(current_admin: User = Depends(get_current_admin_user)):
    """仅管理员可访问的示例接口"""
    return {"message": f"欢迎管理员 {current_admin.username}"}

4.5 实现Token刷新接口

当Access Token过期时,客户端可以使用Refresh Token换取新的Access Token。

class RefreshTokenRequest(BaseModel):
    refresh_token: str

@router.post("/refresh", response_model=Token)
async def refresh_access_token(refresh_req: RefreshTokenRequest):
    """使用Refresh Token获取新的Access Token和Refresh Token"""
    payload = decode_token(refresh_req.refresh_token)
    if payload is None or payload.get("type") != "refresh":
        raise HTTPException(status_code=401, detail="无效的Refresh Token")
    
    user_id = payload.get("user_id")
    username = payload.get("sub")
    if not user_id or not username:
        raise HTTPException(status_code=401, detail="无效的Token内容")
    
    # 生成新token(可选:可以检查用户状态,但为简化省略)
    new_access_token = create_access_token(
        data={"sub": username, "user_id": user_id, "role": payload.get("role", "user")}
    )
    new_refresh_token = create_refresh_token(
        data={"sub": username, "user_id": user_id}
    )
    
    return {
        "access_token": new_access_token,
        "refresh_token": new_refresh_token,
        "token_type": "bearer"
    }

4.6 集成Swagger文档的认证按钮

由于我们使用了OAuth2PasswordBearer,FastAPI会自动在/docs页面添加“Authorize”按钮。但需要指定tokenUrl为我们的登录接口。已经通过tokenUrl="/api/v1/auth/login"实现。用户点击按钮,输入用户名密码,即可自动在后续请求中添加Authorization: Bearer <token>头。

4.7 实战:为用户资源接口加上权限控制

修改第15课的用户CRUD路由,添加认证和授权:

# 在 user_crud.py 中引入依赖
from app.api.v1.auth import get_current_active_user, get_current_admin_user

# 获取用户列表(仅管理员可访问)
@router.get("/", response_model=Page[UserResponse])
async def get_users(
    # ... 原有参数 ...
    current_admin: User = Depends(get_current_admin_user)  # 新增依赖
):
    # 原有逻辑
    pass

# 获取当前登录用户自己的信息(普通用户可访问)
@router.get("/me", response_model=UserResponse)
async def get_my_info(current_user: User = Depends(get_current_active_user)):
    return current_user

# 更新用户(普通用户只能更新自己,管理员可以更新任意用户)
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
    user_id: int,
    user_update: UserUpdate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_active_user)
):
    # 检查权限:只有管理员或者更新自己的信息
    if current_user.id != user_id and current_user.role != "admin":
        raise HTTPException(status_code=403, detail="无权修改其他用户信息")
    # ... 更新逻辑

4.8 注销登录(前端处理)

JWT是无状态的,服务器不需要主动注销。前端只需删除本地存储的Token即可。如果需要服务器端主动撤销Token(如用户修改密码后),需要维护Token黑名单(使用Redis),本课不展开。

4.9 安全增强:使用HTTP-only Cookie传输Token(可选)

将Access Token存储在localStorage存在XSS风险。更安全的方式是使用HTTP-only Cookie,无法被JavaScript读取,可防止XSS攻击。但Cookie易受CSRF攻击,需配合CSRF Token。因篇幅,本课不展开,可自行研究。

4.10 测试认证流程

启动应用:

uvicorn app.main:app --reload

步骤1:注册用户(如果还没有)

curl -X POST http://localhost:8000/api/v1/users/ \
  -H "Content-Type: application/json" \
  -d '{"username": "alice", "email": "alice@ex.com", "password": "123456", "age": 25}'

步骤2:登录获取Token

curl -X POST http://localhost:8000/api/v1/auth/login \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=alice&password=123456"

响应示例:

{
  "access_token": "eyJhbG...",
  "refresh_token": "eyJhbG...",
  "token_type": "bearer"
}

步骤3:访问受保护接口

curl -H "Authorization: Bearer eyJhbG..." http://localhost:8000/api/v1/auth/me

步骤4:访问仅管理员接口(如果alice不是管理员,会返回403)

4.11 在Swagger UI中测试

打开http://localhost:8000/docs,点击右上角“Authorize”按钮,输入用户名和密码,即可授权。然后尝试调用/auth/me接口,会看到成功响应。


5. 重点知识点总结

JWT核心语法速查表

操作函数/方法
哈希密码hash_password(plain)
验证密码verify_password(plain, hashed)
生成Access Tokencreate_access_token(data, expires_delta)
生成Refresh Tokencreate_refresh_token(data)
解码Tokenjwt.decode(token, secret, algorithms)
提取Bearer TokenOAuth2PasswordBearer(tokenUrl=...)
获取当前用户依赖get_current_user
权限控制依赖get_current_admin_user

安全最佳实践

  1. 密钥管理SECRET_KEY必须复杂且保密,使用环境变量注入,不要硬编码。
  2. Token有效期:Access Token建议15-30分钟,Refresh Token 7天,平衡安全与体验。
  3. 密码强度:要求至少8位,包含字母和数字。
  4. 使用HTTPS:生产环境必须启用HTTPS,防止Token被中间人窃取。
  5. Token黑名单:如果需要强制注销,维护Redis黑名单。
  6. Payload最小化:只存储必要信息(用户ID、角色),不存储敏感数据。

易错点与避坑指南

  1. ❌ 将密码明文存储在数据库中
    必须使用hash_password哈希后存储。

  2. ❌ 在JWT Payload中存储密码或其他敏感信息
    JWT的Payload可解码,任何人都能看到。只存非敏感标识。

  3. ❌ 未验证Token的签名和过期时间
    解码时必须捕获JWTError,并检查exp声明。

  4. ❌ 在依赖中直接返回用户对象,未检查用户是否仍存在于数据库
    用户可能被删除或禁用,最好每次都查询数据库确认状态。

  5. ❌ 在OAuth2PasswordBearer中错误配置tokenUrl
    导致Swagger授权无法正常获取Token。必须与登录接口路径一致。

  6. ❌ 将Refresh Token与Access Token使用相同过期时间
    失去了刷新机制的意义。Refresh Token有效期应远大于Access Token。

依赖注入组合模式

# 组合依赖,实现递进式权限
get_current_user → get_current_active_user → get_current_admin_user

每个依赖都可以单独用于不同敏感度的接口。


6. 课后作业 & 思考题

实操练习题(必做)

  1. 基础练习:完成登录、获取当前用户、刷新Token接口,并测试

    • 创建一个普通用户登录,获取Token。
    • 调用/auth/me接口验证能正确返回用户信息。
    • 等待Access Token过期(或修改配置为1分钟),使用Refresh Token获取新Token。
  2. 进阶练习:实现基于角色的接口权限控制

    • 在用户表中添加role字段(已有)
    • 创建接口GET /admin/users,仅管理员可访问,返回所有用户列表。
    • 创建接口DELETE /users/{user_id},仅管理员可删除。
    • 普通用户请求这些接口返回403。
  3. 挑战练习:实现Token黑名单(使用Redis)

    • 安装aioredis,在登出接口中将Token加入黑名单(设置过期时间等于Token剩余有效期)。
    • 修改get_current_user依赖,在验证Token后检查是否在黑名单中。

理论思考题

  1. JWT相比传统的Session认证有什么优缺点? 在分布式系统中为何更受欢迎?
  2. 为什么Access Token有效期要设置得短,而Refresh Token有效期长? 如果Refresh Token被盗,攻击者可以不断刷新,如何防护?
  3. OAuth2PasswordBearertokenUrl参数作用是什么? 它与在Swagger UI中显示“Authorize”按钮有什么关系?
  4. 如何实现“踢出用户”功能(强制指定用户下线)? 在不使用黑名单的情况下是否可能?

拓展学习方向

  • OAuth2.0与OpenID Connect:了解更完整的认证授权协议
  • 使用python-jose的更多算法:RS256非对称加密
  • 使用fastapi.security中的HTTPBearerAPIKeyHeader:其他安全方案
  • 单点登录(SSO):集成OAuth2社交登录(Google、GitHub)

7. 本节干货总结

核心考点(面试/自测)

  1. 如何实现JWT认证?
    用户登录验证后,生成JWT返回;客户端在请求头Authorization: Bearer <token>携带;服务端使用依赖提取并验证。

  2. FastAPI中OAuth2PasswordBearer的作用?
    从请求头提取Token,并自动生成Swagger文档的认证按钮。

  3. 如何保护一个路由,仅允许管理员访问?
    定义依赖get_current_admin_user,在其中检查用户角色,失败抛出403异常。

  4. Refresh Token的用途?
    在Access Token过期后,使用Refresh Token获取新的Access Token,避免用户频繁登录。

  5. 如何存储密码才安全?
    使用bcrypt等算法加盐哈希,永远不存储明文。

实际工作应用场景

  • 场景1:用户登录与身份保持
    移动App或前端应用,用户登录后获取Token,后续请求携带Token,后端识别用户。

  • 场景2:管理后台权限分级
    普通员工只能查看数据,管理员可以编辑和删除。通过角色依赖轻松实现。

  • 场景3:API开放平台
    为第三方应用颁发JWT,通过自定义声明控制其访问的API范围和有效期。

  • 场景4:微服务间认证
    服务A生成JWT,服务B验证JWT,实现无状态安全通信。

  • 场景5:单页应用(SPA)
    前端存储Token,每次请求自动添加到Authorization头,实现无刷新登录。


下节课预告

本节课我们构建了完整的用户认证与授权体系,API具备了身份识别和权限控制能力。但当一个系统用户量增长,频繁的数据库查询(如每次请求都查用户表)可能会成为性能瓶颈。此时,引入缓存可以大幅提升响应速度。

下节课(第18课),我们将学习:

  • 集成Redis缓存:安装配置、异步客户端使用
  • 接口限流:防止恶意刷接口,保护系统稳定性
  • 会话存储:使用Redis存储用户会话或临时数据
  • 性能优化实战:缓存数据库查询结果、使用缓存提升响应速度

让你的API在高并发下依然稳健!下节课见!


🔗《20节课 FastAPI 从入门到精通》系列课程导航

去订阅

🌟 感谢您耐心阅读到这里!
💡 如果本文对您有所启发欢迎:
👍 点赞📌 收藏 📤 分享给更多需要的伙伴。
🗣️ 期待在评论区看到您的想法, 共同进步。
🔔 关注我,持续获取更多干货内容~
🤗 我们下篇文章见~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Thomas.Sir

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值