
你学会了“签名手环”session登录状态存在服务器。今天升级成“数字令牌”——JWT身份自含、验签即信、服务器不用记任何状态。给裸奔了五篇的早报站API穿上衣服。本篇产出注册 / 登录 / 刷新三个接口 受保护接口的完整实现。含代码约130行。 太长不看版给想快速上手的你项目信息一句话说明本篇目标给FastAPI接口加上JWT认证代码行数~130行含注释依赖pyjwtpython-multipart核心功能注册 登录 刷新 受保护接口跑起来的命令uvicorn app.main:app --reload→ /docs里Authorize核心知识点JWT三段结构、无状态验证、access/refresh双令牌做完你能得到一套生产级的API认证方案核心认知JWT的“无状态”是礼物也是代价——服务器不记状态 无法主动吊销。解法就是双令牌短命access 长命refresh。一、先复习session手环与JWT令牌的区别session是“签名手环”登录后服务器在库里/内存里记一笔客户端每次带session_id来服务器查表验证。JWTJSON Web Token是“数字令牌”登录后服务器把身份信息用户id、过期时间签名进一段字符串客户端每次带着它来服务器验签即可——不用查任何表。对比session手环JWT令牌身份信息存哪服务器要查表令牌自己带着不用查表验证方式对表验签名水平扩展会话要共享存储任何服务器都能独立验证登出/踢人删服务器记录即可做不到令牌还在客户端 “无状态”是JWT的礼物也是它的代价没有服务器记录 无法主动吊销。业界标准解法就是本篇的组合拳短命access token15分钟 长命refresh token7天——泄露的access很快过期刷新能力掌握在你手里。二、两分钟概念课JWT拆开看一段JWT长这样三部分用点分隔部分内容作用header{alg:HS256}签名算法声明payloadsub用户id、typeaccess/refresh、iat签发时间、exp过期时间身份声明signature用服务器密钥对前两部分签名任何篡改都会让验签失败 核心认知payload是公开可读的只是base64编码不是加密——所以令牌里绝不能放密码等敏感信息只能放“身份标识时间”。安全靠的是签名不是隐藏内容。三、第0步依赖与配置pipinstallpyjwt python-multipart包作用pyjwt签发与验签不用自己实现HMACpython-multipart登录接口要兼容Swagger的Authorize表单FastAPI的OAuth2PasswordRequestForm需要它Settings加两个配置项app/config.pyclassSettings(BaseSettings):...jwt_secret:strdev-only-change-me# 生产环境务必用环境变量覆盖jwt_expire_minutes:int15# access token短命refresh_expire_minutes:int60*24*7# refresh token 7天User模型加密码列alembic装的git今天第一次兑现classUser(Base):...password_hash:Mapped[str]mapped_column(server_default)alembic revision--autogenerate-madd user password_hashalembic upgradehead这就是Alembic的全部意义加字段不用删库、不用重造数据。四、第1步签发——注册与登录app/routers/auth.pyapp/routers/auth.py —— 注册 / 登录 / 刷新 / 当前用户fromdatetimeimportdatetime,timedelta,timezoneimportjwtfromfastapiimportAPIRouter,Depends,HTTPExceptionfromfastapi.securityimportOAuth2PasswordRequestFormfrompydanticimportBaseModelfromsqlalchemyimportselectfromsqlalchemy.ormimportSessionfromwerkzeug.securityimportcheck_password_hash,generate_password_hashfromapp.configimportsettingsfromapp.depsimportget_sessionfromcore.modelsimportUser routerAPIRouter(prefix/auth,tags[auth])classRegisterBody(BaseModel):username:strpassword:strdefcreate_token(user:User,expires_minutes:int,token_type:str)-str:签发令牌payload只放身份标识和时间不放敏感信息nowdatetime.now(timezone.utc)payload{sub:str(user.id),type:token_type,iat:now,exp:nowtimedelta(minutesexpires_minutes),}returnjwt.encode(payload,settings.jwt_secret,algorithmHS256)router.post(/register)defregister(body:RegisterBody,session:SessionDepends(get_session)):注册密码哈希后入库第18篇的碎纸机existssession.scalar(select(User).where(User.usernamebody.username))ifexists:raiseHTTPException(status_code400,detail用户名已被占用)userUser(usernamebody.username)user.password_hashgenerate_password_hash(body.password)session.add(user)session.commit()return{id:user.id,username:user.username}router.post(/login)deflogin(form:OAuth2PasswordRequestFormDepends(),session:SessionDepends(get_session)):登录校验密码签发access refreshusersession.scalar(select(User).where(User.usernameform.username))ifnotuserornotcheck_password_hash(user.password_hash,form.password):raiseHTTPException(status_code401,detail用户名或密码错误)return{access_token:create_token(user,settings.jwt_expire_minutes,access),refresh_token:create_token(user,settings.refresh_expire_minutes,refresh),token_type:bearer,} 哈希加盐原样复用碎纸机还是那把OAuth2PasswordRequestForm让登录兼容Swagger的Authorize表单。五、第2步校验——依赖注入实现get_current_user第05篇的Depends模式现在派上大用场——写一个“谁在调用我”的依赖挂在需要登录的接口上即可。app/deps.py补充fromfastapiimportDepends,HTTPExceptionfromfastapi.securityimportOAuth2PasswordBearerfromsqlalchemy.ormimportSessionimportjwtfromapp.configimportsettingsfromcore.modelsimportUser oauth2_schemeOAuth2PasswordBearer(tokenUrlauth/login)defget_current_user(token:strDepends(oauth2_scheme),session:SessionDepends(get_session),)-User:从令牌解析出当前用户无效或过期一律401credentials_errorHTTPException(status_code401,detail登录状态无效或已过期)try:payloadjwt.decode(token,settings.jwt_secret,algorithms[HS256])exceptjwt.PyJWTError:raisecredentials_error usersession.get(User,int(payload[sub]))ifuserisNone:raisecredentials_errorreturnuseroauth2_scheme的额外红利 FastAPI发现接口依赖它后/docs右上角会自动出现Authorize按钮——在Swagger里登录一次所有受保护接口都能直接在线调试本篇验收的重头戏。⚠️注意oauth2_scheme只在deps.py里定义一次。auth.py如果也需要它从deps导入即可——不要重复定义否则tokenUrl会对不上。六、第3步受保护接口与刷新 早报站第一个受保护动作——登录后才能提交RSS源app/routers/sources.py改造frompydanticimportBaseModelfromapp.depsimportget_current_userfromcore.modelsimportSource,UserclassSourceBody(BaseModel):url:strrouter.post()defcreate_source(body:SourceBody,session:SessionDepends(get_session),user:UserDepends(get_current_user),# 门禁挂上):登录用户提交自己的订阅源sourceSource(urlbody.url,user_iduser.id)session.add(source)session.commit()return{id:source.id,url:source.url}/auth/me——我是谁接口前端登录态判断的标准工具app/auth.pyrouter.get(/me)defme(user:UserDepends(get_current_user)):return{id:user.id,username:user.username} 刷新接口——access过期后用refresh换新的app/auth.pyclassRefreshBody(BaseModel):refresh_token:strrouter.post(/refresh)defrefresh(body:RefreshBody,session:SessionDepends(get_session)):用refresh token换新access tokencredentials_errorHTTPException(status_code401,detail刷新令牌无效或已过期)try:payloadjwt.decode(body.refresh_token,settings.jwt_secret,algorithms[HS256])exceptjwt.PyJWTError:raisecredentials_errorifpayload.get(type)!refresh:raisecredentials_error usersession.get(User,int(payload[sub]))ifuserisNone:raisecredentials_errorreturn{access_token:create_token(user,settings.jwt_expire_minutes,access),token_type:bearer,}token的typeclaim是“用途隔离”refresh令牌只能换新access不能直接当access用——令牌之间的权限边界要靠自己画。修正说明原文refresh_token: str是查询参数会暴露在URL里已改为用Pydantic模型接收JSON请求体更安全也更符合规范。七、验收清单1.curlPOST /auth/register → 注册成功psql看password_hash是天书2.curlPOST /auth/login → 拿到access_token refresh_token3.curl/auth/me不带令牌→401Not authenticated4.curl/auth/me-HAuthorization: Bearer token→ 返回用户信息5. /docs里点Authorize登录 → POST /sources可直接调试受保护生效6. 伪造一个token随便改一位→4017. 全程无报错后提交Gitgitadd.gitcommit-mJWT认证注册登录 受保护接口 刷新八、常见报错这6个JWT的标配重点①ImportError: python-multipart is not installed 原因OAuth2PasswordRequestForm解析表单需要python-multipart。✅ 解法pipinstallpython-multipart FastAPI的报错提示会直接告诉你装它。②ModuleNotFoundError: No module named jwt 原因包名陷阱——装的是pyjwt导入的是jwt第13篇bs4的亲戚。✅ 解法pipinstallpyjwt③jwt.exceptions.InvalidSignatureError/ 所有令牌突然失效 原因换了JWT_SECRET——签名密钥变了旧令牌全废。✅ 解法生产环境的JWT_SECRET一旦定了就永远不要改改了 全员重新登录。 万一泄露改完必须接受“全员下线”。④ 接口明明写了user依赖不登录也能访问 原因函数签名里写了user参数但忘了 Depends(get_current_user)。✅ 解法检查Depends受保护接口必须显式声明依赖否则就是裸奔。⑤ExpiredSignatureError刚登录怎么就过期 原因access token 15分钟到期设计如此。✅ 解法走/auth/refresh换新前端在401时自动刷新重试后续联调实现。⑥TypeError: NoneType object is not subscriptable或查不到用户 原因payload的sub是字符串“1”你却session.get(User, 1)——类型不匹配查不到。✅ 解法int(payload[sub])转回整数第5节写法。签发时str()解析时int()两头要对齐。九、课后练习#练习难度提示1登出思考题无状态令牌怎么“踢人”研究两个方案——令牌黑名单服务器记下作废的jti或用户表存token_version签发时带上校验时比对⭐⭐⭐各自代价是什么2refresh轮换给User加token_version列refresh一次1旧refresh令牌立即失效⭐⭐⭐payload里带version claim3角色区分token里加role claimuser/admin管理员专属接口校验角色⭐⭐get_current_admin依赖4选做完整流程截图用/docs的Authorize走通“登录 → 提交订阅源 → 查看列表”三步⭐⭐发评论区 配套代码完整认证模块已上传GitHubpython_daily/【gitee仓库地址】