ChatGPT 动作认证
在这个随机应用程序构建系列中,我们将创建一个使用OAuth身份验证的自定义GPT,以获得一个持久的用户身份,并委托给其他身份验证提供商。
代码:https://github.com/garunski/wordswithchat。
网站:https://wordswithchat.com/.
CustomGPT: https://chat.openai.com/g/g-HGmSVHyWf-与交流的单词。
目标:为自定义GPT创建一个行动,允许用户认证。
技术栈:使用Cloudflare Pages和Functions - 快速、简单、无服务器、键值存储、免费。使用Google Auth,因为用户名/密码流程更简单且可在任意时间完成。在开始时添加社交登录的要求,可以让解决方案灵活适应其他供应商。此外,我强烈不建议大家再创建另一个身份验证提供者。
本文假设读者已熟悉OAuth流程以及自定义ChatGPT操作的OAuth配置。
最初的想法是仅使用Google OAuth来让用户登录并在服务器端验证ChatGPT发送的令牌。但是,我遇到的限制之一是所有URL都必须匹配操作。因此,服务器的URL必须与身份验证对话框中配置的URL相匹配。否则,你会收到“授权URL、令牌URL和API主机名必须共享根域”的错误。如果目标是直接与Google APIs通信,这是可以正常工作的,但作为第三方API的身份验证,就不行了。此外,本地主机(localhost)不作为一个有效的URL,所以使用类似https://ngrok.com/的工具使得本地测试API变得更容易。鉴于这个限制,实质上我们需要成为该操作的身份验证提供者。在我的情况下,身份验证需要被代理到Google并在服务器端进行处理。
授权URL
这是用户点击“登录”按钮后ChatGPT将重定向到的URL。当需要进行认证时,将需要在此页面上进行决策。
TL;DR(跳过分析并转到令牌网址):初始页面加载向用户展示所实现的认证提供商的选择,并根据所选择的选择开始登录流程。成功验证后,需要将一个代码返回给ChatGPT,ChatGPT将使用该代码进行POST请求到令牌网址以获取访问令牌。在我的情况下,我将用户ID与过期时间进行加密,并将其作为代码返回。
在最简单的情况下,我们可以默认重定向到Google认证,并在完成该流程后返回令牌。在这种系统中,认证成为了一个真正的代理,并且很容易实现。在此过程中,我们将从令牌URI中获取JWT,并将其返回给ChatGPT。我从未测试过这种方法,因为我想创建一个认证提供程序,所以不确定发行者或受众中的所有不同的URL是否会产生问题。我猜可能不会。当访问令牌过期并通过刷新令牌发送新的访问令牌时,需要考虑特殊情况,但这也可以直接代理给Google。
我决定使其更具可扩展性,并增加弹性以允许多个认证提供商。我目前的实现是使用完全静态的 HTML 响应,不使用 JavaScript,但这有点傻,唯一的好处是页面在关闭 JavaScript 的情况下也能工作,对于像 ChatGPT 这样的客户端机器人来说,这个问题可以忽略不计。登录页面和静态重定向到谷歌函数的锚点标签都应该重构为静态页面,并使用 JavaScript 简化代码库并消除函数调用的开销。实际实现静态 HTML 并进行环境变量替换的过程出奇地复杂。有很多非常复杂的构建工具可以执行各种复杂的操作,但当你只需要像字符串替换这样的小功能时,它们都无能为力。我尝试了 Vite,但在调整默认设置并尝试不同的插件之后,我放弃了,并采用了当前的服务器端解决方案。我遇到的问题是 Vite 无法替换包含在 a 标签的 href 等属性中的变量,但在属性之外,环境变量替换正常运作。
我们需要一个终端点来接收Google认证决策,以确定认证成功或失败,并使用返回的代码从令牌URI获取JWT令牌。这也是我们必须进行服务器端处理的第一个地方,因为没有人想要泄露客户端密钥。为了唯一地识别用户,可以解码id令牌并使用sub声明作为Google特定的用户ID。sub保证在Google认证用户中是唯一的,但它不是所有认证提供者都通用的标准,因此我们不能只将sub作为通用标识符使用。在我的情况下,我存储了一些JWT结果以刷新令牌。refresh_token仅在初始认证授权时返回。对于授权的后续调用,只返回access_token和id_token。为了提供更流畅的体验,我们可以存储refresh_token并使用它来获取新的更新后的access_token。Google的access_token在进行认证检查时应进行验证,以防止登录撤销。如果由于任何原因刷新令牌无法返回,应返回401,ChatGPT将重新开始OAuth流程。
令牌网址
有两种情况下会调用Token URL,第一种是grant_type为authorization_code时,表单将被提交到端点,其中包含从Authorization URL返回的代码。而第二种情况是grant_type为refresh_token时,如果authorization_code的grant_type返回refresh_token和access_token,将会发送refresh_token。
TL;DR:无论哪种情况,令牌都是在没有后备数据存储的情况下生成的。我选择加密用户ID,一些填充用于验证和过期,并将其打包成一个访问令牌,授权期间可以解密和拆包。
我想要减少对Cloudflare KV的调用次数,所以选择将access_token和refresh_token加密作为流程的一部分。这允许大部分身份验证跳过获取存储在数据库中的某种值并将重要的用户ID融入到令牌中。这种方法纯粹是为了降低成本并提高无服务器功能的性能。由于这种选择,存在一些缺点,当轮转密码时,包括刷新令牌在内的所有令牌都将无效,这将导致OAuth流程重新启动,这并不是最好的用户体验。如果实施了一个带数据存储的流程,KV不是最好的选择,像D1这样的存储系统将会更好。
授权
我选择使用中间件在页面功能中实现授权。中间件检查ChatGPT发送的Bearer令牌并做出决策。我使用context.data来存储提取的用户ID,以备后用。
安全
我实施的加密是使用Web Crypto API进行的非常简单的AES-GCM实现。为了降低计算复杂度,密钥派生的迭代次数设定为256次。这减少了无服务器函数的计算负载,并使得每个函数的运行成本更低。之前选择减少对外部数据存储的请求次数也意味着我没有地方存储应该是随机生成的初始化向量和用于加密的盐。
const generateKeys = (value: string) => ({
password: value.slice(0, 60),
salt: value.slice(value.length - 40, value.length),
iv: value.slice(value.length - 60, value.length - 20),
});
generateKeys函数使用一个长字符串并将其拆分成用于加密所需的字符串。这种方法并不理想,不应在高度安全的应用程序中使用。通过字典攻击,攻击者可以在多项式时间内推导出每个密钥,这是由于迭代次数较小以及静态盐和初始化向量之间的关系。
话虽如此,Web加密API作为开发人员使用的接口实在是糟糕。我确信所作的选择是为了流媒体和网络套接字,但是当尝试进行简单的加密时,开发人员被迫将字符串转换为ArrayBuffer,然后再转换为Uint8Array。这段代码复杂且存在许多陷阱,会让程序员感到沮丧。我不认为这样的“灵活性”是有必要的。阻止人们使用良好实践的最简单方法是设置障碍,比如这个接口。加密应该容易实现,尽可能少出差错。该API需要具有直观的接口,能够处理底层的实现细节。如果你在文档页面上不得不放置一个巨大的警告,基本上是说:“你会做错,雇佣懂行的人来检查你的工作”,那是因为你做错了。
游戏
实际游戏是通过定义的三个可能的单词端点来猜测一个单词。指南很简短地定义了游戏流程以及用户猜对时应该做什么。
ask the user which word category they would like to guess, their category choices are animals, places, colors.
once the category is selected, call the action that corresponds to the get for that category, for animals its /api/words/animals, for places its /api/words/places, for colors it is /api/words/colors.
once you have all the words, pick a random word from the array, and remember it.
give a hint about the word.
you are allowed to give hints about the word.
you are not allowed to say the word.
you are not allowed to describe the word fully.
if the user guesses the word correctly then POST to the post_score endpoint, the endpoint will return the new score.
if the user asks about their score, GET from get_score and return the score
游戏的目的是展示授权,并不打算成为一个完全成熟的游戏,所以是的,有一种非常简单的方法来欺骗它并获得高分。Cloudflare WAF 配置了一些滥用防范措施,但还有很多其他防止滥用的方法可以采取。