以工程化角度落地锋火登录 SDK
碎碎念
这一篇文章只是一个杂记,记录一下我是怎么开始构建一个 SDK 的。最近发现公益站 Codex 暴起,猛猛登,这个文章也属于记录的文章(会稍微偏向一些工程化实践进行考量),有些地方我就可以很快略过。也不需要使用心智模型较低的一些模型来做(因为我用低心智模型来做东西写文章我主要凸显出来的目的就是一定要有自己的思考【第二就是上班,如果没报销的话】)。
因为属于依旧属于一些工程化的文章,再多多少少都要会或者熟悉一些代码才能看得懂,并不是纯理论的文字纯因果关系描述,如果是上班或者忙的用户可以留到下班或者清闲的时候看,文章并非教程,会引出你该怎么想,带了自己的思考后再去看文章,如果本身已经很累了,那么再看这一类文章属于增加负担。
我的这里的记录依然是从人工开始把握主动权后逐渐让位给 AI 去执行,因为最开始的内容往往是可以顺着思路下去的。一开始就让 AI 来做,绝大部分内容做的内容绝大部分不符合我的预期。
如果你不知道 OAuth2 的登录流程也不要担心,我会在必要的地方写出其关系的逻辑链。也许你看完这个你对 OAuth2 的登录流程就清楚多了。
写这个文章主要是发现我主要的地方我发现我漏写一个这个登录的 SDK,如果直接在项目实现了我迟早还要实现这个 SDK,不如先写了好了,当做工程体系文的番外篇看看。
在这里我需要补充一点,本文内所涉及的代码不要感到害怕,本文不需要你明白代码详细逻辑怎么写的。本文贴出的所有代码都属于定义叫做解释型代码,只需要理解语义即可,不需要对方法进行展开理解(我贴出来的代码就是所需的可见范围需要语义理解的范围)。
例如,如下示例代码,作为解释型是
1 | func (h *AuthHandler) Login(ctx *gin.Context) { |
哦,有一个 Login 方法,传入一个叫做 context(这啥东西,翻译一下,上下文,干嘛的,就是从上到下传递的东西),然后有一个h.log.Info ,h 啥玩意看不懂不知道,但是有 log 有 Info,大概意思就是打日志呗,知道了。然后 h.service.oauthLogic.Create 不晓得,service 那就是 Service 层或者 Logic 层,然后用到了 oauthLogic 然后调用了创建,意思就是创建了啥啥啥东西呗,看到最后有一个 ctx.Redirect(http.StatusFound, authURL) 哦,logic 返回了个链接目的是跳转。
做到这样直接解释就可以了,不需要深究。这里不是做源代码拆解,只是讲工程思想,不然就会接受过多导致思维混乱。
SDK范围界定
这个 SDK 并非传统提供函数调用类型的 SDK,他属于更偏向脚手架层面,额外提供一些函数调用的 SDK 类型。
这个 SDK 要做什么?他能处理什么事情?如何后期为我简化工作难度?
- 这个 SDK 需要对接我的「锋火登录」程序,他是一个标准的 OAuth2 登录程序。这里需要做的是 Client 端的登录验证 Code。换取 AT 和 RT。对 AT 的刷新等等工作。
- 他应该实现最基础的这些功能,并且可以在引入这个库的时候不修改源代码基础上直接使用。这样可以很简单使用这个 SDK,并且不会出现后续 SDK 与项目绑定过深导致 SDK 不好维护。
那么我们做这个的时候最可能在哪里用到。结合我前面我说的基础脚手架 bamboo-base-go 所以这里可以确定使用的是 Gin 框架,数据库采用 Gorm,缓存采用 Redis(在这里可以看到脚手架文档 竹简文档-筱工具。
那么就可以按照这三个方向来设计(因为这个 SDK 优先保证我自己使用,Redis 是可以作为可选项的。但是保证我自己使用那就一定会有 Redis,那么就可以直接实现 Redis 有关的内容)。
对于如何拦截如何响应处理呢?拦截可以采用 middleware 中间件的形式进行拦截。响应处理可以直接构造 handler 部分,SDK 引入直接在 route 路由进行注册(或者直接封装好路由直接引入 *gin.RouterGroup)。
OK,那么工作内容就是大体需要 middleware, handler, models, cache 主要的包进行管理。这里界定我的项目叫做 bamboo-base-sdk 所以定义包名前缀就是 bSdk 避免后期混淆(比较方便)。
碎碎念:为什么这里不用 internal 这里 Go 定义的官方名字呢?毕竟 internal 主要目的是防止代码污染(它可以能强制隐藏实现细节,当前项目外的地方是看不到这里面的东西的)。我这里是作为一个 SDK 使用,内部这些东西是必须要给出去的,所以这里不能用 internal 包裹一层。
1 | ❯ tree -d |
我来解释一下这里面的这些模块有什么用。
- api 包具体是存放 handler 所构造的最终 请求体 和扩展响应体 ,这里将会用作 HTTP 的输入和数据输出端点,用于前端相关内容的对接。
- constant 包是定义一些常规的常量(没啥特征点)。
- handler 包用作请求的处理,如处理写入请求体写入 api 内的结构,构造最终的请求输出,以及调用多个 logic 进行数据流转,不对数据进行逻辑处理,仅分发。
- logic 包用作具体的逻辑处理,例如数据输入将如何流动(如:用户登录后信息应该如何操作,这些数据格式是否需要修改,验证判断等等)
- middleware 包用作请求相关的中间件,用作拦截使用。例如本项目拦截器用作验证用户登录是否有效。
- repository 包是用作数据的生命周期管理,因为要求 logic 仅仅是逻辑层面的处理,能处理数据流动和数据处理。但是绝对不能对数据发起管理(增删改查),以免造成数据修改不统一导致数据不一致,其内部的 cache 就是专门对缓存的生命周期管理。
碎碎念:这里是我的经验结构观念,所以我直接说出来了,并没有说我是如何思考想出来的,按照我对设计这个的认识来思考的,如果想不到也无所谓。你大概要知道 handler(controller)一定会有就行,后续缺什么创建什么也可以的。(为什么一定有,因为对于这个 SDK 来说,我要尽可能减少代码编写,对于扩展东西可以迅速复用,所以并非传统的提供调用函数的写法,总的来说更偏向脚手架)
设计/开发
这里其实可以直接利用 golang 官方推出来的的 oauth2 client 端的 SDK 直接进行对接,非常舒服非常快!
你可能要问,对于这个 SDK 的配置从哪里来呢?因为对于配置 OAuth2 登录来说,是需要 ClientID、ClientSecret 以及几个 Endpoint 端口的。
这里的配置也是从我的 bamboo-base-go 脚手架配置好了,在这里面有内置的 xEnvXxx 函数可以使用直接拿到环境变量内具体的值。最后只需要写出一份 README.md 按照这个提供的环境变量名字直接提供就可以了(很方便吧)。
回调部分(callback)
我们先来做回调处理。为什么选择这个接口开始呢?
发散一下思维,你的关系链是什么。如果用户没有登录那么他能干什么?只能引导去登录对不。那么在登录平台登录完之后呢?
根据这里补充的内容,最终用户授权完毕后,将会在分发 Code 拿到一个临时激活码,用户会携带这个临时 Code 再次到拦截器端点(middleware) 或者 授权端点(handler) 【我更倾向于授权端点,只是我的图没有表现出来,拦截器部分这里包含了授权之类的端点,只是简图】后在这里访问验证端口(或者叫其他类似名字端口【图片仅供参考逻辑流程图】)换取用户的 AT 以及 RT 登录态。
在这里最先做 callback 的端点,可以确认只要成功换取 code 后访问这个 callback 就可以换到 AT 和 RT 授权信息下来。
另外一个原因,因为现在项目非常空,如果直接让 AI 进行上手,这个东西本身不复杂。对于 AI 来说(心智模型比较高的模型)往往会把这个东西做的比较大,会做出来很多没有必要的工作。并且由于项目很空,他并不知道我的代码规范和我的结构规范。对于我好声好气一字一句进行描述我的架构过程,不如写好一个完整的链路过程后再让 AI 构建 SKILL 也好或者其他东西也好。这样行为会更加可控。
这里采用的 SDK 是 Golang 官方的 oauth2 进行对接。现在可以看到 handler 下。按照我的习惯中,我会单独创建一个 handler.go 或者 new.go 文件专门当做结构体的引入。
1 | // handler.go |
在这里定义所有的 handler 基础结构体,需要给入 db, rdb, log (其实这三个并不是实际都需要给的),可以采用上下文的形式直接获取。这里属于 bamboo-base-go 的地方,我不多进行讲解(因为这里跟我的其中脚手架内特性绑定,所以最好的方式是 handler 这里要提供,往下传递)
这里结构体最重要是为了区分不同的类型,后面例如扩展不止 AuthHandler 还有其他的,所以统一 handler 基结构体可以保证向下传递基础必要的内容都是需要的(类似于 Java 的接口)。
现在只需要注意到我们有 type AuthHandler hander 就可以了,对于 NewAuthHandler(…) 后面用于路由注册使用的,在这里预留就可以了。
下面我们就可以创建正式的业务 handler 文件了。那么如何写呢?现在已经创好了基类文件了。剩下只需要关联上就可以了。
特殊地方讲解一下:为了防止学过 Go 的人不多,我这里讲一下 Go 这里比较特殊的东西 “接收者 (Receiver)” 。他长这样
func (receiver) name(value) {function}。这玩意是干什么的呢?
上面讲解完了,如果你还是不太理解我这里做出来的 handler.go 有什么作用没关系,只要知道有这么一号玩意在这。并且实际业务需要就可以了。
现在分析一下 callback 需要有什么?从刚才的解释原理图里面可以看到,登录后会返回一个临时授权 code。我也解释需要用这个 code 来交换到 AT 和 RT(登录令牌)。并且这个 code 不能常规说携带在 cookie 或者 localStorage 中,因为不同源也不是一个站点。所以能传递的只有链接 302 跳转链接。
那能怎么办?只能构造成链接 param 参数形式,也就是 <协议>://<主机地址>/<回调路径>?code=<临时Code> 那么我的 param 就是 code(这里叫做 code 是因为 OAuth2 规范就是这么定义的,返回的就是 code)
那么我在 handler 的第一步做什么?要拿到这个参数
1 | func (h *AuthHandler) Callback(ctx *gin.Context) { |
如果不给这个 code 那就一定是错的,就要报错,对于 RESTful API 前后端分离来说,返回标准状态码和具体的信息。那就可以构造为
1 | func (h *AuthHandler) Callback(ctx *gin.Context) { |
在这里,如果检查到 getCode 是字符串空,因为 ctx.Param(string) 明确说了返回是 string 而不是 *string 所以一定是携带有指针,也就是非空内容,那就是字符串空值。
(那为什么没有加其他检查?)因为指定平台 OAuth2 生成 code 是有规律的,自己算法生成的,那就有一套正则规则。如果是 OAuth2 的标准程序(也就是可以对接其他任何符合标准的 OAuth2 程序),并没有表示这个 code 的正则范围哪里,规范并没有定义约束 code 的生成限制,可以自己自定义最终可以发给服务器就行,所以这里我没有验证【其实大白话其实是,我懒得重新找到我的 OAuth2 项目,我忘了我这里怎么生成的了】。
后面部分,就要从环境变量获取一下 ClientID 以及 ClientSecret 。为什么要这个?因为对于 OAuth2 程序来说,创建应用会提供,这里是作为通讯 OAuth2 的手段(鉴权这个应用是否有权限访问这个 OAuth2 服务)

在我的脚手架内已经写好了如何读取环境变量的工作。
1 | // constant/environment.go |
我这里单独定义了输入的 xEnv.EnvKey 类型,目的就是尽量去做标准化。在这类进行常量配置,配置完后可以直接在引用的地方进行使用(在这里只是先预填写几个内容,因为在后续实际上需要自己提供 Endpoint ,但是这里还没环境变量引入,先放入最基础的内容进行流程验证)。
1 | // 获取环境变量 |
下面基础资料已经准备好了,现在可以开始看 oauth2 SDK 的内容开始对接。在他这里可以看到 func (c *Config) Exchange(...) (*Token, error) 使用了接收者,所以一定需要初始化 oauth2.Config 的。最终可以构造出如下所示的内容。
1 | // 调用跳转 |
在这里实际验证的时候,可以采用 curl 或者 postman 类似进行模拟换取 code 的操作。然后携带此 code 测试访问这个接口是否可行。若可以换取到 getToken 的内容,并且能够成功打印出来。则说明这个流程是没有问题的。
1 | { |
诶,看到这里,有疑难杂症的小伙伴应该就有问题了。从最开始到现在,AI 愣是一点没介入。实际上上面的东西写的内容相对来说基本是个 AI 就可以解决(还不需要自己验证,因为 OAuth2 这种标准东西加上 AI 一定认识 Golang 自己出的 oauth2 SDK,所以基本可以一步到位的,但是实际上我并没有使用。
这里并不是说他不能做,而是很有可能做多,他一定可以做而且做的还不错。但是他一定会做多,并且上面的过程是为了疏通这一串流程,后续部分我还要进行抽象出来。简化整体逻辑的。
AI 他有一部分的全局观以及完成这个小范围的所有能力。但是只要你不完全指明,他不会给你站在那么高的角度去做事情。对于心智模型比较高的模型来说,你跟他说实现这一套。他后续可能把所有的一连串的东西,例如这里的换取信息,构造请求链接,吊销 Token,续 Token 全部过程都做了一次。对于我后续一句一句跟他说,不如有这段时间我就直接自己构造完大体,写好一到两个接口后由 AI 接管剩下的内容。
现在已经全部跑通了,但是可以发现在这里其实有很多内容干了 handler 不该干的事情,从 xEnv 获取环境变量部分开始,然后下面的 oauth2.Config 配置,这一部分都可以完全抽象出来到注册上下文的部分来使用。而实际的代码中,只需要最终的 (config).Exchange(…) 才是合理的!
那么可以对上述内容再次进行抽象化改造,提出到独立 startup 的板块。那么对于 startup 包就可以创建一个 startup_oauth.go 在这里面写初始化的步骤就可以了。
1 | // startup/startup_oauth.go |
完整文件你可以参考 beacon-base-sdk|oauth.go 我优化了很多地方(在这里我就不贴出来代码了,因为跨度比较大)。但是在这里并不是作为重点进行说明,我可以给你解释一下具体与这里的差异以及最终设计应该如何使用。
这里面我添加了 .well-known 配置。因为 well-known 是属于自动发现机制。有这一个配置只需要保证 Discovery 不发生变化,如果其他接口更新了发生了变化也无所谓,只要保证 Discovery 内同步更新就可以了。并且使用了闭包来管理上下文,业务端只需要直接
func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes就可以直接使用了,非常方便。那么示例最终的业务端代码引入效果如下所示例
1
2 r.Serve.Use(handler.systemContextHandlerFunc)
r.Serve.Use(bSdkStartup.OAuthContextHandlerFunc(bSdkStartup.OAuthConfigStartup(r.Context))) //Here【请注意,我更新了 bamboo-base-go 库的初始化样式,这里是旧的写法,不过意思依然是表达到位的】在这里,主要优化地方有两个,第一个是允许直接环境变量硬配置或者选择启动阶段跑一次 .well-known 接口获取最新端点。第二个就是将这里的结构信息注入到上下文。
现在,已经把这部分初始化的内容最终返回一个带指针的 *oauth2.Config 注入上下文中了。那么在实际使用的时候要从上下文取出来才可以用。现在的 handler/auth.go 部分代码就可以修改为下面的样子。
1 | value, exists := ctx.Get(bSdkConst.CtxOAuthConfig) |
但是这样还是不够优雅,对于这个步骤 ctx.Get(...) 会返回两个参数,其中一个是 interface{} 以及一个 bool 是否存在,每次都要校验的话会很奇怪。但是主要初始化正确,那一般来说绝对不会报错。而且这里一旦报错就是跟配置有关系的。所以这里更应该使用 panic 直接产生恐慌比较合适。
但是这里是 handler 的位置,我觉得忽略 exists 又不太好(而且我在做 SDK),那怎么办?
只好再封装一层,让最后的 handler 部分调用用一行代码就可以拿到目标结果是最好的!
那在这里,就可以新建一个新的包是 utility (叫 util) 也没问题的。
1 | // utility/context.go |
为什么这里我使用 panic,首先这里是开发阶段的错误,并且在项目启动的时候我已经做了初始化阶段缺失参数导致无法启动,如果因为没有注入该内容导致 panic,gin 阶段也有 recover 不会导致程序崩溃。这里一定是服务器内部错误导致的。
现在,就是最终的结构样式了,来看看最终的 callback 成品吧(当前阶段的成品,因为我还没有处理 state,state 的处理应该是发起跳转的时候处理的,这里是回调部分)。
1 | // Callback 处理 OAuth2 登录回调请求 |
相比于最开始探索阶段来看,现在可复用的内容就非常多了。
做一个这一部分阶段的小总结。
我花了挺长的篇幅来写回调的这一部分的内容。我为什么选择回调,而不是选择登录的过程。登录的过程实际上来说非常简单,对于标准 OAuth2 来说,就是根据 AuthorizationEndpoint 端点去拼接 HTTP 地址用作 302 访问就可以实现目的了。这个很快,很容易实现。但是对初步项目结构化来说干不了什么。也就是他只是作为拼接(并且 Golang 社区中 oauth2 的 SDK 绝对可以帮你完成这个工作)约等于没有工作,而且后续开展也就是一定要做回调的接口。所以压根做不了抽象或者简化之类的内容。写出来的并不能算作工程实践类型的内容写到这里来。
相比于回调,回调部分绝对要拿到 code 请求一次 OAuth2 服务,虽然回调的阶段依然是 oauth2 SDK 完成的。但是他可以拿到用户令牌(正式点说法就是可以将外部系统能力转化为内部系统的能力,那么就很好对后续能力进行扩展)。也就是说我可以根据拿到的用户令牌直接去做我期望优先实现的接口,以及从这个角度来看相对是比登录过程是好抽离的。
现在我们站在大体上看一下。从原来最开始只是做了 handler/handler.go (或名字 new.go) 阶段去做结构体,到后面实现 handler/auth.go 实际回调业务代码。到后面完整的流程跑通后进行功能拆解, handler 只完成他的数据处理和逻辑引导工作,不涉及任何数据初始化或者上下文提取信息。只是干 handler 自己的事情。初始化部分交给了 startup ,常量直接构造在 constant 中,上下文工具直接写入 utility 中。整体看下来就会显得更加 “高内聚低耦合”。
并且初始化部分的内容和上下文工具,之类到时候其他接口是一定会使用到的。这里抽离出来是没问题的(这里的证据是可以看 golang oauth2 document 可以看到 type Config 下还存在其他的方法可以调用,那么抽离出来是绝对没问题的。他们功能不会在同一个接口内重合使用。不然 oauth2 SDK 作者可以再进一步结合一下的对不)。
那么,现在基础部分做完了,由于本次开发用的是 Codex,Codex 也有 /init 现在初步的结构已经出来了,所以可以进行初始化了。有了部分已有的代码当做示例,大部分他总结的内容大差不差不会错很多的。
登录部分
碎碎念:斯哈,这个 Codex 他的 Skill 怎么是全局的呢,怎么没有项目级别的 Skill。是我使用的有问题吗。算了,暂时先不用创建 Skill(按照 GPT-5.2[-Codex] 的心智模型初步判定能自己补全文件信息)。
碎碎念:看了一下 Developer 文档,有项目的,但是我用内置 skill-creator 的时候默认创建在用户那边,就变成全局的了。看文档知道怎么创建了。
下面一些就是 AI 写的了,这个相对来说就太简单了(我有必要展示吗…好像自己写更快,嘛算了,无所谓),只要说按照我写的规范来写就可以写一样的样子。这个没啥难度,就是试一试。
1 | // Login 处理 OAuth2 登录跳转请求 |
现在,开始完善这个体系制度,现在已经存在了登录部分和回调部分。对于 OAuth2 会害怕什么?我下面画一张图具体解释 OAuth2 登录过程的图(简化图,不含安全处理)

那么,根据这个图的处理过程。会出现 302 跳转,也就是用户界面不一定会停留在业务端所展示的前端界面,还可能跳转到 OAuth2 的前端界面。就算是本身的界面都会存在跨站攻击,更别说还需要跳转到 OAuth2 再回来。接收到 Query 参数后端就会访问到 OAuth2 站点。明显是不合规的。
所以,在业务端认证失效的部分又或者说直接未登录的身份下, 构造跳转链接跳转授权 的时候,在这里可以在业务端构造一个 state 防止跨站攻击(CSRF),同时引入 PKCE(挑战码),防止授权码在跨站或不可信环境中被捕获重新使用。
AI 解释:攻击者可在第三方站点构造一个指向业务端或 OAuth2 授权端点的跳转链接,诱导已登录用户点击。浏览器会自动携带用户的会话 Cookie,使授权请求在用户不知情的情况下被发起,从而可能导致错误的账号绑定、授权被劫持,或授权结果被攻击者利用。
我解释一下,就是用户已经可能存在已经在业务端或者 OAuth2 平台登录过了。然后另外一个平台可能藏了一个跳转到这个 OAuth2 授权的跳转,浏览器跳转发现是已经登录的用户,那就发放一次授权吧。用户就在不知情的时候完成了授权过程。
为什么要讲这一些。上面的地方是思考过程,因为有这一部分的思考过程才能继续抽离内容出来。为什么要抽离,上面的代码不是已经实现功能了吗?
哎呀,没错,确实是实现功能了。但是这里的风险相对来说还是很高的,如果引入了 state 以及 PKCE 那么我们就必须要传递的时候创建这一类数据(在 Login 阶段),然后在 Callback 阶段需要验证这个阶段。这一部分就属于逻辑和数据存储的地方了。如果继续往下写就会涉及到 handler 模块(逻辑)越界问题【handler 在做自己不该做的事情】。
上述内容就是讲述在最简单的 OAuth2 流程完毕后,如何进一步保证完善安全走向。现在就是要做这一类的内容。来理一下流程。
state 和 PKCE 的数据从哪里来?需要在构造登录的时候去创建这一类数据(数据创建来源?state 可以自定义,PKCE 有一套简单的算法,对于 golang oauth2 sdk 我认为是存在的,属于 OAuth2 技术规范内的,这俩正常应该都有的)。所以这个流程应该是 Login 阶段创建,Callback 阶段去认证。在这里逻辑链就闭环了。
我们要做什么,要做的在逻辑层处理如何创建这个的逻辑以及如何保存,以及接收到这个参数后如何从存储内获取下来进行验证比较。
那么现在我们可以创建 logic/oauth.go 这部分的内容,在这里面专门实现 create 以及 verify 就可以了。
扩展发散
诶,怎么现在就要开始写了呢?接下来的部分才是重点!还不能开始写,跟我一起娓娓道来。在这里你会开始非常发散思维,或者不叫这么说,反正会扩展很多(感觉被奸了一样,一直在被迫加入新的东西)。
碎碎念:不知道是不是这里是刚刚接触开发或者没开发很长一段时间的伙伴会遇到的问题。简单的东西可以做,基本的 MVVC 架构或者三层架构的内容(从 Java 角度来说)。我可以把所有东西都放在 Service 啊,或者一些不那么重要要隔离非常清楚的东西就 controller,service,dao 之类的随便放一下。最后项目都可以完成,而且都可以完成工作。但是一旦项目稍微大一些,或者等待几个月开始扩展的时候,亦或说自己写的一些东西开始让 AI 介入的时候,就很容易发现我的东西不好扩展了,怎么感觉怎么写都很乱。不知道是不是这样的感觉(每个人应该都不一样)。
现在坐下来开始总结。第一步先总结现有的内容,然后我再尝试把我认知里的经验或者常见的写法转换为因果关系写出来。这么做一定有他的理由以及怎么想到的也是有理由的(只是可能写的比较多,相对这里知道这个方案是可行的而且比较方便就可以直接做。而且目前方案也有很多根据我自己的多次实验验证后相对来说至少是我认为比较方便的一种写法)。
现在看看前面完成了什么,我们完成了最基础的 callback 部分以及 login 部分。其中 callback 部分是用作回调处理。这里的回调是从 OAuth2 换取到 code 后在这里将用户换到的 code 换取令牌的任务。当拿到令牌就可以将外部逻辑本地化,就可以对接其他的内容(也就是说后续其实可以选择不实现 login 而优先实现其他的内容【这里根据业务逻辑或者功能决定,并没有绝对的优先级】)。接着我们完成了 login 的部分,这里主要就是构造授权登录的地址进行 302 跳转,跳转到 OAuth2 的登录平台。
上面解析完了业务结构,那么从代码层级结构来解构一下。目前的目录结构从最先的 handler 作为核心逐步拆解为 startup, constant, utility, route 这些属于什么。handler 依然是最大的大头(作为路由后的入口,进行数据验证和数据流逻辑链导向,而 logic 负责单个逻辑固然没有 handler 大头【这里指的不是重要性,对于项目整体来说,都是很重要的,缺一不可】),而拆解部分是可重复使用或便捷的功能,所以这里依然不属于核心拆分点。不涉及逻辑分级数据分级之类的内容。
那么复盘一下目前完成的阶段性工作以及这个阶段性工作后面需要怎么发展。
目前完成了 OAuth2 登录的 login 跳转和 callback 换取令牌的对接。这里只是最简单的对接,因为 callback 的 Exchange 所提交的 code 有两种含义,第一含义就是从 login 换取的临时 code,第二个含义是在登录阶段从 code 换取的 AT 和 RT。虽然我在这里经常会提到 AT(AccessToken) 以及 RT(RefreshToken),但是到底有什么用?为什么需要两个令牌?这里实际上对用户有效的令牌是 AT,这里是授权令牌,允许用户执行登录权限内的任何操作,代表用户有效的是 AT 完成的工作。如果 AT 过期了那么当前用户应该判作非授权阶段。但是每次过期都要跳转回到 OAuth2 平台是不是不太合理或者有点抽象?那么 RT 的作用就在这(一般来说 AT 的有效期可能一小时或者几小时,RT 长达几天或几个月不等),RT 还没有过期,所以在 callback 阶段,是不是可以用还没有过期的 RT 来当做 code 重新换取一次新的 AT 和 RT 呢?这样可以直接授权而不需要跳转了。
上述部分就是 callback 的需要改进的地方。现在再来看到 login 阶段和 callback 阶段这个过程中,如果我需要加入 state 和 PKCE 的情况下。(暂停)我先解释一下 state 和 PKCE 相关的关系,为什么需要它们,以及为什么他们都是可选,但是建议添加。

他的目的,都是保证你拿到的 code 是正规合法的,而不存在入侵的可能性。用户的东西永远是不可以相信滴。
OK~,解释完了。那么根据这个逻辑,可以知道 state 和 PKCE 的信息,是要在服务器内驻留的。那么如何驻留,可以采取的方案有两种。因为只是几分钟的临时驻留,可以选择直接程序内变量做一个 Map 或者说直接 Redis 来做一个都可以。在这里我选择了用 Redis(优先保证我自己整体的体系结构,再考虑其他,毕竟我的那个登录系统也没说完全对外开放可以申请,虽然做了商户模式)。
现在仔细想一下,既然我的 state 和 PKCE 已经确定要持久化了,而且需要进行数据类型的持久化。对于多层架构来做职责区分,每一个层级有自己的事情,不要层级越界。那么对于层级功能来分,现在 handler 部分一定不能出现跟数据处理有关的,也就是 handler 不能直接调用 repository 的内容。需要通过 logic 流转后调用 repository 避免跨层级越界。
为什么?永远不能相信用户输入的任何信息,虽然用户信息的处理是 handler 完成的,但是用户输入的详细数据或者内容是不一定可以保证使用的。并且不一定一个 handler 接口只会对应一个 logic,有的时候一个 logic 的形参输入来源于 handler 调度的上一个 logic。单个 logic 实现最小化功能点。例如 VerifyPassword 只完成密码校验最多可以删除一下缓存的内容。而不能说在用户登录下 VerifyPassword 完成了密码验证,还要完成最终的授权码颁发,属于逻辑功能越界。
所以,形参的输入也是不能保证其他 logic 返回完全一致,在单个 logic 内必要的时候需要做内部鉴权验证,避免在 go 出现 nil 的情况或者无理由的空值情况(开发者也会缺漏,有时候缺漏可以让另外一个 logic 做至少可以熔断的兜底策略)。
现在再来仔细看,我的 state 和 PKCE,那么在哪里生成呢?交给 repository 是不对的,repository 只对数据进行格式化整理为可以存储的内容,以及取出来/更新。对于新建一个 state 和 PKCE 以及一些随机 cookie 值绑定前端用的,只能交给 logic 层。而生成的内容,例如 state 的生成策略是否可以关系到 cookie 的值策略?还是说直接存储 redis。
什么意思?例如我 state 生成随机值 32 位,我可以当做 AES 的加密位数按照当前的时间加密得到 cookie 的信息。最终 Verify 部分解密,传入 state 信息和 cookie 信息进行反解,得出的时间是否相差过大(如超过 15 分钟)拒绝授权。这些逻辑都是可选的,而且如果单独写在 logic 是非常好扩展的,并且不需要修改其他地方的代码,因为 repository 只会接受三个参数第一 PKCE 的校验信息和 cookie 信息存储,state 当做 redis 键值对,取数据的时候,可以做 cookie 原值校验,校验 AES 解密之类的。
【小插曲】哎呀,写到这里的时候,我在看 gin 的官方文档,然后我发现我搞错一件事情。
碎碎念:跟认识的人打了一天 Mindustry (游戏),努力回忆打游戏抛弃掉的东西中……
我们言归正传。在这里,我说明白了为什么要加入 state 和 PKCE 挑战码的部分,以及加入后应该如何进行持久化。在这里我们目前就考虑这一个因素,在完成这一个功能后再考虑其他一些被动因素(例如,最终的 callback 用户最终是拿到令牌信息的,业务端是否需要存储令牌信息等等,单次实现不要引入过多因素。不要害怕因为完成手头功能因为添加功能导致部分代码要删除或者修改逻辑而麻烦,本身写代码就是很麻烦的事情)。
碎碎念:站在一个简单的开发者角度来说,只想上面的实现完毕后就直接开始干东西,这一类攻击现在少了很多很多,而且不需要一定要弄到那么抽象(当然这只是想法)。毕竟各种用户都有,奇奇怪怪的也有,鬼知道能遇到什么东西呢,稍微备一下也好。并且 SDK 可以不用,但不能没有。
内容补全
对扩展发散的内容进行总结一下。也就是我们现在的目标是实现 state 和 callback 中间数据链验证机制(加入 state 和 PKCE 机制)
对于这个过程,需要顺着这个逻辑从前往后写。那么就是从 login 开始写,完成登录部分(登录部分是创建和存储),之后才是获取和验证(这是完整的逻辑链关系)。
那么从开始写 Login 部分的时候我们的第一目标是实现其 logic 的功能地方。
碎碎念[1]:这里插一嘴,虽然我说了是从 logic 开始写,但是其数据来源也就是 logic 就收的形参应当是来自于 handler(并且这一句话并不绝对,是相对性说来自于 handler 的)。为什么?这里在我认为里面,需要进行结构权衡,权衡这个功能是否相对通用。
碎碎念[2]:什么是相对通用?这个 logic 方法是否很有可能在其他地方第二次用到(复用),就是相对通用。如果是相对专用(例如这里说的 state 和 PKCE 部分只会出现在 OAuth2 授权,其他地方并不会出现,但是我这里提一嘴目的是在实现 logic 的时候确实需要考虑到这个),那么你的 logic 实现就要多数参考 handler 的输入内容。
碎碎念[3]:为什么说这个?因为在业务实现中 handler 是及其业务性质的,只要属于业务性质就是专有(专一)性,不能做到相对的全面性。如果多数 logic 最终实现都是相对专用,那不能叫解耦,只是文件切分后好管理一下而已。隐式的耦合性还是非常严重。为什么?因为在这个阶段你考虑 handler 进去就会把当前业务考虑进去,在多数可能下如果其他接口需要复用的时候,他不需要这个内容就会凭空多出来这个“业务参数”。属于干扰项,就算给 AI 他的很有可能的解决方案是再写一个 logic 去做(屎山就开始有初步样子了)。
如何避免?在设计 logic 层单一功能函数(方法)的时候,优先考虑当前 logic 他需要实现什么,那么最小的界定范围就是他应该实现的内容,最大的界定范围是允许业务层的通用业务引入,例如 userInfo 之类这种整个项目任何接口大概率都有的一些业务之类的【举例】。剩下多余的内容,更应该考虑是否需要单独创建 logic 或者说是否需要修改 handler 的 requestBody(Param/Path…) 的内容。如果不这么做,我上面说的 handler 作为 logic 的引导那不是没有作用吗?handler 只做路由接收处理数据校验不做 logic 数据流转发那不是没啥意思,功能太少了,不如不要他数据处理交给 route 就好了,反正就单一 logic 不是吗?
Login实现
我们来看 logic 内容(就算完全按照顺序写,也得把这个函数实现吧),我们需要实现创建 Create
1 | func (l *OAuthLogic) Create(ctx *gin.Context) (string, *xError.Error) { |
看不懂也不需要担心,只需要理解其语义过程。例如 l.data.Store(...) 意思就是 data -> Store|数据->存储 的过程,Verifier 是 PKCE 所需要的;按照语义理解就可以【我在这里并不是教你怎么写代码,主要是学习思路以及思想,我在这个文章不会提强代码相关的逻辑链】。
Verify 逻辑:由于这里在 Login 存储逻辑中,是采用 STATE 当做缓存键的。能够通过 STATE 取出来数据,代表数据是匹配的(初步认为),所以在这里拿出缓存记录的 Verifier 值信息,当做 OAuth2 的 VerifierOption 参数传递用于向 OAuth2 校验这个挑战码是否正确。如果是正确的,那么将会最终授权成功返回令牌信息。
现在就站在正在写这个功能来看,现在只是单独写完了 logic 部分,这里的逻辑已经完成了(就算如果是你真正在写代码有 BUG 暂时不管,你这里认为写完了就是写完了)。那么你的后续部分就是数据处理,在这里的部分就是你写完了 logic 的逻辑走向,但是没有处理数据(嘛,我前面也提到了分层架构,所以 logic 尽量不出现数据操作的内容,自然需要交给 repository 层来处理),在这里我就展示一下 Store(...) 就可以了,数据处理部分就是获取形参数据处理然后存储的这个过程,其他内容都是大同小异的。
1 | func (r *OAuthRepo) Store(ctx *gin.Context, state string, verifier string) *xError.Error { |
诶,这里你是不是又有疑问了?在 logic 层部分,我不是确认这个数据绝对非空了吗,为什么在这里我还要判断?这里会涉及到两个问题,第一个是开发者问题,即我们自己(本 SDK 项目),万一后面部分你有其他地方也调用了这个 Repository 层的 OAuthRepo 接收者的方法呢(虽然对于这个方法来说极小概率在外部出现,他的实际功能很大程度决定了只能在这里使用【那么又说回来了,我做管理员的审计接口呗/doge「举个例子,正常这个过程也不应该做审计」】;第二个问题就是这属于一个 SDK,并且是对外暴露的 SDK 内容。那么业务端有可能会进行调用,这里做了数据校验是用于兜底。如果是数据为空的情况直接存储,一样是可以存储进去的!(虽然我这里并没有用到数据库存储,但是实际上数据库存储也是一样的)
在这里,实际上他的工作就是对数据进行最终校验,整理数据格式为数据表可存储或者缓存可存储格式进行操作。
对于
r.cache.SetAllStruct(...)这个我就不进行展示了,实际上跟 Repository 差不多的,在这里有他是因为可以对整个生命周期进行管理,我在我的 bamboo-base-go 脚手架明确定义了如何写的内容,并且需要实现这个接口需要实现全部增删改查的过程。在这里,你可以查阅文档 竹简文档 看详细实现方法。
最后回到 handler 调用一下,整个链路就完成了他的工作。
1 | func (h *AuthHandler) Login(ctx *gin.Context) { |
这个时候你可能会问,怎么多了一个这个鬼玩意 oauthLogic.BuildURL(ctx, oAuth) 这个东西不需要管它,他实际上就是最初原本的 oauth2 的内容进行了封装,让 handler 部分专注于他自己的数据校验和数据逻辑流引导。
Callback实现
碎碎念:Ummm,快过年了,在广东被爸妈抓去干这干那大扫除。思绪经常被打断,偶尔还要花时间沉思一下要怎么去做。沉思

我们烧烤一下他需要什么/doge,现在已经实现了 Login 的核心内容了,那么对于 Callback 需要什么:验证!
由于 Login 部分已经写出来了 Verify 的逻辑,所以直接写出来 handler 逻辑即可
1 | func (h *AuthHandler) Callback(ctx *gin.Context) { |
这里同理,构造最终的 Exchange 请求 oauth2 的内容进行了 oauthLogic.Exchange(ctx, getCode, oAuth.Verifier) 一层的封装而已。
在这里我进行了第二步简化的功能,因为基本可以确定对于这一个 logic 来说,一定属于专有性质的 logic。几乎不可能被其他服务进行复用调用,所以他基本可以断定就是专有性质使用的。那么原先抽离后还剩下的 oauth 相关的 Exchange 以及登录跳转的构建,都可以一并交给逻辑层来处理。
请注意呀,我这里并没有加入获取用户信息 userinfo 的接口,我还没有进行对接。对于 SDK 来说,这个 handler 已经完成了他的使命,这一部分就交给 middleware 进行处理。不然对于 Callback 部分掺入太多内容,导致后续如果修改内容很可能造成 SDK 的破坏性修改,所以我尽量不考虑在这里添加这个直接读取 userinfo 然后拿用户信息存储的这个工作步骤。
并且我在 middleware 也会进行斟酌,因为业务端所需要的用户信息并不是一个固定信息,OAuth2 返回的虽然是固定的,但是数据结构存储的时候业务自己的用户数据往往会加入很多额外的数据信息。所以是否需要直接兑换,对我来说,应该只需要写好对接一个 logic 层或者封装一个 api 层对外调用的即可。不需要封装进入 middleware 或者说 handler。
中间件处理
在这里,其实才是 SDK 实际需要实现的内容,更应该说他应该是这个 SDK 的核心或者说本质。没有这个中间件并不好看展后续的业务。
为什么?中间件负责鉴权,鉴权后才允许继续处理。若没有鉴权这个步骤业务端就要自己实现,那么一拖再拖 SDK 的进度是越来越慢的,而且这样最后(临时)写完的内容会高度跟业务端定死,导致 SDK 完成也并不好修改。所以这篇也是为什么是我工程体系文章的番外,因为他属于非做不可。并且在这里可以解释我的 bamboo-base-go 脚手架和这个 SDK 的行为动机。
算是补全了我想解释 bamboo-base-go 脚手架如何设计的体现。
ねね~(来看~),现在看中间件,中间件需要什么?他承担什么样的角色。亦或说什么是中间件?
这里我就不说那种听上去非常官方的词汇,什么洋葱类型,先入后出这类的词,需要看看官方文档,我这里讲的通俗一些。就是有人过来干扰你(亦或者协助你),大学应该不会,初高中啊总有喜欢传纸条的,传纸条时候就是从 A 同学刀 B 同学,中间经历了不少同学,每一个同学就是中间件,负责中间给你传递信息用的。要是是你跟你喜欢的人传纸条,中间的人还喜欢瞄一眼看看写了什么,想搞你还要扣留一下,然后给你纸条加几句话搞你一下再往下传。这里中间同学身份就是中间件。
那么在这里,中间件的动机就是检查 A 同学(用户)访问你的具体 handler 业务(B 同学),你的访问是否合法(让我康康你的纸条有没有在聊涩涩,诶怎么在聊官话,没问题没意思过去吧【放行】;或者,诶哟不行,你说这个我来兴趣了,我要康,你就别想着给他们康了,这是我的【拦截】),那么最终 B 同学,天天期待的涩涩东西是一点没看着,看的全是官话(哈哈哈,让我来保护你幼小的心灵)。
这么来看,中间件,他需要什么?他需要验证,检查用户身份是否有效,如果有效就放行,没有就拦截下来。那么中间件应该要什么,我们依然按照最小化原则。他就应该实现验证这一步,那么验证什么?还记得前面提到的 AT 和 RT 吗(AccessToken 和 RefreshToken),在这里的 AT 就是用户令牌信息,需要验证他是否有效。后续是否需要缓存令牌信息还是失效如何处理,都应该是实现这个基础上进行的。
1 | func CheckAuth(ctx context.Context) gin.HandlerFunc { |
在这里我解释一下代码,因为 Go 是支持函数返回(闭包),由于这部分的内容会交给 (gin.IRoute).Use([Middleware]......) 这里进行使用,也就是实际访问执行会执行中间件的内容,然后再走到 handler 之类的。这样的话,每一次访问都要 bSdkLogic.NewOAuth(db, rdb) 新建这一个实例,并不友好,有额外的资源开销,所以在这里程序启动的时候由于会执行 .Use 部分,相当于初始化步骤,在初始化的时候进行初始操作,然后返回闭包函数整体作为中间件使用的内容。这样后续就可以持续使用这个已有的实例,而不是使用的时候直接创建一个。
在这里中间件的逻辑就是过程是,获取请求头所所包含的 X-Access-Token 信息下来,进行验证其登录是否有效,如果有效则放行继续,过期了返回 HTTP 401 输出 TOKEN_EXPIRED 后前端跳转到 Callback 部分重新通过 RT 分发新的授权信息。
AI的介入
在这里,可以让 AI 进行介入一下了。因为最重要最核心的内容 “结构” ,已经很清晰了,对于 2026 年的 AI 来说已经有能力解决问题了(除非遇到什么非常规逻辑),就算心智模型比较差的尽可能描述完整基本都可以从功能角度出发的逻辑清楚的内容了。
这次开发用的是 Codex ,啊↗啊↘啊→毕竟 A\ 发力了,好多中转站的 claude 活的不咋样,GLM 呢(毕竟不是上班,还不想那么累自己),就试试现在很多公益中转站的 gpt-5.2/gpt-5.3 作为主力用一下,都属于 AI Agent CLI 类型,差的不会很多。
那么 AI 需要介入什么呢?我们现在大概可以想一下缺少什么东西。目前对于标准的 OAuth2 来说,缺少获取 userinfo 的信息,但是呢 userinfo 不能作为主要对外使用的内容(什么意思?就是业务端很可能有自己的 userinfo 扩展,所以不能写入 handler 直接作为接口返回),应该写入 logic 层进行获取就可以了。这是其中一个比较主要的,剩下属于扩展项,即对于扩展的 OAuth2 接口来说有 Token introspect 用于检查令牌的有效期还有多久的接口,虽然可能用到的地方不多,但是作为 SDK 有扩展的可以先提供出来。
主要的内容就基本完成了,剩下需要维护的就是一些细节的地方。
1 | $skill-creator 你需要参考网站 https://developers.openai.com/codex/skills |


大概意思就是,通过前面的部分,项目已经大概结构确定下来了,后续的部分都是构造 logic 部分内容,所以这里写了一个 logic-builder 的 SKILL,后续就可以描述我的功能内容让 codex 帮我实现就可以了。
完善剩余内容
用户信息
对于登录的 SDK 现在已经完成了核心的内容,进行登录和换取登录信息。本身来说已经完成它自己所拥有的内容了。对于用户信息,属于高度依赖但并不是必须项。
不过他本身就属于 OAuth2 获取 userinfo 的内容,不实现又不太行,做成完整的路由体系不行,因为这里获取的 userinfo 是 OAuth2 获取的,业务端绝大部分情况还需要二次存储其他数据信息,所以不能直接构造成路由交给前端进行处理。
在这里思考后,处理逻辑转向变为了只设计 logic 后留口子,供给业务进行 handler 逻辑流对接处理。
1 | $logic-builder 你需要看到文件夹 /logic 下,你现在需要创建一个新的文件叫做 business.go 业务,内部结构体叫做 BusinessLogic。 |
最后实际效果可以参考一下 github beacon-sso-sdk #f4f27fb5 这里的内容,我就不张贴代码了。在这里可以对比一下这里的写出来的风格和我已有的风格基本是一样的,并且执行出来也有兜底不会越界,在这里就达到了。
查看和注销
下面的提示词是继续上面的提示词后继续写的内容
1 | 现在,请你看到 startup/oauth.go 这个位置,对于 .well-known 部分需要添加获取 introspection_endpoint 以及 revocation_endpoint 端点,并且 constant/environment.go 部分也需要添加对应的变量信息。 |
最终效果可以查看 github beacon-sso-sdk #181ee51c 就可以啦。
后续
其实到这里最基础的部分的 SDK 已经完成了。
对了,上述的两个提交是根据提示词做完而已,要完成符合工程化和性能保证后续还有提交,例如「查看和注销」部分,如果业务端没有注意到经常调用这个方法,导致请求量激增,这个并不合理。适当情况应该还需要加入 10-30s 左右的缓存,不要过高请求 OAuth2 源服务器导致请求激增。
不过这个时候就有一些疑问了。诶诶诶,这里不就是对 OAuth2 部分再次进行 gin 框架的封装吗(脚手架)?对的没错,目前阶段是这样的。主要是因为我的 OAuth2 自己的程序部分主要完成了这些,以及一些扩展功能模块。但是扩展功能模块还没有做出来 gRPC 对外开放的接口,所以我还没办法在这个 SDK 集成进去,暂时写不了。
但是对于我后续需要做的项目来说,暂时用不到,但是这些内容是必须的,所以做到这里(点到为止即可)。
对于后续的 gRPC 对接也是的,我只需要设计好初始一些关键节点,后面就可以做成类似上述的 SKILL。例如,请根据这个 .proto 内容后在 proto/ 内实现 gRPC 的对接获取转化为 bSdkModels 输出,最终可以构建为 bSdkProto.Client() 拿到一个新客户端后直接例如 bSdkProto.Client().Announcement().List() 获取公告列表这样的调用方式,就可以了。
碎碎念:啊,如果你对最前面自己设计的内容并没有底气,也就是 callback 和 login 部分因为纯属自己设计后自己实现的,对于这一类刚刚设计好的,并且后续我已经用 AI 写了几个功能组件了,那么这个项目就有相对固定的结构样式,属你熟悉的范围内的。这个时候 AI 是清楚你的项目是做什么的,是有能力反哺回去维护一下你刚刚写好的 callback 和 login 部分。
在这里我提一嘴,上述展示的代码并非最终代码,只是写了以及修改后认为合适后展示出来,实际上的考虑会更多,代码相对会复杂很多。避免引入太多因素容易理不清楚内容,所以文章部分地方如果可以发现是缺失一定逻辑的,若有需要可以直接看 Github 全部源码。
管道及结构解释
在这里我有必要解释一下对于 Go 项目的结构和我的项目 context.Context 是如何进行流转的,又是如何从需要的时候拿出来需要的内容。不然不容易理解我文章说的初始化部分以及为什么数据流向是这么走的。
我这里以我的结构设计方案来画示例图,来展示实际的管道流程是如何进行的

这张图表示的是项目完全启动成功(黑色和绿色部分)后的 context.Context 阶段内容。红色部分是当有请求的时候,外部请求进入后,开始访问中间件自动将已有的 context.Context 注入到 http.Request->context.Context 中。
下面图是具体访问开始后的链路路径信息。

这样构造后的链路,就能将初始化的上下文以附加的方式加入到 http.Request 中的 context.Context 中,实现具体单个请求可以通过上下文工具进行读取,实现类似于 SpringBoot Bean 注入的方式使用。并且这本身也是 go context 特性(请注意我在这里没有细说 context 相关注入一些的性能问题,如果要写会单独写一篇文章,这里水挺深的,溺水过不少次了我)。
















