首页 / 资讯中心 / 文章详情

adarnauth-esi库实战:简化ESI接口OAuth认证与Token刷新流程

adarnauth-esi库实战:简化ESI接口OAuth认证与Token刷新流程 ★ FEATURED ARTICLE
从第一次接触某太空题材MMO游戏的第三方数据接口ESI开始我就一直在找一个能让我省下认证环节的封装包adarnauth-esi就是我在这个过程中试出来最顺手的一个。它把ESI的OAuth认证流程、令牌刷新、接口请求封装成了三层非常薄的结构上手成本低改动也容易很适合做角色分析工具、市场订单追踪、军团管理这类小型项目。这篇内容我会把它的核心类、方法参数、实际调用方式以及在真实项目中踩过的坑一起整理出来希望能帮你少走一些弯路。1. 这个包到底解决什么问题1.1 开发者绕不开的ESI认证流程不管你是想查角色技能、看钱包流水还是做市场差价分析只要碰ESI的接口就绕不开OAuth 2.0这套流程。很多人第一次接触的时候会觉得麻烦——跳转登录、回调拿code、换access token、刷新refresh token听起来简单但真正实现的时候很容易漏细节。最简单的例子access token的默认有效期大约是20分钟refresh token的有效期长得多但一旦角色改过密码或者你在SSO页面重新授权refresh token就会失效。如果没有一套自动刷新的机制你的工具用着用着就提示认证失败用户还得重新扫码授权体验很差。adarnauth-esi这套包的核心目标就是把这些琐碎的认证逻辑收敛到几个类里。你不必自己写重定向处理、不必手动解析JWT里的角色ID只要关心业务代码本身。1.2 adarnauth-esi的定位与核心封装这套包整体上分成三个类EsiAuth负责SSO登录URL生成、回调code换token、刷新token。EsiClient负责实际请求ESI接口自动附带认证头处理返回头和常见HTTP错误。ValidToken负责对JWT做基础校验提取角色ID和过期时间避免额度用错角色。三个类之间的关系很直接EsiClient内部持有EsiAuth实例发请求前检查token是否快过期如果快过期就自动调用刷新逻辑ValidToken则是在回调阶段做校验用的。这种方式和市面上那些“全家桶”式SDK思路不同它没有过度封装。你需要改请求头、换base_url、加自定义超时都可以直接碰到底层不会被迫绕远路。1.3 和自建认证代码相比省了什么我自己最早写过一个认证工具代码大概三百多行主要处理的是构造授权URL时需要把所有scope拼进去还要生成并保存state防CSRF。回调后要拿着code去POST token端点校验返回里的JWT签名。每次请求前要判断token剩余时间低于阈值就刷新。请求返回401时还要重新走一遍刷新再重试原请求。这些逻辑放在业务代码里非常碍眼尤其是同时管理多个角色的场景还得按角色分别存access_token和refresh_token状态管理很快就失控了。用adarnauth-esi之后这堆事情基本就压缩成配置和两句调用token的存取也能用自己的存储层接管灵活度比完全自己造轮子高出不少。2. 语法与参数拆解从登录到刷token2.1 安装与引入安装很简单直接用pippip install adarnauth-esi引入的时候只需要注意包名里带横杠但模块导入时用下划线from adarnauth_esi import EsiAuth, EsiClient, ValidToken如果你是在某类虚拟环境或受限网络里装可能需要换国内镜像源这个包依赖的第三方库不重一般不会出奇怪的编译错误。2.2 EsiAuth类登录链接、回调换token、刷新token先看签名基于常见实践整理EsiAuth( client_id: str, secret_key: str, redirect_uri: str, scope: list | str, user_agent: str MyAPP/1.0, callback_url: str | None None, )client_id和secret_key在开发者后台申请应用时拿到对应应用的标识和密钥。redirect_uriSSO授权后回调的地址必须和开发者后台登记的一致带不带结尾斜杠都会影响校验。scope权限范围列表。比如要看技能就传[esi-skills.read_skills.v1]要读钱包就传[esi-wallet.read_character_wallet.v1]。这里的scope名字要严格填完整版本号少写一个版本标记授权页面就不会显示对应权限。user_agentESI官方建议所有请求都带上可识别的User-Agent方便他们联系开发者处理问题也避免封禁。callback_url如果你的服务端接收回调的地址和用户最终看到的redirect地址不同可以用这个参数做区分。我用官方文档里的常见实践举一个例子auth EsiAuth( client_id你的client_id, secret_key你的secret_key, redirect_urihttps://example.com/callback, scope[esi-skills.read_skills.v1, esi-wallet.read_character_wallet.v1], user_agentMySkillTool/1.0, )生成登录跳转链接login_url auth.get_auth_url(staterandom_state_string) print(login_url)这里的state参数是防CSRF用的官方建议你生成一个随机字符串存到session里回调时再比对。包本身不会替你保存得自己在应用层处理。回调阶段用code换tokentoken_data auth.auth_code_callback(code回调拿到的code, state之前生成的state)返回的token_data通常包含{ access_token: ..., token_type: Bearer, expires_in: 1200, refresh_token: ... }需要说明的是adarnauth-esi在不同版本里对这个方法的命名可能略有差异我这边用auth_code_callback表示实际使用前建议先打印一下dir(auth)确认。核心逻辑是一致的它内部会校验state、拼接参数、POST到token端点再把响应解析为字典。刷新tokennew_token_data auth.refresh(refresh_tokenrole的refresh_token)这里有个细节刷新token时EsiAuth会重新调用一次OAuth端点返回的expires_in和原先未必是同一个值。你最好把返回的access_token和expires_in一起更新到自己的存储里而不是直接沿用旧值。2.3 EsiClient类发请求与自动续期EsiClient的初始化有两种常见方式。第一种直接传EsiAuth实例client EsiClient( authauth, token角色的access_token, user_agentMySkillTool/1.0, )第二种直接传一个返回access_token的函数这样可以动态获取最新tokendef get_access_token(): return my_token_store.get(character_id).access_token client EsiClient( token_providerget_access_token, user_agentMySkillTool/1.0, )我更推荐第二种因为多角色场景下每个角色有独立的token而EsiClient实例是无状态的可以做到一个client走天下。具体调用接口res client.get(/characters/{character_id}/skills/, character_id2112345678)EsiClient会把字典参数按路径占位符拼到URL里再带上Authorization: Bearer ...请求头。如果请求时token已经过期它会自动调用EsiAuth的刷新逻辑拿新token并重放这个请求。这个重放逻辑非常实用我在实际使用中基本很少手动处理401。请求参数示例res client.get( /characters/{character_id}/assets/, character_id2112345678, page1, languagezh, )这里额外的query参数会拼到URL后面languagezh会变成?page1languagezh。不过ESI的很多接口对language的支持有限不是所有字段都翻译别指望它把物品名都本地化。2.4 ValidToken类解析JWT里的关键信息回调拿到的access_token本质上是一个JWT里面携带了角色的基础信息。adarnauth-esi通过ValidToken来做轻量校验vt ValidToken(access_token角色的access_token) character_id vt.character_id expires vt.expires_at这个类的好处是你不用专门去调/verify/接口确认角色身份直接在本地就能拿到character_id。不过它只做解码和基础校验不验证签名如果对安全性要求高建议在换token后自己调用ESI的verify接口再校验一次。我的经验是登录后立刻查一下/verify/确认返回的角色ID和JWT里的角色ID一致能避免很多授权回调串号的诡异问题。2.5 scope和角色关联的坑很多新手在这里翻车。场景是这样的你给角色A授权了技能读取权限然后又给角色B授权了钱包读取权限。这时候如果整个应用只保存一个access_token用B的token去请求角色A的技能接口ESI会直接返回403。本质上ESI的授权是按角色scope绑定的。access_token只能访问“该角色被授权的scope集合”内的接口。你在设计数据库表结构时最好把角色ID、access_token、refresh_token、已授权的scope集合存成一条记录不要用全局唯一的token。adarnauth-esi并没有帮你做“token对应哪个角色”的管理这是它有意保持精简的一部分。你可以自己写一个简单的KeyValue存储比如按character_id为键存token字典。2.6 常用参数速查表参数名所属类用途常见值/注意点client_idEsiAuth应用标识来自开发者后台不属于保密字段secret_keyEsiAuth应用密钥不要提交到公开仓库redirect_uriEsiAuthSSO回调地址后台登记保持一致注意结尾斜杠scopeEsiAuth授权权限范围列表版本号不能漏如v1user_agentEsiAuth/EsiClient请求标识建议写工具名加版本号stateget_auth_url防CSRF随机串回调时校验不能复用token_providerEsiClient动态获取当前token返回字符串即可language请求参数本地化语言字段支持有限page请求参数分页页码部分接口从1开始3. 实际应用案例做一个角色技能分析工具3.1 场景设定我做这个案例是因为当时要给军团里的新人做技能体检看看哪些号技能点虚高、哪些重要船只根本没学。如果手动一个个去游戏内查效率太低。用esi接口批量拉每个角色的技能列表再和预设的“必学技能清单”做比对就能批量输出结果。准备条件一个提供技能的账号角色若干。已经申请好的client_id和secret_key。一个能跑Python的本地环境Python版本3.9以上即可。我当时的开发环境是Windows笔记本命令行里直接跑代码回调地址用的本地localhost。如果你是部署在服务器上需要有一个真实可访问的回调地址流程一样只是要把回调地址换成你的域名或者内网穿透地址。3.2 第一步构造授权链接并引导用户登录核心代码from adarnauth_esi import EsiAuth auth EsiAuth( client_idyour_client_id, secret_keyyour_secret_key, redirect_urihttp://localhost:8080/callback, scope[esi-skills.read_skills.v1, esi-location.read_location.v1], user_agentSkillAuditTool/1.0 by SomeDev, ) state a1b2c3d4e5f6g7h8i9j0 login_url auth.get_auth_url(statestate) print(请打开以下链接完成授权) print(login_url)运行之后终端会打印出一个以https://login.eveonline.com/v2/oauth/authorize开头的链接。用户在浏览器打开登录自己的账号选择角色并点允许然后就会被重定向到你的回调地址地址后面带着?codexxxstatexxx。这里有一点我必须强调state不要每次都用同一个字符串最好是每次生成新的随机串并保存到本地session或内存字典里否则CSRF防护形同虚设。我见过有人直接硬编码state结果回调校验永远通过但登录接口安全性等于没有。3.3 第二步用Flask写一个最小回调服务不用重型框架一个轻量的Flask应用就够from flask import Flask, request, redirect, jsonify from adarnauth_esi import EsiAuth app Flask(__name__) auth EsiAuth( client_idyour_client_id, secret_keyyour_secret_key, redirect_urihttp://localhost:8080/callback, scope[esi-skills.read_skills.v1, esi-location.read_location.v1], user_agentSkillAuditTool/1.0 by SomeDev, ) pending_state a1b2c3d4e5f6g7h8i9j0 app.route(/callback) def callback(): code request.args.get(code) state request.args.get(state) if state ! pending_state: return state校验失败, 400 token_data auth.auth_code_callback(codecode, statestate) character_id token_data.get(character_id) return jsonify({ character_id: character_id, access_token: token_data[access_token], refresh_token: token_data[refresh_token], }) app.route(/) def index(): return redirect(auth.get_auth_url(statepending_state)) if __name__ __main__: app.run(host127.0.0.1, port8080)实际开发时我建议把token_data直接存到数据库不要放在内存里。否则服务一重启用户就得重新授权。我自己的做法是建了一张角色表字段包括character_id、character_name、access_token、refresh_token、expires_at、scopes后续所有请求都从这张表读token。3.4 第三步调用技能接口并做分析拿到角色ID后调用技能接口import json from adarnauth_esi import EsiClient, ValidToken access_token 上一步拿到的access_token vt ValidToken(access_token) character_id vt.character_id def get_access_token(): return access_token client EsiClient( token_providerget_access_token, user_agentSkillAuditTool/1.0 by SomeDev, ) skills_res client.get( /characters/{character_id}/skills/, character_idcharacter_id, )返回结构大致是{ total_sp: 123456789, unallocated_sp: 0, skills: [ { skill_id: 12345, skillpoints_in_skill: 500000, trained_skill_level: 5, active_skill_level: 5 } ] }其中skill_id可以通过另一个接口/universe/types/{type_id}/查到技能名称和描述但这个查询是按单个type_id走的。如果你要查很多技能建议服务端启一个简单的缓存免得频繁打接口。接着做“必学技能比对”required_skills { 182: Spaceship Command, 3386: Racial Frigate, 33091: Cynosural Field Theory, } my_skills {s[skill_id]: s[trained_skill_level] for s in skills_res[skills]} for skill_id, skill_name in required_skills.items(): level my_skills.get(skill_id, 0) status OK if level 4 else LOW print(f{skill_name}: {level} - {status})这里182这些编号是我随便举例实际使用时要先通过类型接口查清技能ID。技能ID是稳定的不会随便变所以可以做成一个静态映射表存起来。3.5 完整流程串联与效果把以上代码整合成一个脚本流程用户访问首页跳转SSO登录并授权。回调获取token和角色ID存入数据库。主程序读取数据库中的角色逐个调用技能接口。将每个角色的技能点、技能等级写入CSV或Excel报告。比对必学技能清单生成缺失技能列表。我实际跑了一轮大概五十个角色完整跑完时间主要在接口分页和网络延迟上一个角色平均0.5秒左右整体很稳。这里有一个容易忽略的点角色的trained_skill_level和active_skill_level是有区别的。前者是已训练等级后者是当前植入体加成后的等级。如果只做技能体检用trained_skill_level更准确。我曾经用错字段给一个新手号打出“战力溢出”的报告后来发现是算上了脑插加成。4. 常见问题与排查技巧实录4.1 问题速查表现象可能原因解决方法授权跳转后回调收不到coderedirect_uri不一致检查开发者后台里的回调地址必须和代码完全一致授权页只有部分scopescope列表漏了版本号核对官方scope列表补全v1之类的后缀回调后报invalid_grantcode已被使用或过期重新走一遍授权流程不要复用旧code接口返回401access_token过期或无效改用refresh_token刷新后再请求接口返回403token没有对应角色的该scope权限确认授权角色和请求角色一致JWT里取不到character_idtoken类型不对或已损坏重新换token或改用verify接口获取刷新token后新token又立刻失效refresh_token被多个地方并发使用加锁或统一由单一token管理服务刷新分页数据取不全忘记读响应头里的X-Pages根据X-Pages字段循环请求本地回调调试一直转圈端口没开或host绑定不对用127.0.0.1而不是0.0.0.0检查防火墙请求被限流单IP短时间请求过多降低并发加本地重试逻辑4.2 经典案例site验证失败有一次我换了一个新回调端口结果一直报redirect_uri不匹配。检查发现开发者后台填的是http://127.0.0.1:8080/callback而代码里写的是http://localhost:8080/callback虽然看起来是同一个地址但OAuth服务端严格按字符串匹配localhost和127.0.0.1不算等价。解决方案是统一下定义全部用http://127.0.0.1:8080/callback同时把SSO后台也改一致问题立刻消失。4.3 经典案例刷新token时并发冲突早期我在批量脚本里用多线程同时给多个角色刷新token本来想的是每个角色独立刷新逻辑上应该没问题。但某个角色被两个任务同时触发刷新后一个刷新把前一个刷新出来的refresh_token覆盖掉了导致旧refresh_token变成数字签名无效。解决方式有两个第一在角色级别加分布式锁第二把所有刷新操作收敛到一个独立的refresher进程里其他任务只负责读取新鲜度足够的token不直接刷新。我用第二种之后再没出现过token互相覆盖的问题。4.4 经典案例分页参数被忽略ESI的分页接口会在响应头里返回X-Pages总数很多新手只翻第一页就统计数据量少的时候没问题数据量大就差了十万八千里。adarnauth-esi的get返回的是响应体字典分页头信息需要你自己从原始响应里读。我用requests.Session包装了一层把X-Pages一并取出来。具体实现resp client.raw_get(/characters/{character_id}/assets/, character_idcharacter_id, page1) pages resp.headers.get(X-Pages, 1) for p in range(1, int(pages) 1): data client.get(/characters/{character_id}/assets/, character_idcharacter_id, pagep) process(data)这里需要注意如果用的是client.get部分版本可能已经解析成字典后续拿不到headers。我建议直接用底层的raw_get或session.get保留原始响应对象。4.5 实际使用中的三个经验心得第一access_token建议统一走刷新函数获取不要在主业务代码里手动判断时间。这样后续如果要加缓存、加锁、加审计日志都可以集中在同一个入口。第二scope尽量按最小权限原则申请。少一个权限应用能做的事就少一分风险。如果你的应用只是读技能数据千万不要申请资产管理、市场订单这类写权限或高敏感权限否则token一旦泄露对方能造成的破坏会大很多。第三User-Agent一定要写清楚。某个账号如果因为调用频繁被限流ESI官方团队可以根据UA联系到开发者协商优化。空UA或默认UA被处理的风险会大不少别在这种小地方吃亏。5. 一些值得再展开的边界情况与扩展思路5.1 多角色管理的token存储设计如果你只是给自己单角色写脚本那token存JSON文件就够。但如果要做一个给几十上百人用的小工具就必须考虑并发和隔离。我在某跨平台系统的模拟项目中试过这样的表结构字段类型说明character_idbigint 主键角色唯一标识character_namevarchar角色名access_tokentext当前有效access tokenrefresh_tokentext刷新令牌expires_atdatetimeaccess token到期时间scope_jsontext已授权scope列表每个角色一行记录读取时先查expires_at剩余时间小于120秒就触发刷新。这个刷新操作通过一个带超时的进程内锁保护避免同一角色并发刷新。5.2 定时任务场景批量拉取数据很多工具会做成定时任务比如每小时更新一次市场均价、每天检查一次技能队列。adarnauth-esi本身没有调度功能你可以配合apscheduler或系统定时任务来做。一个需要注意的点是每日任务如果刚好遇到access token过期EsiClient自动刷新逻辑会帮你续期但refresh token本身也可能因为用户改密而失效。这种情况需要让用户重新授权最好在管理后台提供“重新授权入口”同时用邮件或客户端消息提醒用户。5.3 请求失败的重试策略ESI偶尔会有网关超时或5xx错误官方建议对5xx做重试。我在项目里封装了一个简单的重试装饰器效果不错import time from functools import wraps def retry_on_failure(max_retries3, delay1.0): def decorator(func): wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as e: if attempt max_retries - 1: raise time.sleep(delay * (attempt 1)) return None return wrapper return decorator重试间隔建议递增比如1秒、2秒、4秒不要固定间隔否则多个任务同时重试时可能造成二次限流。5.4 从单脚本走向Web服务如果你想把工具分享给其他玩家使用最简单的方式是做成一个Flask应用页面里嵌入登录链接回调后把角色信息存到数据库里再在网页上展示技能分析结果。这一套流程用adarnauth-esi做认证部分非常顺畅代码量不会很多。我个人的建议是认证部分放在单独的蓝图或模块里不要和业务接口混在一起。这样后期如果要加新接口不需要改动认证逻辑只增加新的业务函数即可。5.5 日志与监控开发和部署过程中日志很重要。adarnauth-esi没有强制要求配置日志但你在调用时最好记录以下信息每次刷新token的执行时间和返回状态。每个角色最后成功请求接口的时间。接口返回401或403的次数。我用标准库logging实现按日期切分日志文件一旦用户反馈“我的工具挂了”第一件事就是去翻日志里最后一条刷新记录和请求记录能很快定位是token问题、权限问题还是限流问题。这个习惯帮我排查了至少十次潜在故障强烈建议你也养成。6. 踩过几次坑之后的个人体会最开始用adarnauth-esi的时候我其实没认真读源码完全靠试错。结果在token刷新上反复折腾浪费了不少时间。后来我把它的源码通读了一遍才发现它的设计思路是“尽量少的过期状态管理尽量多的把决定权交给你”。这一点和很多自动化程度很高的SDK完全不同。我的建议是用这个包之前先花半小时把EsiAuth、EsiClient、ValidToken三个类的源码浏览一遍重点看refresh方法和get方法的内部逻辑。搞懂它什么时候重放请求、什么时候抛出异常后面的开发就会顺很多。如果你准备做的东西只是临时拉一下数据可以直接抄前面的案例。但如果你要长期维护一个公共工具我强烈建议你尽早设计好token存储和多角色隔离方案不要等项目上线了再重构。根据我个人经验认证这块重构起来最费劲因为涉及到所有已授权用户他们可不会愿意重新授权一次。最后再分享一个小技巧调试阶段不要直接用真实角色ESI提供了一个测试角色功能你可以在开发者后台创建一个专用测试角色只给它最小权限所有乱调用都在测试角色上做。等逻辑稳定了再让真实用户授权这样既安全又不影响他人。adarnauth-esi这套包的定位就是“薄而稳”它不会替你包办一切但把最麻烦的认证细节理顺了。配合良好的存储设计和错误处理它能支撑起从小脚本到完整Web服务的一系列应用值得你花点时间掌握。
阅读完成 · 觉得有帮助?
咨询建站