那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志

那个用 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 站上全是 passlibpython-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 写认证系统------

  1. 别用 passlib,直接用 bcrypt
  2. 别用 python-jose,直接用 PyJWT
  3. 别把密钥硬编码在代码里
  4. 别把 hashed_password 返回给前端
  5. 给你的 access_token 设短一点,给 refresh_token 设长一点

把这五条记住,你已经比 90% 的教程作者更清醒了。

完整的项目代码我放在 GitHub 了,拉到本地改改就能用。

谢谢大家 👋

相关推荐
ping某2 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx
JustHappy3 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
uhakadotcom3 小时前
在python 的 工程化架构中 ,什么是 薄包装器层?
后端·面试·github
用户1474853079747 小时前
CodeX使用Skill生成游戏美术和音乐资源,一分钟入门
后端
Melody1237 小时前
用 abort 中断 AI 流式请求,我之前做错了
后端
onething3658 小时前
Spring Boot + Spring AI 从入门到实战:7天转型计划 Day 5 —— SSE 流式输出 + 打字机效果
人工智能·后端·全栈
一个做软件开发的牛马8 小时前
MyBatis-Plus 从零实战:完整搭建可运行 Demo,BaseMapper 零 SQL、Wrapper 条件构造、分页插件与代码生成器详解
java·后端
码事漫谈8 小时前
AI 编程的「三体」架构:OpenSpec + Superpowers + GStack 如何让一个开发者撑起整个研发团队
后端
吃饱了得干活8 小时前
深入解析 OpenFeign:从重试、拦截到负载均衡的全维度实践
后端