接手这套平台的时候,最大的一个痛点就是账号体系乱七八糟。GitPuk是我们团队内部的一套代码托管与交付平台,上面跑着仓库管理、CI/CD流水线、制品下载、工单协同好几个模块,每个模块要么自己建一套账号,要么跟域账号部分打通,密码策略不统一,离职清理也不彻底。后来我们决定把GitPuk整体接入自研的统一认证中心soular,也就是标题里说的“统一登入”,一次性解决身份分散、重复登录、审计缺失这一连串问题。
整个集成过程说难也不算难,但踩的坑不少,尤其是Git远程操作和Web端登录态的联动,花了挺多时间才彻底理清。这篇文章把整个方案设计、协议选型、代码改造、网关配置、排障经验完整记录下来,给正在做统一登录接入的团队一个参考。无论你是后端、前端、运维还是架构师,只要公司里有多个系统要打通账号,这篇都能派上用场。
1. 整体思路与方案设计
1.1 为什么必须做统一登录,而不是继续打补丁
多系统各自维护账号这种事,一开始看着没什么,等系统多了就是灾难。每个系统一套密码,用户记不住就只能到处找回;有的系统密码要求高,有的几乎没有限制,弱口令和撞库风险直接翻倍;同事离职以后,你不知道他到底在多少个系统里还有账号,挨个清理几乎不可能。更麻烦的是,如果某个系统被拖库拿到密码,攻击者拿这套用户名密码去其他系统撞,一连串服务全都跟着沦陷。
我在接入前做过一次统计,GitPuk相关的账号数据分散在三套数据源里:一套是内部LDAP,一套是各模块自己建的表,还有一套是曾经给外部合作方开临时账号时手工维护的Excel。三套数据的用户ID还不一致,一个人可能有三个不同标识,日志审计根本看不出他到底干了什么。这种前提下再继续打补丁,比如“让每个模块都去同步LDAP”,只会让问题更复杂。
统一登录的本质不是把所有密码收到一个库里就完事,而是把“身份”从具体业务系统里抽出来,变成一个独立的域。业务系统只需要知道“这个用户是谁、有哪些基础属性”,至于密码怎么校验、会话怎么管理、账号怎么冻结,全都交给认证中心。GitPuk接入soular以后,业务模块里不再存任何密码,只存soular返回的用户唯一标识,认证和授权彻底解耦。
1.2 GitPuk和soular的分工与边界
很多团队做SSO失败,就是因为边界没划清楚,认证中心什么都管,业务系统又什么都想自己控制,最后两边打架。
我们内部定的边界只有三条:
- soular是唯一的身份提供方,负责登录、注销、账号状态、MFA、密码策略。
- GitPuk是业务方,负责根据soular给的用户标识做权限判断,决定用户能不能看某个仓库、能不能触发某个流水线。
- 双方只通过标准协议通信,不直接读写对方的数据库。
只有一条数据是双方必须对齐的:soular的用户主键(subject/UID)和GitPuk内部的用户ID映射。GitPuk这边有个user_profile表,记录soular_uid和本地业务用户ID的对应关系,首次登录时自动创建映射,后续都靠这个映射做关联。这条边界帮我避免了一个大坑:如果业务系统直接用用户名做关联,一旦用户在soular里改了用户名,整个系统的历史数据就全断了。
1.3 认证协议选型:为什么是OAuth2 + OIDC
协议选型是这次改造里最关键的决策,没有之一。当时摆在面前的选项有CAS、SAML、OAuth2 + OIDC,还有人提议“干脆自己发一个加密token算了”。
自己发明token的方案第一个被否掉。身份认证领域的安全细节太多,稍不留神就会留出漏洞,而且后面的系统再要接入就很难统一,每个人都要对着你那套私有协议重新开发。CAS和SAML都是成熟方案,但我们在调研时发现一个现实问题:GitPuk不仅有Web端,还有API和Git命令行场景,SAML主要面向浏览器重定向,处理API认证和令牌刷新比较麻烦。OAuth2天然支持授权码、客户端凭据、刷新令牌等多种流程,OIDC又在OAuth2之上补了id_token,专门解决“这个用户到底是谁”的问题。换句话说,OAuth2负责发令牌,OIDC负责让令牌里有可验证的用户身份信息,两个配合正好覆盖Web、API、命令行三类场景。
具体用到的是授权码模式(Authorization Code Grant)。流程上要先跟soular约定好:GitPuk前端把用户重定向到soular的授权页,用户输入账号密码(或者扫码)完成后,soular回调GitPuk的callback地址,带上授权码;后端再用授权码去换access_token、refresh_token和id_token。授权码模式最大的优势是token不会暴露在浏览器地址栏里, access_token始终只出现在后端到认证中心的请求中,前端拿不到密钥,安全性明显好于隐式模式。
1.4 统一登录改造涉及哪些子系统
接入soular之前要先盘一下自己的家底。GitPuk这边的改造面包含四个部分:
第一是前端登录逻辑。原来的登录页、注册页、找回密码页面全部下掉,替代方案是“未登录时直接302到soular授权页”。前端几乎不用写认证逻辑,只负责生成state参数和承接回调后的跳转。
第二是后端服务。所有需要登录的接口从原来的“本地校验Session”改成“解析soular的token获取用户信息”,同时保留部分接口的匿名白名单。
第三是网关。网关承担了最重的活:统一做会话校验、匿名接口放行、把用户标识注入请求头。这样后端服务不用每个都接soular的SDK,改造量小了很多。
第四是Git操作。Git本身不是Web应用,浏览器里的登录态它不认。我们做了两套适配:HTTPS仓库地址走token凭据,SSH仓库地址走公钥映射。具体细节后面专门讲。
这四块改造里,最容易低估的是第四块。很多人以为Web端登录打通就算完成,结果用户一执行git clone就开始弹窗输密码,体验瞬间回到解放前。
2. 核心细节与关键原理
2.1 soular的核心组件
soular作为自研认证中心,内部结构并不复杂,但三个组件我们必须完全搞清楚,因为集成代码要跟它们分别打交道。
auth-server是核心认证服务,负责颁发授权码、发放token、校验token、提供用户信息接口。所有协议相关的端点都挂在它下面,比如/oauth/authorize、/oauth/token、/userinfo。联调时主要看这个服务的日志。
client-sdk是给业务方用的接入库。GitPuk是Java技术栈,所以用的是soular提供的Java SDK。SDK封装了“发起授权跳转”、“解析回调参数”、“用授权码换token”、“校验id_token”、“缓存token”、“自动刷新token”这些全套动作。开始我一直以为SDK会非常复杂,结果核心API就那么几个,一个OAuthClient对象加上三四个方法就覆盖了Web端和API端的所有场景。
admin-console是管理后台,用来注册应用、管理用户、绑定公钥、查看授权记录。GitPuk接入的第一步就是在上面创建一个应用,拿到client_id和client_secret。
理解这三个组件的关系,可以类比成“酒店前台、房卡、订房系统”的关系:auth-server是前台,负责核对身份;client-sdk是房卡,业务系统拿着它才能开门;admin-console是订房后台,先把房间信息注册好,前台才知道该给谁开门。
2.2 授权码流程拆解
把GitPuk接入后的完整登录流程画一遍,你会发现大部分登录问题都出在下面某个环节衔接不上。
第一步,用户访问GitPuk的任意受保护页面,网关发现没有会话,返回302,Location指向soular/oauth/authorize,并带上client_id、redirect_uri、scope、state这几个参数。这一步要注意state必须是一个随机值,而且前后端至少要有一方能保存它,后面回调时要拿它做比对。
第二步,soular渲染登录页,用户输入账号密码或者扫码,认证通过后auth-server生成一个一次性授权码code。这个code非常短命,默认两分钟有效,且只能成功换取一次token。
第三步,浏览器被302重定向回GitPuk的callback地址,URL是/oauth/callback?code=xxx&state=xxx。GitPuk后端收到请求后,先校验state是否和之前发出的值一致,不一致就终止流程,防止CSRF攻击。
第四步,后端拿着code、client_id、client_secret向soular的/oauth/token端点发起POST请求,换access_token、refresh_token和id_token。这一步是后端到后端,密钥不会出现在浏览器里。
第五步,后端解析id_token中的subject、name等字段,确认用户身份,建立GitPuk本地会话,种下session cookie,然后重定向回首页。以后用户再访问其他模块,网关只认这个本地会话。
整个流程最容易被忽略的是第二步和第四步之间的时间窗口。如果用户登录完后拖了很久才完成回调,code可能就过期了,表现出来就是“明明输对了密码却登录失败”。这类问题要靠日志才能快速定位。
2.3 关键参数速查
集成过程中我们会反复接触一堆OAuth参数,新同学经常搞混。我整理了一张速查表,每个参数在什么时候出现、该放哪里、不能放哪里,都写在里面。
| 参数 | 用途 | 出现位置 | 注意事项 |
|---|---|---|---|
| client_id | 应用标识,相当于业务的“用户名” | 前端跳转URL、后端换token请求 | 可以出现在前端,不怕暴露 |
| client_secret | 应用密钥,相当于业务的“密码” | 仅后端换token请求 | 绝对禁止出现在前端代码、日志、URL上 |
| redirect_uri | 授权完成后的回调地址 | 前端跳转URL、soular配置、回调请求 | 必须与soular注册的完全一致 |
| scope | 请求的授权范围 | 前端跳转URL | 按最小权限原则申请 |
| state | 防CSRF随机串 | 跳转URL、回调URL | 必须校验,不能跳过 |
| code | 一次性授权码 | 回调URL | 只能换一次,过期时间极短 |
| access_token | 访问资源用的令牌 | 后端请求头 | 有有效期,过期后要用refresh_token刷新 |
| refresh_token | 刷新令牌 | 仅后端保存 | 长期有效,泄露风险极高 |
| id_token | 用户身份声明,JWT格式 | 后端解析 | 用它拿用户ID,不要拿它调API |
这张表贴在工位旁边,排查问题的时候先对照一遍,能省不少时间。尤其是client_secret,我们曾经在某个同事的调试代码里见过它被打到日志里,幸好当时是测试环境,如果在生产环境,等于把整个系统的钥匙交出去了。
2.4 网关层如何统一拦截
很多团队做SSO接入时踩的最大的坑就是让每个后端服务都自己去对接认证中心,结果每个服务都引入SDK、都写一遍回调处理,改造量巨大且很容易出现实现不一致。我们这次走的是网关统一鉴权的路子,GitPuk的后端服务几乎不用改逻辑。
网关用的是Nginx + Lua,核心思路是这样的:受保护路径上开启一个内部子请求,拿当前会话cookie去soular的会话校验端点问一次“这个人登没登录”。校验通过,网关把soular返回的用户标识塞进请求头,比如X-User-Id、X-User-Name,再转发给后端服务。后端服务不解析token、不看cookie,只读这两个请求头,就知道当前是谁。
这么做有几个明显的好处。新增后端服务的时候不需要再接入一遍认证sdk;token刷新、会话延长这种事情只需要在网关处理一次;用户标识的注入格式是统一的,日志也好查。代价是网关多一跳内部请求,但实测消耗极低,在可接受范围内。
配置上有两个容易踩的坑。一是callback路径必须放行,否则用户在soular登录完回调到GitPuk时又被网关拦回soular,形成死循环。二是匿名接口要单独维护白名单,不能在网关上把所有GET请求都放行,否则很多业务数据就裸奔了。
2.5 Git远程操作如何联动
统一登录的最终体验目标,是用户在GitPuk网页上登录完之后,命令行里git clone、git push也能顺滑地工作,不再弹窗问密码。这里有两个不同的实现路径。
HTTPS方式下,用户执行Git操作时,Git会把用户名和密码交到git credential机制手里。我们借用了这个机制,开发了一个小的凭据助手,让Git在需要凭据时先去本地找soular下发的access_token并作为密码发送。只要access_token没过期,用户就完全不用输入任何东西。如果过期了,凭据助手会提示用户重新跑到网页上点一次“重新授权”,然后刷新本地token。
SSH方式下,流程更简单。用户在soular管理后台里上传自己的SSH公钥,GitPuk的SSH服务收到连接后,从公钥找到对应的soular用户,直接映射成本地用户。SSH的密钥本身天然比用户名密码更安全,适合CI/CD这种无人值守的场景。
两种方式可以同时支持,但要注意HTTPS和SSH的“登录身份”得能对应到同一个soular用户ID。我们自己就遇到过用户网页上正常,却用另一个账号的SSH钥匙推送代码的情况,最后靠仓库审计日志才发现。统一登录不是只管登录那一刻,身份的统一才是关键。
3. 实操过程与核心环节实现
3.1 环境准备与信息确认
动手改造之前,我建议先把下面这张单子准备齐全,避免联调到一半四处找信息。
| 准备项 | 示例 | 说明 |
|---|---|---|
| soular服务地址 | https://soular.example.com | 生产环境地址,开发环境用另一套 |
| soular管理后台地址 | https://soular-admin.example.com | 用来注册应用、管理用户 |
| GitPuk对外域名 | https://gitpuk.example.com | 确保该域名已在soular的可回调域名白名单里 |
| 回调端点 | https://gitpuk.example.com/oauth/callback | 网关和后端都要为此路径放行 |
| 开发库版本 | Spring Boot 2.7.x + soular-sdk 1.4.0 | 版本越接近生产越好 |
| 多环境账号 | dev/staging/pro三个独立应用 | 避免环境间互相干扰 |
我第一次集成时犯过没有准备域名白名单的错,soular那边只允许精确匹配回调地址,而我提交的回调地址写成了http://localhost:8080/oauth/callback,网关转发时域名又是http://localhost:9090,导致回调一直被拒。后来加了条“开发环境所有本地端口都允许回调”的规则,联调才顺畅起来。
3.2 在soular管理台注册应用
第一步是进入soular的admin-console,创建一个名叫“GitPuk”的OAuth应用。需要填的信息不多,但每项都影响后续行为。
应用名称填GitPuk,应用类型选“Web应用”。回调地址填GitPuk正式的回调URL,也就是https://gitpuk.example.com/oauth/callback。这块必须精确匹配,路径不能多斜杠,大小写不能错。如果GitPuk内网环境和公网环境域名不同,要分别注册两个应用,不要试图用一个回调地址覆盖所有情况。
授权模式选择“Authorization Code + Refresh Token”。scope按需要勾选openid profile,openid是OIDC必需的,profile用来获取用户昵称和头像。不要贪多选一堆用不上的scope,授权范围越大,token泄露时被波及的数据就越多。
创建成功后,页面会给出一对client_id和client_secret。client_id可以直接复制给前端和后端用,client_secret务必放到后端的配置中心,走环境变量注入,不要提交进Git仓库。团队内部如果发现client_secret疑似泄露,立即去管理台重新生成并替换。
3.3 前端登录页跳转改造
GitPuk原来的登录页是一整套表单,这次改造直接把登录页替换成一个“正在跳转至统一登录”的过渡页。核心逻辑其实就一段代码:生成随机state、保存state、拼授权URL、跳转。
我贴一下我们前端的关键代码,用的是Vue + TypeScript:
function startLogin() { const state = Math.random().toString(36).slice(2) + Date.now(); sessionStorage.setItem('soular_oauth_state', state); const params = new URLSearchParams({ response_type: 'code', client_id: 'gitpuk-web', redirect_uri: window.location.origin + '/oauth/callback', scope: 'openid profile', state: state, }); window.location.href = `https://soular.example.com/oauth/authorize?${params.toString()}`; }这里有两个点要重点说明。第一,client_id虽然可以放在前端,但client_secret永远不能出现在这段代码里,一旦出现在这里,就等于任何人都可以冒充GitPuk去换token。第二,state存哪儿要提前想好,我们存在sessionStorage,这样只对当前标签页生效,窗口关闭就消失,相对安全。存localStorage也行,但多标签页会互相覆盖,曾出现过state对不上导致登录失败的问题。
回调处理完以后,后端会种会话cookie并302回首页,前端只需要监听一下一次路由变化,刷新当前用户信息即可。
3.4 后端接入client-sdk
后端这边我直接用Java + Spring Boot做演示。先引入soular-sdk的依赖,然后在application.yml里配置认证中心信息:
soular: issuer-uri: https://soular.example.com client-id: gitpuk-web client-secret: ${SOULAR_CLIENT_SECRET} redirect-uri: ${GITPUK_BASE_URL}/oauth/callback scope: openid,profile配置里有一个细节:client-secret和redirect-uri不写死,而是用环境变量占位。这样同一份代码部署到dev、staging、prod时,只需要在部署平台配置不同环境变量,代码不用改。
回调接口是最核心的部分,逻辑就是把code换成token、解析用户、建立本地会话:
@RestController public class OAuthCallbackController { private final SoularClient soularClient; private final UserProfileRepository userProfileRepository; @GetMapping("/oauth/callback") public String callback(@RequestParam String code, @RequestParam String state, HttpSession session) { String savedState = (String) session.getAttribute("soular_oauth_state"); if (!Objects.equals(savedState, state)) { throw new OAuthStateException("state mismatch"); } TokenExchangeResult token = soularClient.exchangeCode(code); UserInfo userInfo = soularClient.parseIdToken(token.getIdToken()); UserProfile profile = userProfileRepository .findBySoularUid(userInfo.getSubject()) .orElseGet(() -> userProfileRepository.create(userInfo.getSubject(), userInfo.getPreferredUsername())); session.setAttribute("userId", profile.getId()); session.setAttribute("displayName", userInfo.getName()); return "redirect:/"; } }很多人第一次写的时候会漏掉state校验那几行,这就导致了CSRF风险。攻击者可以诱导已登录用户访问一个伪造的callback地址,如果你的后端只认code不管state,就可能被绑定到攻击者自己的账号上,用户后续的所有操作都被记录下来。这个校验必须写,没有商量的余地。
id_token解析出来之后,不要直接拿它的sub字段当业务系统的自增ID,最好用user_profile表做一层映射。原因是sub字段是soular生成的不透明ID,以后如果从soular迁移到别的认证中心,这个ID可能变,但你的业务数据不能跟着变。
3.5 网关与后端服务配置
网关这里我们用的Nginx,核心配置如下:
server { listen 443 ssl; server_name gitpuk.example.com; location /oauth/callback { proxy_pass http://gitpuk-backend:8080; } location /api/ { auth_request /_soular_session_check; auth_request_set $soular_uid $upstream_header_x-soular-uid; proxy_set_header X-User-Id $soular_uid; proxy_pass http://gitpuk-backend:8080; } location = /_soular_session_check { internal; proxy_pass http://soular-auth-proxy/session/check; proxy_pass_request_body off; } }这里面看似简单的配置,在运维层面躲了三个坑。第一个坑是proxy_pass_request_body off必须写,否则内部校验请求会把原始POST的body也转发给会话校验接口,既浪费带宽又可能污染数据。第二个坑是auth_request是同步子请求,用户实际请求会被阻塞等校验结果返回,如果soular服务响应慢,整个GitPuk的接口都会变慢,所以会话校验接口要有缓存策略,不重复发请求。第三个坑是X-User-Id请求头必须由网关统一设置,同时后端要校验请求里不能带这个请求头,防止伪造。可以在网关入口用proxy_set_header X-User-Id ""清空客户端传入的值,再设置真实值。
3.6 多环境配置技巧
多环境配置是这次集成中花时间最多的地方,不是技术难,是细节琐碎。我们分了dev、staging、prod三套环境,最初的想法是复用同一组client_id和client_secret,把回调地址写成一个通用域名,靠路径区分环境。结果发现callback地址里的环境路径会让soular的回调地址匹配逻辑处理起来很别扭,经常出现dev的账号跳到prod的情况。
后来老老实实地在soular里注册了三个应用:
| 环境 | client_id | 回调地址 |
|---|---|---|
| dev | gitpuk-dev | https://dev.gitpuk.example.com/oauth/callback |
| staging | gitpuk-staging | https://staging.gitpuk.example.com/oauth/callback |
| prod | gitpuk-prod | https://gitpuk.example.com/oauth/callback |
每个环境一套单独的client_id和client_secret,环境间数据彻底隔离。本地开发时使用dev环境,访问soular的一个专用测试登录页,里面预置了一批测试账号,权限和数据都在测试空间里,不影响生产账号。
CI/CD方面,每个环境的SOULAR_CLIENT_SECRET都存在部署平台的加密变量里,不用明文写进任何配置文件。这一点属于老生常谈,但确实很多人栽过:把prod的client_secret打在测试环境的配置里,一次控制台误输出就把生产钥匙泄露了。从流程上约束,比事后清理要轻松得多。
3.7 Git命令行集成
Web端登录做了大半,接下来操作Git仍会提示输入密码,因为本地的credential机制不知道新的token。我们按这个步骤做了一轮配置。
首先安装一个git-soular-helper的小工具,它的作用是在Git请求凭据时,先检查本地缓存的soular access_token是否有效,有效就直接返回;无效则提示用户重新到网页完成一次“一小时后免登录”授权,再把新token写回本地。安装完成后,每个开发者在自己的机器上执行一次:
git config --global credential.helper /usr/local/bin/git-soular-helper验证是否生效,最简单的方式是用curl模拟一次带token的API请求:
curl -H "Authorization: Bearer $SOULAR_ACCESS_TOKEN" \ https://gitpuk.example.com/api/v1/userinfo如果返回用户JSON,说明token有效,再执行git clone https://gitpuk.example.com/team/project.git,整个过程应该无感完成。如果clone时又弹出要输入密码,多半是凭据助手没配对,或者旧凭据被系统缓存了。这时先执行一次:
git credential reject <<EOF protocol=https host=gitpuk.example.com EOF清理掉旧的缓存凭据再试。
token有效期和刷新策略值得一提。access_token默认有效期是1小时,refresh_token是7天。我们为了避免用户频繁重新授权,在后端做了一次透明刷新:当API调用返回401时,会拿着refresh_token去向soular换新token,换完再重放一次原请求。这个逻辑放在SDK内部自动完成,用户完全无感。唯一需要注意的是refresh_token不能下发给前端或Git命令行走明文,必须存在后端和本地凭据助手的加密存储里。
4. 常见问题与排查技巧实录
4.1 redirect_uri不匹配
现象是点击登录后被soular打回,提示redirect_uri_not_match或是invalid_redirect_uri,日志里能看到soular记录的实际请求URI。
这种问题九成出在“注册的回调地址”和“实际请求的回调地址”不一致。最常见的差异包括:多了一个尾斜杠、把https写成了http、用了localhost而实际请求是127.0.0.1、回调端口没写对。还有一种情况是前端在拼接URL时做了encodeURIComponent,把/转成了%2F,到soular那边比对时就对不上了。排查时把soular日志里的redirect_uri原样拿出来,跟在管理台上注册的地址逐字符对比,几秒钟就能定位。如果两边看着一样却还是报错,把URL复制出来放进在线解码工具还原一次,确认没有隐藏的编码差异。
我建议在注册时把回调地址规范化:一律使用正式域名、不带尾斜杠、明确协议和端口。本地联调时用Nginx把dev.gitpuk.example.com转发到本地服务,避免直接用localhost去soular注册。
4.2 state校验失败
state校验失败在测试阶段出现的频率比想象的高,而且原因很分散。
最常见的是刷新页面导致state丢掉了。用户在授权页停留了很久,点回来后soular把code带到callback,但后端的session里已经找不到之前存的state了,于是拒绝。这种问题的根源在于把state存进了HttpSession,而HttpSession在网关的多个实例之间如果不共享,就会出现“发起登录的实例存了state,回调落到另一个实例找不到”。解决方法是把state存进Redis,设置两分钟过期时间,同时把Redis作为统一的Session存储。
另外有两种情况也会导致state校验失败:一是本地多标签页同时打开登录,后一个标签页的state把前一个覆盖了;二是回调时后端用了双写Cookie和Session,Cookie里的state和Session里的state值不一致。我的建议是永远以起始时保存的那一份为准,校验失败直接重定向回登录页重新开始,不要尝试“给用户拼一个正确state”,那样反而会掩盖真正的问题。
4.3 登录成功但一直跳回登录页
这个问题的表现是:在soular那边明明输对了账号密码,也能收到code,但回到GitPuk后立刻又显示未登录,仿佛会话根本没建立。
排查顺序一般是先看cookie。打开浏览器开发者工具,确认callback响应里的Set-Cookie是否成功种下。如果cookie名字带HttpOnly和Secure,本地开发时用http://localhost访问,浏览器会拒绝写入Secure cookie,自然就始终未登录。再看SameSite属性,新版浏览器默认Lax,如果设置成None而连接又不是HTTPS,cookie同样会被拦截。
如果是网关和后端Session不共享,回调落到backendA,下一个请求被网关负载均衡到backendB,backendB没有对应的Session数据,也会出现跳回登录页。网关接入后,Session存储要迁移到Redis或让Nginx做Session亲和性,否则多实例部署下必然踩这个坑。
还有一种低概率但存在的情况:回调完成后后端根据用户ID做了一次重定向,重定向目标路由又被网关判定为需要登录,而新的会话还没在网关侧生效,出现“自己拦自己”的环。网关的会话校验要有缓存,刚种下的cookie在几十毫秒内不应立即做全量校验。
4.4 跨域与端口问题导致登录中断
开发环境最容易遇到,前后端分离导致前端localhost:5173、后端localhost:8080,前端把用户重定向到soular时的回调地址填的是localhost:5173/oauth/callback,但真正处理回调的是后端8080端口,两边对不上。
最终解决方式是引入一个开发网关,统一对外暴露一个域名:
server { listen 80; server_name dev.gitpuk.example.com; location / { proxy_pass http://localhost:5173; proxy_set_header Host $host; } location /api/ { proxy_pass http://localhost:8080; } location /oauth/callback { proxy_pass http://localhost:8080; } }这样前端、后端、soular之间的回调地址是同一个域名下的不同路径,cookie也能正常种下。如果你不想改/etc/hosts,也可以直接用127.0.0.1加端口,但要注意Cookie和端口的关系,端口一般不参与Cookie的作用域判定,但跨域时Access-Control-Allow-Origin会出问题,API需要把soular和soular回调域名都加进允许列表。
4.5 Git命令行报401,浏览器却正常
这种情况最容易让人困惑:网页上一切正常,一执行git push就返回fatal: Authentication failed。定位思路只有一个:确认命令行里发出去的凭据到底是什么。
先开一次详细的Git跟踪,看看请求里到底带的什么头:
GIT_TRACE=1 git push origin main如果日志显示发送的密码是空串或者是一串老的随机文本,说明credential helper没有接管凭据。检查git config --global credential.helper是否输出了正确路径,然后检查本地缓存里是否存了旧token。
如果确认发出了access_token还是401,再看一下这个token的scope里有没有仓库写入权限。我们犯过的错就是在注册应用时只选了openid profile,漏了read_repository write_repository,Web端这些权限由GitPuk后端控制所以没事,但Git直接拿token访问仓库时,soular的token权限范围就不够了。重新授权并加上对应scope后,问题立即消失。
最后还有一个细节:Git的Basic Auth机制会把用户名作为username发送,我们用token作为password,用户名往往写“oauth2”。很多实现里userinfo会被忽略,但如果GitPuk这边严格校验用户名,就必须确保命令行提交的用户名格式一致。
4.6 一套日志排查法
排障这么多轮以后,我把日志规范沉淀成了一套固定流程,每次出问题都按这个顺序看,基本能在十分钟内定位到环节。
第一步,在前端发起跳转前打印一行日志,带state开头四位、目标URL脱敏后的信息。这样能确认用户是从哪个页面开始登录的。
第二步,在soular回调到GitPuk时打印一行日志,带上code是否为空、state是否通过校验、用户ID。如果这一步没有日志,说明请求根本没进到后端,问题出在网关或soular回调配置。
第三步,在换token之后打印一行日志,带token换取耗时时长、返回的用户标识、scope列表。能确认soular是否正常、scpoe是否正确授权。
第四步,在网关校验Session时打印一份轻量日志,带cookie是否有效。这一点通常在排查“跳回登录页”时非常有用。
每行日志都带上一个trace_id,从跳转开始到最终会话建立,一串到底。我们用的是在跳转URL的state里额外拼一个短trace_id,回调时再把它取出来打印,这样日志平台里一搜就能拉出完整链路。不要试图靠肉眼读一连串请求记录拼出全过程,分布式系统的日志不聚合,定位问题就像大海捞针。
这里也给大家一个建议:上线前把登录链路的每个关键节点都埋好日志,哪怕开发期觉得“这不要紧”,等生产环境出了问题你就知道它的价值了。
集成soular统一登录这件事,从代码量上看并不大,真正的成本和难点全在边界梳理和场景覆盖。我自己在这个过程中最深的体会是,千万不要只盯着浏览器里的登录跳转,Git命令行、API自动化和定时任务这些非交互场景才是最难处理的部分。如果你也在做类似的统一登录接入,建议上线前把三类场景完整过一遍:网页端正常登录、命令行git clone/push、带token的API自动刷新。这三条路全通了,这个项目才算真的落地。另外一个额外收益是,权限审计变得清晰了很多,现在任何一个仓库的每一次推送,都能追溯到soular里的具体用户ID和会话时间,这是我们之前分散账号时期根本做不到的。