ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FastAPI登录验证实战:OAuth2密码模式+JWT签发与校验全解析

FastAPI登录验证实战:OAuth2密码模式+JWT签发与校验全解析 先讲个我亲眼见过的事情。有个朋友用FastAPI写后端接口登录验证完全是自己造的轮子用户登录成功后往数据库里塞一条uuid当token前端每次请求带过来后端就先查一次表比对成功才放行。结果接口一上量数据库连接先被打满更麻烦的是部署多节点之后在这台机器登录的token请求打到另一台机器就查不到直接变成未登录。他跑来问我怎么解决我反问他为什么不用OAuth2加JWT他有点犹豫地说感觉那是大厂才用的东西我们这种小项目用不上吧。其实这个想法很有代表性。很多人觉得OAuth2和JWT听着高大上实际自己写项目时就用最朴素的方式糊一个登录等流量起来或者服务拆分之后才被一堆隐藏问题追着跑。这篇文章就围绕FastAPI登录验证这件事展开从OAuth2密码模式的基本原理到JWT令牌的签发、校验、续签和实战排坑完整梳理一套可以直接落地的API安全防线方案。不管是刚接触FastAPI的新手还是已经写了几个接口但没系统搞过认证的开发者都可以照着这篇文章的思路把登录验证这块补齐而且能真正理解每一步在做什么、为什么要这样做。1. 为什么登录验证必须用OAuth2和JWT而不是自己手写Session表很多人第一次接触登录验证脑子里冒出来的方案就是搞个session表登录成功后生成一个随机字符串存到数据库或者Redis里以后再请求就带着这个字符串服务端查一下就知道是谁了。这种方案在小项目、单体应用里确实能跑但它有几个先天问题。首先是存储和查询的压力。每个请求都要做一次查token是否存在的操作接口并发一高这部分查询就成了瓶颈。其次是分布式环境下的会话共享问题。服务拆成多个节点之后用户请求可能被负载均衡打到任意一台机器上如果token状态只存在某一台机器的内存或某个数据库里就得额外引入Redis这样的集中式存储架构复杂度直接上升。第三是token本身不携带任何信息服务端必须记住它这本质上是一种有状态的设计。JWT恰恰解决了这三个问题。它是一个自包含的令牌用户是谁、权限是什么、什么时候过期全部写在令牌本身的Payload里边。服务端拿到JWT之后只需要用密钥验证一下签名确认这个令牌确实是自己签发的、没有被篡改、没有过期就可以直接信任里面的信息完全不需要查数据库。这就是无状态认证的核心含义。用一个生活化的类比session方案像你去健身房办卡每次进门工作人员都要去电脑里查一下你的开卡记录而JWT方案像你拿了一张盖了钢印的通行证门口保安只要检查钢印是真的、日期没过期就能直接放行。那OAuth2又是干什么的OAuth2本身是一个授权框架和登录这件事不完全划等号但它定义了一套标准的客户端换令牌流程。最常见的是授权码模式比如你用微信登录某个网站而FastAPI官方文档里推荐的场景是OAuth2的密码模式Resource Owner Password Credentials也就是客户端直接拿用户名密码向认证服务换一个access_token专门适用于前后端分离的API场景。FastAPI之所以把OAuth2和JWT放在一起讲是因为它内置的OAuth2PasswordBearer依赖项可以帮你自动完成从请求头里提取Bearer Token这件事你再把JWT的签发和验证逻辑挂上去一套标准的认证链路就闭合了。为了看清这套组合的定位我通常会把三种常用方案放在一起对比Session/Cookie状态在服务端需要集中存储适合传统Web应用不适合纯API和跨服务场景。JWT Bearer Token状态在令牌本身服务端无状态校验适合前后端分离、微服务、API开放平台。API Key一个固定字符串代表一个调用方适合机器对机器、第三方开放接口不适合承载用户身份和权限细节。回到开头那个朋友的场景他用uuid当token存数据库实际上就是自己实现了一套残缺的Session方案但缺少了集中存储和过期策略所以一上量就出问题。换成OAuth2密码模式加JWT之后登录接口负责签发令牌受保护接口只负责验签数据库不再参与每一次请求的认证过程整个设计清爽很多。2. 环境准备与用户密码存储别在第一步就把安全搞砸方向定了接下来是动手前的准备。FastAPI登录验证涉及到的核心依赖有这么几个FastAPI和Uvicorn是基础运行环境然后需要python-jose来做JWT的编码解码需要passlib来做密码哈希还需要python-multipart来解析OAuth2密码模式里的表单数据。如果你用数据库存用户信息那再加上SQLAlchemy和对应数据库驱动。我见过不少教程推荐用PyJWT但FastAPI官方文档用的是python-jose两者都能做JWT不过python-jose支持的算法更全和FastAPI的配合也最顺。安装方式很简单pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt] python-multipart这里有个细节值得注意python-jose的依赖项里带上[cryptography]是为了支持RS256这类非对称签名算法。如果只用HS256理论上不装也能跑但带着它不会有副作用我建议一律装上免得以后扩展算法时踩坑。另一件必须在动手前做好的事情是准备一个足够安全的SECRET_KEY。这个密钥用于给JWT签名一旦泄露任何人都可以伪造登录令牌。网上很多教程直接在代码里写SECRET_KEY your-secret-key如果你只是本地学习无所谓但如果部署到生产环境密钥必须放到环境变量或者密钥管理服务里绝不能硬编码进代码仓库。生成方式用系统自带工具就行openssl rand -hex 32这条命令会生成一个64位的十六进制字符串随机性足够。然后在代码里读取环境变量import os SECRET_KEY os.getenv(SECRET_KEY, dev-secret-key-change-me) ALGORITHM HS256 ACCESS_TOKEN_EXPIRE_MINUTES 30再来说说密码存储。这是整个登录验证里最容易被轻视的一环。直接存明文密码属于底线问题不用讨论稍微有点概念的人会用sha256加密但这同样不够。普通哈希算法的问题在于速度太快攻击者拿到数据库之后可以用彩虹表快速反查也可以每秒跑上亿次暴力破解。正确的做法是用专门为密码设计的慢哈希算法比如bcrypt它内部会自动加盐并且可以通过调节cost参数控制计算耗时。加盐的意思是每个用户同一密码生成的哈希都不一样彩虹表直接失效变慢则让暴力破解的成本高到不划算。在FastAPI项目里我一般这样封装密码工具from passlib.context import CryptContext pwd_context CryptContext(schemes[bcrypt], deprecatedauto) 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)这段代码放到security.py或者core/security.py里后面注册用户和登录校验都会用到。deprecatedauto的意思是如果以后切换到更安全的哈希算法passlib会在验证旧哈希时自动识别并给出警告提示方便平滑迁移。实际开发中还有一个高频坑passlib的版本和bcrypt的版本兼容问题。如果你pip安装之后发现pwd_context.hash()报错提示AttributeError: module bcrypt has no attribute __about__通常是因为passlib 1.7.4和bcrypt 4.1以上的版本不兼容。解决办法是把bcrypt降到3.2.x或者直接改用bcrypt库自己的接口。我在项目里为了避免这类麻烦新项目已经倾向于直接用bcrypt库了但为了贴合FastAPI官方文档的写法这篇文章仍然采用passlib加bcrypt的组合遇到报错按我刚才说的处理即可。用户表的设计也值得说两句。最简的User模型至少要有id、username、hashed_password、is_active这几个字段。注意表里存的是hashed_password而不是password整个项目里任何地方都不应该出现明文密码的存储。注册接口拿到用户输入的密码后第一时间调用hash_password()再落入数据库。登录校验的时候从数据库取出该用户的hashed_password用verify_password()和用户提交的明文密码比对返回布尔值。数据库会话的管理FastAPI社区的标准做法是用Depends(get_db)依赖注入。这里不展开讲但你得保证后面的authenticate_user函数能够拿到数据库会话否则登录校验无从谈起。3. OAuth2PasswordBearerFastAPI帮你自动提取令牌的那根接缝FastAPI里所有认证相关的魔法都建立在依赖注入系统之上。所谓依赖注入简单说就是你在路由函数里声明一个参数FastAPI会在调用路由之前自动去帮你把参数准备好。OAuth2PasswordBearer就是一个现成的依赖项它的职责只有一个从请求的Authorization请求头里提取Bearer令牌字符串。创建它的方式非常简单from fastapi.security import OAuth2PasswordBearer oauth2_scheme OAuth2PasswordBearer(tokenUrltoken)这里的tokenUrltoken指的是客户端将来要拿着用户名密码去换取令牌的接口地址。如果你的登录接口路径是/auth/login那这里就要写成tokenUrl/auth/login。这个参数有两个作用一是让FastAPI在生成OpenAPI文档时知道去哪申请令牌Swagger文档右上角会多出一个Authorize按钮点进去输入用户名密码就能直接测试受保护接口二是让OAuth2的客户端实现知道该往哪发请求。那oauth2_scheme到底做了什么它首先检查请求头里有没有Authorization字段如果字段值是Bearer xxxxx这种形式它就把xxxxx提取出来作为依赖项的值传给路由函数。如果请求头缺失或者格式不对它直接抛出一个401异常。但请注意它只负责提取不负责验证。也就是说哪怕你随便写一串乱码放进Authorization头oauth2_scheme也能把它提取出来至于这串token是不是真的、有没有过期那是后面JWT校验环节的事。这种提取和验证分离的设计恰好是FastAPI依赖注入优雅的地方。你可以把认证拆成几个层次第一层是提取Bearer Token第二层是验证JWT签名第三层是从token里解析出用户信息。每层都是一个独立的依赖项按需组合。比如有的接口可能只需要确实是咱家签发的token就行而有的接口需要必须是某个具体用户还有的接口需要必须是管理员。这种粒度控制手写中间件反而很难做到。我再强调一下密码模式和另一种常见模式的区别。OAuth2规范里除了密码模式还有授权码模式后者是浏览器网页登录时用的比如使用GitHub账号登录需要跳转到授权页面、用户手动点击确认。而API场景下前端是我们自己的应用用户直接在登录页输入用户名密码不需要跳转所以用密码模式最直接。FastAPI官方文档把这两者区分得很清楚密码模式只适用于客户端和资源服务是同一方的可信场景如果你做的是开放平台、允许第三方应用接入那就得研究授权码模式了不能生搬硬套。开发的时候建议把OAuth2PasswordBearer实例放在一个公共模块里因为很多文件都要引用它。比如放在core/security.py或者单独建一个dependencies.py。这样后续在多个路由里Depends(oauth2_scheme)的时候只需要import一次不会出现重复创建导致tokenUrl不一致的混乱局面。4. 登录接口与JWT签发把用户名密码变成一张一次性通行证有了提取令牌的机制接下来就要实现真正的登录接口。在这一步OAuth2密码模式带来了一个看似不起眼、但很关键的细节客户端提交用户名和密码时用的不是JSON而是标准表单格式application/x-www-form-urlencoded。这是OAuth2规范的规定。FastAPI为此专门提供了OAuth2PasswordRequestForm这个依赖类你只需要在路由参数里声明它FastAPI就会自动从请求体里解析出username和password两个字段。登录接口的完整写法如下from datetime import timedelta from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel class Token(BaseModel): access_token: str token_type: str app.post(/token, response_modelToken) async def login_for_access_token( form_data: OAuth2PasswordRequestForm Depends() ): user authenticate_user(form_data.username, form_data.password) if not user: raise HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detail用户名或密码错误, headers{WWW-Authenticate: Bearer}, ) access_token create_access_token(data{sub: user.username}) return {access_token: access_token, token_type: bearer}这里有几个细节值得展开。authenticate_user是我封装的一个函数内部先按用户名查库查到用户后用verify_password比对密码两个条件都满足才返回用户对象。这个函数里最忌讳的是给攻击者提供用户名存在但密码不对这种信息差所以无论用户不存在还是密码错误都统一返回同样的提示也就是代码里的用户名或密码错误。这种模糊化处理在安全实践中属于基本素养。headers{WWW-Authenticate: Bearer}这行很容易被忽略但它是OAuth2规范的一部分作用是告诉客户端这个接口需要Bearer认证请带上Authorization头。很多API调试工具依赖这个响应头来提示开发者加上它401响应才更加标准。然后是create_access_token函数。JWT的生成逻辑集中在函数里方便复用和测试。一个典型的实现长这样from datetime import datetime, timedelta, timezone from jose import jwt def create_access_token(data: dict, expires_delta: timedelta | None None): to_encode data.copy() if expires_delta: expire datetime.now(timezone.utc) expires_delta else: expire datetime.now(timezone.utc) timedelta(minutesACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({exp: expire}) encoded_jwt jwt.encode(to_encode, SECRET_KEY, algorithmALGORITHM) return encoded_jwtJWT本身由三部分组成用点号分隔Header算法信息、Payload声明数据、Signature签名。HS256签名算法的工作原理是用同一个密钥对Header Payload这两段内容做HMAC运算生成签名。服务端拿到token后用同样的密钥重新计算一遍签名如果和token上的签名一致说明内容没有被篡改。这种对称签名的好处是计算快、实现简单坏处是签发和验证必须在同一个信任域内秘密就一把钥匙。如果你有多个服务都需要验证token且不是所有服务都适合持有这把密钥那就该考虑RS256非对称签名了公钥给别人验证、私钥留在认证中心签发。这是后话但理解了这个区别你以后架构演进时不会走错方向。JWT的Payload里可以放很多信息但有两个标准字段必须注意。一个是subSubject用来标识用户主体我习惯放用户名另一个是expExpiration Time令牌过期时间。FastAPI官方示例中把过期时间设置成30分钟这个默认值背后是有考量的如果过期时间太长token泄露后的风险窗口太大如果太短用户频繁重新登录体验又不好。我一般建议15到30分钟但这只是access_token的过期策略关于续签我们后面专门讲。还有一个非常常见的误区有些人以为JWT是加密的所以敢把密码、身份证号等敏感信息放进Payload。实际上JWT的Payload只是Base64Url编码任何人拿到token都能轻松解码看到里面内容只不过他们改不了因为没有签名密钥。所以Payload里只能放非敏感信息比如用户ID、用户名、角色代码所有的敏感数据都应当通过受保护接口去数据库查询。生成Token之后前端怎么存储又是一个绕不开的问题。最安全的做法是把token放在内存变量里页面刷新就没了但用户体验差很多人选择放localStorage简单方便但XSS攻击一打到就能偷走。折中方案是把token放在HttpOnly的Cookie里由后端设置配合CSRF防护。这个话题展开又是一篇文章但核心原则是不要把token放在URL参数里也不要让token轻易被JavaScript读取到。这一点设计的时候就要想好后面改起来伤筋动骨。5. get_current_user全程链路从一坨字符串到当前用户对象登录接口签发完token受保护接口就得会验证token。验证逻辑不能在每个路由里重复写FastAPI的解法依然是依赖注入——封装一个get_current_user依赖函数谁要保护就声明一个参数。这是整套认证体系里最核心的编排环节。一个完整的get_current_user长这样from fastapi import Depends, HTTPException, status from jose import JWTError, jwt def get_current_user(token: str Depends(oauth2_scheme), db: Session Depends(get_db)): credentials_exception HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detail无法验证凭据, headers{WWW-Authenticate: Bearer}, ) try: payload jwt.decode(token, SECRET_KEY, algorithms[ALGORITHM]) username payload.get(sub) if username is None: raise credentials_exception except JWTError: raise credentials_exception user get_user_by_username(db, username) if user is None: raise credentials_exception return user这个函数的执行链路我拆开解释一下。第一步token: str Depends(oauth2_scheme)这一行是整条链的入口。FastAPI先调用oauth2_scheme从请求头提取Bearer Token的字符串然后把它传给get_current_user。如果请求头里压根没有Authorization字段到这里就直接返回401了后续代码根本不会执行。第二步jwt.decode()用同样的密钥和算法去验证token。这一步做了几件事检查签名是否有效检查exp是否过期最后把Payload解出来。注意algorithms[ALGORITHM]不能省略否则jose库可能根据token头里的信息选择算法产生安全风险。第三步从Payload里取出sub字段再根据这个用户名去数据库查询用户记录。这里有个看起来很矛盾的环节我们前面刚说了JWT是无状态的、不需要查库为什么拿到token之后又要查一次数据库原因在于JWT只能证明这个令牌确实是咱家签发的、内容没被改过但token签发之后用户可能被删除了、被禁用了、角色变了这些状态变化JWT是感知不到的。所以对于需要实时用户状态的接口查一次库是必要的。如果完全不查库相当于token一旦签发就成了铁券哪怕用户被管理员踢出去他手里的token也一直有效这是很多项目出安全事故的根源之一。第四步用户不存在或者已经被删除跟token无效一样统一抛401。不要把token过期和用户不存在区分成两种错误让攻击者通过这些细节去探测系统内部状态。有了get_current_user受保护路由写起来非常舒服。比如获取当前用户信息接口app.get(/users/me) async def read_users_me(current_user: User Depends(get_current_user)): return current_user就一行依赖注入FastAPI会先执行get_current_user把返回的User对象传给current_user参数路由函数内部可以直接使用。如果认证失败请求根本到不了路由函数内部直接被401拦截。这种链路的清晰度是手写装饰器或者中间件很难达到的。你还可以在这个基础上做权限控制。比如加一个get_current_active_user里面先调用get_current_user再检查user.is_active是否为TrueFalse就抛403。这样谁能访问和谁已登录就能分层控制。FastAPI的依赖注入还支持嵌套依赖只要一个依赖函数里声明了另一个依赖链路就会自动展开。在线生成OpenAPI文档里每个受保护接口都会显示一个带锁的标记点进去就能用登录时拿到的token去测试整个开发体验相当顺滑。依赖注入里还有一个容易被忽略的机制FastAPI默认会缓存依赖的计算结果。同一个请求里如果多个路由或者嵌套依赖都用到了get_current_user它只会执行一次后面的直接复用结果。这个设计对性能很友好但如果你的认证逻辑里包含随机数检查之类快速变化的状态就要小心缓存的干扰。不过对于JWT验证这类幂等操作完全不用担心。6. 实战中的高频坑位排查以及令牌续签与登出的进阶方案文章写到这个份上光讲怎么做还不够我把自己在实际项目里踩过的坑和排查思路一并分享出来顺序是先排查401问题再解决时区坑然后设计续签方案最后说登出问题。首先是401排查链路。我见过太多人报unexpected status 401 unauthorized但真正的原因五花八门。接到这类问题我总是按下面这个顺序排查检查请求头格式。必须是Authorization: Bearer xxxxx注意Bearer和token之间有一个空格Bearer首字母大写缺一不可。前端代码里写错一个字母就全盘401。检查token是否真的拿到了。如果登录接口返回的是{access_token: ...}前端有没有正确地把这个字段存起来并塞进下一次请求的Header里很多前端小白的错误在这里。确认解密算法一致。签发token时用HS256验证时也要指定HS256不要token头里写着什么就跟什么走。检查密钥是否一致。本地开发用了一个SECRET_KEY部署到服务器换了一个旧token自然全部失效这是最让人抓狂的一种情况。检查时间是否正确。JWT的exp机制依赖服务器时间如果服务器时钟偏差太大token会莫名其妙提前过期。生产环境务必配置NTP时间同步。其次是时区坑。JWT的过期时间必须用UTC千万别用本地时间。网上很多老教程写datetime.utcnow()但Python 3.12之后这玩意deprecated了更规范的写法是我的代码示例里的datetime.now(timezone.utc)。这里的核心不是用哪个函数而是保证签发和验证都在同一个时间基准上。如果签发用本地时间、验证用UTC时间token的有效期就会产生偏移用户一脸懵地看着刚登录就过期。第三是密钥轮换。SECRET_KEY不是终身不变的如果怀疑泄露或者团队人员变动就要轮换密钥。最简单是双密钥方案验证时新旧密钥都试一遍签发时只用新密钥。这样旧token在有效期内还能验证通过新token立即生效用户无感迁移。等到旧密钥签发的一批token全部过期再彻底移除旧密钥。接下来是令牌续签。JWT的exp一旦过期就不可用了但让用户每隔30分钟重新输一次密码体验很糟糕。业界常用的方案有两种滑动过期和刷新令牌。滑动过期的思路是每次用户发起请求并成功验证之后如果token剩余有效期不足某个阈值就顺便签发一个新token返回给前端。前端收到新token就替换旧的用户只要在持续使用token就永远不会断。优点是不用引入额外接口缺点是无状态服务自己没法主动续签所以实际实现时需要额外存储或者让客户端参与。刷新令牌是我更推荐的方式登录时签发两个token一个是短期有效的access_token比如30分钟用来访问API一个长期有效的refresh_token比如7天只能用来调刷新接口换新的access_token。access_token过期之后前端拿着refresh_token去/token/refresh接口换新token全程用户无感知。refresh_token需要有撤销能力服务端通常要存储它的状态所以它是有状态的。设计上refresh_token通过sub加上一个随机jti标识存储在数据库或Redis里一旦登出或者涉嫌复用立刻把它标记失效。为什么代码里我签access_token时只放{sub: user.username}如果要做refresh_token方案还要再加上{jti: uuid4().hex}并且带上token_type字段区分是access还是refresh。最后的难题是登出。JWT无状态导致服务端无法主动让一个已经签发的token失效常见的解法是把access_token的有效期缩短到分钟级同时保证refresh_token支持撤销。如果你想在access_token层面做到立即失效那就需要引入黑名单机制把被登出的token的jti存起来验证时先查黑名单。这会让认证重新变回有状态所以绝大多数轻量项目采用短access_token 可撤销refresh_token的组合在安全性、体验、复杂度之间取一个平衡。我在实际项目中的体会是不要一上来就追求完美的黑名单机制小项目先保证access_token短过期、refresh_token能撤销已经能覆盖绝大部分真实场景。权限控制方面FastAPI的OAuth2PasswordBearer还支持scopes参数可以在token里声明权限范围比如{sub: alice, scopes: [admin]}然后在具体路由里用Security依赖检查scope。这个机制比单纯判断是否登录更精细适合做多角色系统。不过如果你的权限模型只是简单的用户/管理员两级那直接在用户模型上加个is_admin字段配合get_current_user查库反而更简单直观不必为了用scope而用scope。实际开发中还有一个体验相关的小坑很多人在登录接口返回的token_type直接硬编码成bearer前端拼Authorization头时又自己写死Bearer 。大小写不敏感还好万一遇到严格校验的库就会出问题。建议前后端都统一按规范来响应里的token_type就用bearer前端拼头的时候用响应字段而不是硬编码。说到底登录验证不是一个可以拿螺丝刀随便拧的环节它决定了你整个API的门卫系统靠不靠谱。把OAuth2的流程规范、JWT的签发验证、密码哈希的存储原则这套东西弄明白之后不管接口规模怎么变、服务怎么拆分认证这块的骨架都是稳的。最后再分享一个我个人的操作习惯项目里所有HTTPException的状态码和错误信息我会统一收拾到一个常量模块里比如AUTH_REQUIRED (401, 身份验证已失效请重新登录)路由里引用常量而不是散落各处排错的时候能少翻很多代码。希望你照着这篇文章搭完认证体系之后也能顺手把这类基础设施级别的整洁度做起来。
返回列表