那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
大家好。
上周我们组来了个实习生小张,Python 底子不错,FastAPI 也能跑通。Leader 让他负责新项目的用户认证模块。
需求很简单:用户登录 → 返回 Token → 根据角色控制权限。
小张拍着胸脯说:三天搞定。
三天后,他确实交了代码。但 Security Review 的时候,我们所有人倒吸一口凉气------
他安装的依赖里,躺着一个 2022 年就停止维护的 passlib。
小张的"三天搞定"代码
小张打开他的项目目录,一脸自豪地给我们演示:
python
# requirements.txt ------ 小张版
passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
python
# core/security.py ------ 小张版
from passlib.context import CryptContext
from jose import jwt
pwd_context = CryptContext(schemes=["bcrypt"])
def verify_password(plain, hashed):
return pwd_context.verify(plain, hashed)
def create_token(data):
return jwt.encode(data, "my-secret-key", algorithm="HS256")
python
# schemas/user.py ------ 小张版
class UserResponse(BaseModel):
id: int
username: str
hashed_password: str # 👈 直接返回密码哈希!
role: str
我问他:
"小张,你知不知道
passlib已经三年没人维护了?知不知道response_model里暴露了hashed_password?知不知道密钥硬编码在代码里意味着什么?"
小张愣了两秒,然后振振有词:
"我看网上的教程都这么写的啊!掘金上、CSDN 上、B 站上全是
passlib和python-jose!"
我沉默了。
不是小张的错。是中文互联网上 90% 的 FastAPI 认证教程,都在用 2021 年的技术栈。
屎山崩溃的那一天
上线第一天,一切正常。小张得意地发了条朋友圈:"用户认证系统,So Easy。"
第三天凌晨,安全团队紧急通知:生产日志里出现了用户的明文密码。
排查发现,小张在登录失败的时候加了行调试日志:
python
except Exception as e:
print(f"Login failed: {form_data.password}") # 👈 明文密码直接打印
raise
更致命的是,因为 passlib 已经停更,安全扫描工具直接报了 3 个高危 CVE 漏洞。
Leader 看着 Snyk 报告上的红色警告,问了一句:
"写这玩意儿的人,是觉得以后都不用维护了吗?"
过早乐观是万恶之源
小张不服气。他说网上教程都这么写,他能跑通就是对的。
但他忽略了一个基本事实:
教程的时效性 ≤ 技术栈的生命周期。
拿 passlib 举例:
- 2022 年,
passlib作者宣布不再维护 - 2023 年,社区发现多个安全漏洞无人修复
- 2024 年,主流项目全部迁移到
bcrypt直接调用 - 2025 年,你还在跟着 2021 年的教程用
passlib
这不是技术选择问题,这是信息滞后问题。
什么样的代码才是好代码?
后来,Leader 让我带着小张把整个认证模块推倒重写。
写完以后,小张看着新代码沉默了十分钟,说了一句:
"原来不是 FastAPI 难,是教程太老了。"
下面就是我们一起重写的完整过程------我尽量还原当时的对话和思路。
第一步:选对工具,别跟着过时教程走
| 旧教程推荐 | 实际状态 | 2025 年正确选择 |
|---|---|---|
passlib |
2022 年停更 | bcrypt 直接调用 |
python-jose |
2021 年停更,有 CVE | PyJWT |
pydantic.BaseSettings |
Pydantic v2 已废弃 | pydantic-settings |
SQLModel |
作者自己转向纯 SQLAlchemy | SQLAlchemy 2.0 + Pydantic v2 |
bash
# 正确的依赖清单
pip install fastapi uvicorn "sqlalchemy[asyncio]" asyncpg pydantic pydantic-settings bcrypt pyjwt
第二步:起手式------配置集中管理
"小张,你刚才把密钥硬编码在 security.py 里。想过没有,如果代码要开源或者上传到 GitHub,你的密钥就全世界都知道了。"
python
# core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# 密钥从环境变量或 .env 文件读取,绝对不硬编码
SECRET_KEY: str = "change-me-in-production"
ALGORITHM: str = "HS256"
# access_token 只有 15 分钟寿命------丢了也只疼 15 分钟
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
# refresh_token 有 7 天------只在换 token 的时候才用
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# 数据库连接字符串
DATABASE_URL: str = "postgresql+asyncpg://admin:admin@localhost:5432/auth_db"
model_config = {"env_file": ".env"}
settings = Settings()
小张问:"为什么要两个 Token?一个不够吗?"
我给他打了个比方:
"access_token 是你的门禁卡,每天掏几十次,丢了也不怕,15 分钟后自动作废。 refresh_token 是你的身份证,你只放在钱包最深处,只在换新门禁卡的时候才掏出来。 高频的东西设短效,低频的东西设长效------安全和体验的黄金平衡。"
第三步:密码安全------绝不存明文
python
# core/security.py
from datetime import datetime, timedelta, timezone
import bcrypt # 👈 直接调用,不经过 passlib 中间层
import jwt # 👈 PyJWT,社区活跃
from core.config import settings
def hash_password(password: str) -> str:
"""把明文密码变成哈希值。gensalt() 每次都随机,防彩虹表。"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""比较用户输入的密码和库里存的哈希。"""
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
def create_access_token(sub: str) -> str:
"""生成临时门禁卡,15 分钟有效。"""
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": sub, "exp": expire, "type": "access"}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_refresh_token(sub: str) -> str:
"""生成身份证,7 天有效。"""
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": sub, "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict:
"""解密 token,自动校验签名和过期时间。过期抛 ExpiredSignatureError。"""
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
小张瞪大了眼睛:"type 字段是干啥的?"
"防止有人拿 refresh_token 当 access_token 用去刷接口。每种 token 有明确的身份标记,乱用就炸。"
第四步:数据库------只存哈希,不存明文
小张的版本里数据库模型和 API 模型混在一起,一团浆糊。
我们把它拆成两层:models/ 管数据库长什么样,schemas/ 管 API 接收和返回什么。
python
# models/user.py ------ 数据库表结构
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(unique=True, index=True)
hashed_password: Mapped[str] # 👈 只存哈希
role: Mapped[str] = mapped_column(default="guest")
python
# schemas/user.py ------ 输入和输出严格分开
from pydantic import BaseModel, ConfigDict
class UserCreate(BaseModel):
"""注册时的输入------包含明文密码,因为还没哈希"""
username: str
password: str
role: str = "guest"
class UserRead(BaseModel):
"""返回给前端的------没有 hashed_password,彻底杜绝泄露"""
id: int
username: str
role: str
model_config = ConfigDict(from_attributes=True)
"小张,看到区别了吗?你的版本返回了 hashed_password,虽然它是哈希,但它也是敏感信息。万一哪天数据库被拖库,攻击者拿着哈希去撞库,你的用户密码就全暴露了。前端不需要知道密码的任何形态。"
第五步:依赖注入------FastAPI 最精妙的设计
小张之前的代码,每个接口里都写了重复的验证逻辑:
python
# 小张版 ------ 每个路由里都有一大片重复代码
@router.get("/something")
async def something():
token = request.headers.get("Authorization")
# 解析 token...
# 查数据库...
# 检查权限...
# 一大堆重复逻辑
pass
我们把这部分抽成了可复用的依赖:
python
# api/v1/dependencies.py
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.db import get_session
from core.security import decode_token
from models.user import User
security_scheme = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
session: AsyncSession = Depends(get_session),
) -> User:
"""从请求头提取 token → 解码 → 查库 → 返回用户对象。一步到位。"""
try:
payload = decode_token(credentials.credentials)
except jwt.ExpiredSignatureError:
raise HTTPException(401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(401, detail="Invalid token")
if payload.get("type") != "access":
raise HTTPException(401, detail="Invalid token type")
user_id = int(payload.get("sub"))
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(401, detail="User not found")
return user
class PermissionChecker:
"""
用法:Depends(PermissionChecker(["admin", "developer"]))
不是允许的角色,直接 403。
"""
def __init__(self, allowed_roles: list[str]):
self.allowed_roles = allowed_roles
async def __call__(self, user: User = Depends(get_current_user)) -> User:
if user.role not in self.allowed_roles:
raise HTTPException(403, detail="Permission denied")
return user
小张点头:"所以,Depends() 就像一条流水线,请求进来,先做 A,再做 B,再做 C,全部都通过了才执行我的业务代码?"
"完全正确。而且这条流水线可以任意组合、复用,每个路由只需要一行代码就能拿到当前用户。"
第六步:认证接口------干净利落
python
# api/v1/endpoints/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.v1.dependencies import PermissionChecker, get_current_user
from core.db import get_session
from core.security import (
create_access_token, create_refresh_token,
decode_token, hash_password, verify_password,
)
from models.user import User
from schemas.token import Token, TokenRefresh
from schemas.user import UserCreate, UserRead
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserRead, status_code=201)
async def register(user_in: UserCreate, session=Depends(get_session)):
"""注册 → 密码哈希 → 存入数据库 → 返回 UserRead(无 hashed_password)"""
exists = await session.execute(
select(User).where(User.username == user_in.username)
)
if exists.scalar_one_or_none():
raise HTTPException(409, detail="Username already exists")
user = User(
username=user_in.username,
hashed_password=hash_password(user_in.password),
role=user_in.role,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@router.post("/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), session=Depends(get_session)):
"""登录 → 验证密码 → 签发双 Token"""
result = await session.execute(
select(User).where(User.username == form_data.username)
)
user = result.scalar_one_or_none()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(401, detail="Incorrect username or password")
return Token(
access_token=create_access_token(sub=str(user.id)),
refresh_token=create_refresh_token(sub=str(user.id)),
)
@router.post("/refresh", response_model=Token)
async def refresh(body: TokenRefresh, session=Depends(get_session)):
"""refresh_token 换新的 access_token"""
try:
payload = decode_token(body.refresh_token)
except Exception:
raise HTTPException(401, detail="Invalid or expired refresh token")
if payload.get("type") != "refresh":
raise HTTPException(401, detail="Invalid token type")
user_id = int(payload.get("sub"))
result = await session.execute(select(User).where(User.id == user_id))
if not result.scalar_one_or_none():
raise HTTPException(401, detail="User not found")
return Token(
access_token=create_access_token(sub=str(user_id)),
refresh_token=body.refresh_token,
)
@router.get("/me", response_model=UserRead)
async def read_current_user(
user: User = Depends(PermissionChecker(["admin", "developer"]))
):
"""获取当前用户------仅 admin 和 developer 能访问"""
return user
第七步:组装起来
python
# api/v1/api_router.py
from fastapi import APIRouter
from api.v1.endpoints import auth
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router)
python
# main.py
from fastapi import FastAPI
from api.v1.api_router import api_router
from core.db import engine
from models.user import Base
app = FastAPI(title="企业用户认证系统", version="1.0.0")
@app.on_event("startup")
async def startup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
app.include_router(api_router)
第八步:测试一下
bash
# 启动
docker compose up -d
uvicorn main:app --reload
# 注册 + 登录
curl -X POST localhost:8000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"boss","password":"123456","role":"admin"}'
curl -X POST localhost:8000/api/v1/auth/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=boss&password=123456"
# 拿返回的 access_token 访问 /me
curl localhost:8000/api/v1/auth/me \
-H "Authorization: Bearer <粘贴 token>"
小张的反思
代码重写完那天,小张在工位上发了很久的呆。
我问他学到了什么,他说了三条,我记了下来:
1. 别盲信教程的时间戳。
你看的那篇"FastAPI 从入门到精通",很可能是 2021 年写的。三年在互联网世界,已经是沧海桑田。看教程之前,先看一眼依赖库的最后更新时间。
2. 代码是写给人看的,顺便给机器运行。
你写的每一行代码,未来都有人要维护。那个人可能是别人,也可能是三个月后半夜被叫起来改 Bug 的你自己。对未来的自己好一点。
3. 关注点分离不是高深的理论,是基本的生存技能。
配置归配置,模型归模型,校验归校验,接口归接口。看起来多写了几行代码,出 Bug 的时候一秒钟就能定位。混在一起,出问题只能从头读到尾。
小张现在已经转正了。他把自己踩的坑整理成了一份项目模板,组里后来所有的新项目都用这套。
我去看了他的 GitHub,最新一个 Issue 下面有个评论:
"感谢作者,看了你的代码终于理解了为什么要分离 UserCreate 和 UserRead,之前被 bepasslib 教程坑惨了。"
如果你正准备用 FastAPI 写认证系统------
- 别用
passlib,直接用bcrypt - 别用
python-jose,直接用PyJWT - 别把密钥硬编码在代码里
- 别把
hashed_password返回给前端 - 给你的 access_token 设短一点,给 refresh_token 设长一点
把这五条记住,你已经比 90% 的教程作者更清醒了。
完整的项目代码我放在 GitHub 了,拉到本地改改就能用。
谢谢大家 👋