这段时间在项目里做了一件特别磨耐心的事:把公司几套系统的账号全部收敛到同一套体系里,由 Hadess 作为统一接入框架,soular 作为认证中心,通过两者集成实现真正意义上的统一登入,也就是我们常说的统一登录。整个过程踩了不少坑,也把之前很多停留在文档里的概念真正落地了一遍,所以想把这次实践的思路、配置和问题排查整理出来,给准备在自研框架里做同类型对接的人做个参考。
很多团队做统一登录,第一反应是“我装个 SSO 组件就行”,或者“找一个开源的登录页接进去”,但真正落地的时候才会发现,问题根本不在登录页,而在认证链路怎么设计、网关怎么鉴权、下游服务怎么拿用户身份,以及多实例部署之后登录态还认不认。这篇文章主要会讲清楚 Hadess 和 soular 在统一登录体系里各自负责什么、登录链路是怎么走的、具体配置怎么写、业务系统接入时有哪些容易忽略的设计,以及我们实际遇到的几个致命坑。如果你正在做平台类项目的登录整合,或者打算在网关层统一收口身份认证,这篇内容应该能帮你省掉不少摸索时间。
1. 先把需求想清楚:为什么一定要做统一登录
1.1 没有统一登录时的真实体感
在讲技术方案之前,我要先聊一段现状。我们当时的系统大概有四套:一个面向运营人员的后台、一个给客服用的工单系统、一个数据报表平台,还有一个管理后台的子模块。每套系统都是不同时期搭建的,账号体系各自独立,密码策略、登录有效期、权限模型全都对不上。运营同事每天上班要在三个系统里分别登录,密码经常忘记,支持部门光是处理“密码找回”就占了不少工单量。
而且更麻烦的是安全层面的问题。有些系统把密码明文存在自己的数据库里,有些系统登录接口没有任何频率限制,有些老系统甚至还在用 cookie 里存 username 来判断身份,抓个包就能伪造登录。每次做安全巡检,这些问题都会被拎出来说一遍,但真要改,又牵涉到每套系统各自的改造工作量,一直拖到管理层下了硬性要求:统一账号、统一认证、统一退出。
这个命令落到技术侧,其实就是一句话:把身份认证的能力从各业务系统中抽出来,单独做成一个公共服务。用户只需要登录一次,后续访问其他系统时不用再重新输入账号密码。这里的关键词不是“少输几次密码”,而是把各系统的信任关系重新梳理:业务系统不再自己验证密码,只认认证中心签发的凭证。
1.2 统一登录到底解决什么问题
如果只从表面看,统一登录像是优化用户体验,但从架构角度看,它本质上是做了一次职责边界的大调整。没做统一登录之前,每个业务系统都要负责注册、登录、找回密码、改密码、校验会话,做了统一登录之后,这些事全部收归 soular 处理,业务系统只需要关心“当前请求的用户是谁、有没有权限做这个操作”。
我自己的理解是,统一登录的价值可以拆成三层。第一层是用户体验改善,用户一次登录走遍所有系统,这是最直观的收益。第二层是安全策略归一,密码策略、验证码、风控规则、登录日志都集中在一个地方管控,不会再出现“主系统密码很强,边缘系统密码很弱”的短板。第三层是业务系统减负,新业务系统上线时不需要再做登录模块,直接接入统一认证即可,开发效率提升非常明显。尤其是第三层,长期看价值最大,因为每减少一套自建账号体系,就减少了一套需要维护的密码存储、会话管理和安全补丁。
做好这件事的难点在于,它不是写一个 filter 就完事的,要把网关、认证中心、前端、业务服务之间的信任链条完全打通。我下面的内容会按这个思路逐步展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 登录链路与技术选型:Hadess、soular 各自的角色
2.1 直接讲清楚 OAuth2 授权码模式的选择
先明确一下 Hadess 和 soular 这两个角色。在我们的实践中,Hadess 是团队内部基于 Spring Cloud Gateway 封装的统一访问框架,所有前端请求都会先经过它再做路由转发。soular 则是负责用户身份认证的独立服务,相当于整个体系里的“证件签发中心”。
用户登录这件事,最核心的交互就是浏览器和认证中心之间完成身份校验,然后认证中心给一个凭证,其他系统认这个凭证就行。这个凭证的颁发和校验过程,大多数成熟方案都会走 OAuth2 协议。我们集成时选了 OAuth2 的授权码模式,没有选更简单的密码模式或者隐式模式,原因有三点。
授权码模式下,用户的账号密码只提交给 soular 这一个认证服务,任何业务系统和网关都不会接触到明文密码,攻击面小。授权码模式通过后端换 token,token 不会暴露在浏览器地址栏或前端日志里,安全性明显更好。它天然支持 refresh token,之后可以方便做会话续期和踢人操作。
有些团队会嫌授权码模式步骤多,想让网关直接拿用户名密码去换 token,也就是 OAuth2 的密码模式。如果 soular 和 Hadess 是完全可信的内网服务,密码模式短平快,能减少不少跳转,但这种方式要求网关或者每个请求都得带着用户密码或长期凭证,会话管理比较粗糙,用户改密码之后旧凭证很难实时失效。做内部系统的统一登录,我更建议用授权码模式,前期多写一点配置,后面做安全加固时不用返工。
2.2 一次登录请求从浏览器到后端全链路拆解
链路设计是整个集成里最值得花时间想清楚的部分。我们最终定下来的核心路径是这样的,我尽量讲得完整一点。
用户在浏览器里打开某个需要通过 Hadess 网关访问的业务页面,此时尚未登录。Hadess 侧的认证过滤器发现当前请求没有合法会话,于是把请求重定向到 soular 的登录授权地址,同时在参数里带上 client_id、redirect_uri、response_type=code 和一个随机生成的 state。
浏览器跳到 soular 的登录页面,用户输入账号密码完成身份校验。soular 校验通过后,浏览器被重定向回 Hadess 预先注册好的回调地址,并在地址参数里带上授权码 code 和 state。Hadess 收到回调后,首先校验 state 是否和之前发起登录时生成的一致,防止跨站请求伪造;确认没问题后,再用这个 code 去 soular 的 token 接口换取 access token、refresh token 和用户的身份信息。
拿到这些信息后,Hadess 在服务端建立自己的登录会话,并把 token 信息与会话绑定。之后浏览器访问其他接入统一登录的业务系统时,请求仍然先经过 Hadess,Hadess 识别到会话已建立,就会允许请求继续向后端路由。
用户访问另一个系统时,因为浏览器和 Hadess 之间的会话已经存在,所以不会再跳转登录页。整套流程从用户视角看就是登录一次,后续系统免登;从系统视角看,本质是通过浏览器 cookie 维持网关会话,再通过网关与 soular 之间的可信关系换取各服务需要的用户身份。
2.3 登录态保持与 JWT 的配合逻辑
授权码模式解决了“怎么证明用户登录过”的问题,但还有一个问题需要回答:网关和下游服务之间怎么传递身份。
我们没有让每个下游服务都去 soular 查一次用户信息,那会造成严重耦合,也会让 soular 成为性能瓶颈。更常见的做法是在 soular 签发的 access token 里携带用户信息,通常是一个 JWT 结构。Hadess 网关对 JWT 做签名校验后,把用户信息提取出来,再传给下游业务服务。
这里有一个关键设计:业务服务不直接解析原始 JWT,而是信任 Hadess 网关传递过来的用户标识请求头。这样做的好处是业务服务不需要知道 soular 的密钥、不需要引入 OAuth2 客户端依赖,只要按照约定从请求头读取用户 ID 即可。缺点是如果网关配置不当或者网络边界不清晰,请求头可能被伪造,所以必须在网关上做严格的清洗和过滤,不能允许外部请求直接携带这些头进入内网。这个信任边界问题我会在第 5 章再展开。
从登录态的角度看,浏览器、Hadess、soular 三层各有一个“状态”。浏览器持有的是 Hadess 会话 cookie,Hadess 持有的是与 soular 相关的 access token 和 refresh token,soular 保存的是用户账密和 token 的签发记录。三层状态的有效期可以独立配置,也可以联动控制,业务系统的会话生命周期设计基本都绕不开这一层。
3. 落地实操:Hadess 集成 soular 的完整过程
3.1 第一步:在 soular 注册应用,收集参数
先把前提说一下,以下配置均基于我们本地的实际场景,域名和端口我都做了脱敏处理。你在操作时,只需要把地址换成自己环境里真实的认证中心地址即可。
集成时第一件事不是在 Hadess 里写代码,而是先到 soular 管理后台注册一个客户端应用。大多数认证中心都会要求填如下信息,我当时整理了一张表,直接照着填就行。
| 配置项 | 示例值 | 说明 |
|---|---|---|
| 应用名称 | hadess-web-portal | 用于在 soular 后台区分应用 |
| Client ID | hadess-client | 应用的唯一标识,接口调用时使用 |
| Client Secret | 一串随机字符串 | 相当于应用密码,只能在后端保存 |
| 授权回调地址 | http://sso.example.com:8080/login/oauth2/code/soular | 必须和网关配置完全一致 |
| 授权类型 | Authorization Code | 选择授权码模式 |
| 允许的 Scope | openid profile offline_access | 用于获取用户基本信息与刷新令牌 |
有一点要特别注意:回调地址填的是 Hadess 网关的地址,不是前端页面的地址。很多第一次做这类型对接的同学会把回调地址填成前端首页,比如填成 http://portal.example.com,然后登录时发现要么跳转不对,要么 code 没人接收。回调地址必须是 Hadess 网关中真正处理 OAuth2 回调的那个接口地址,这个结论在接入时我说了很多次,后面第 5 章还会讲对应的报错信息。
soular 后台做完注册之后,你会拿到一对 client_id 和 client_secret,加上认证中心暴露的授权地址、token 地址、用户信息地址、JWKS 地址,这些参数就是整个集成的基础。建议把每套环境(开发、测试、生产)在 soular 后台都单独注册一个应用,不要所有环境共用同一个 client_id,否则日志排查时会很难区分请求来自哪套环境。
3.2 第二步:在 Hadess 中加入 OAuth2 客户端配置
Hadess 是基于 Spring Cloud Gateway 的框架,所以我们在网关工程里直接用了 Spring Security 对 OAuth2 Client 的支持。引入依赖之后,配置集中在 application.yml 里,核心内容如下。
yaml复制spring:
security:
oauth2:
client:
registration:
soular:
client-id: hadess-client
client-secret: your-client-secret
client-name: soular
scope: openid, profile, offline_access
redirect-uri: "{baseUrl}/login/oauth2/code/soular"
authorization-grant-type: authorization_code
provider:
soular:
authorization-uri: http://sso.example.com/oauth2/authorize
token-uri: http://sso.example.com/oauth2/token
user-info-uri: http://sso.example.com/oauth2/userinfo
user-name-attribute: sub
jwk-set-uri: http://sso.example.com/oauth2/jwks
这段配置需要重点解释两个地方。第一,redirect-uri 里的 {baseUrl} 是占位符,Spring Security 会自动替换为当前网关的地址,所以开发环境和生产环境可以用同一份配置模板,只要保证网关对外地址和 soular 后台注册的地址一致即可。第二,jwk-set-uri 用于获取认证中心的公钥,网关拿到 JWT 后需要用这个公钥验签。如果 jwk-set-uri 配错或者网络不通,登录后就会出现 JWT 验签失败的错误。
除了配置,网关工程还需要定义一个 SecurityFilterChain,把不需要登录就能访问的公开接口放到白名单里,其余请求统一要求认证。我们的白名单至少包含这几类:登录页跳转入口、OAuth2 回调接口、健康检查接口、前端静态资源,以及部分刻意开放的匿名接口。
java复制@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/oauth2/**", "/login/**", "/actuator/health", "/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/oauth2/authorization/soular")
)
.logout(logout -> logout
.logoutSuccessUrl("/oauth2/authorization/soular")
);
return http.build();
}
这里要提醒一点:oauth2Login 的配置是 Spring Security 的通用处理方式,它会在请求被拦截时自动跳转到 soular 授权地址。但我们在实际项目中,部分前端页面已经自己实现了登录按钮和跳转逻辑,并没有完全依赖这个自动跳转,所以控制好 permitAll 的范围非常重要。如果白名单放得太宽,匿名请求就会绕过整个认证过程。
3.3 第三步:网关侧的身份转换与请求透传
Spring Security 在处理完 OAuth2 登录之后,会把认证信息保存在 SecurityContext 里,但网关还面临一个现实问题:下游业务服务不认 Spring Security 的 SecurityContext,它们只知道 HTTP 请求头。因此,我们需要在网关层写一个过滤器,把登录用户的信息从认证对象里取出来,转换成业务服务约定的请求头。
我实现的是一个 GlobalFilter,关键代码如下。
java复制@Component
public class IdentityTransferFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return exchange.getPrincipal()
.filter(principal -> principal instanceof OAuth2AuthenticationToken)
.cast(OAuth2AuthenticationToken.class)
.map(token -> {
OAuth2User user = token.getPrincipal();
Map<String, Object> attrs = user.getAttributes();
ServerHttpRequest request = exchange.getRequest().mutate()
.header("X-User-Id", String.valueOf(attrs.get("sub")))
.header("X-User-Name", String.valueOf(attrs.get("name")))
.header("X-User-Account", String.valueOf(attrs.get("preferred_username")))
.build();
return exchange.mutate().request(request).build();
})
.defaultIfEmpty(exchange)
.flatMap(chain::filter);
}
@Override
public int getOrder() {
return -100;
}
}
写这个过滤器时有几个细节建议你提前想清楚。用户 ID 字段建议统一使用 soular 返回的 sub,它是用户在认证体系里的唯一标识,不会因为用户名修改而改变,适合作为业务系统的外键。不要直接用 name 或 nickname 作为业务关联字段,真实姓名可能重复,也可能会被用户修改。
透传的请求头一定要经过严格清洗。如果使用 Spring Cloud Gateway,需要在路由配置里把内部请求头标记为敏感头,避免前端请求伪造 X-User-Id 直接打到业务服务。我们的做法是 ingress 和网关层统一把外部请求的这些头全部 strip 掉,再由这个过滤器重新写入,确保业务服务收到的 X-User-Id 只可能来自网关,这是整体安全设计里非常关键的一环。
3.4 前端接入和基本跳转逻辑
服务端的链路打通之后,前端的接入其实并不复杂。最简单的方案是:前端只需要在后端网关的登录入口上放一个按钮,点击跳转到网关地址的 /oauth2/authorization/soular,让 Spring Security 接管后面的 OAuth2 流程即可。
登录成功后,前端需要知道当前用户是谁,我们暴露了一个 /auth/session 接口,由网关从 SecurityContext 中读取用户信息后返回。前端在应用初始化时请求一次该接口,就能拿到用户 ID、姓名、头像等基础资料,并根据这些信息渲染页面。如果该接口返回 401,前端就统一跳转到登录入口。
必要的情况下,我们还可以加一段“前端登录态前置检查”的逻辑。比如在路由守卫里先请求 /auth/session,如果返回未登录,直接跳转登录页;如果已经登录,则放行。这种做法可以避免页面加载到一半才被网关拦下来,用户体验会好很多。对前端来说,整个改动量大概只需要半天到一天,核心逻辑都在网关和认证中心,前端不需要关心 OAuth2 的细节。
4. 业务系统接入时几个容易忽略的设计
4.1 下游服务拿用户身份,用 Header 传递还是解析 JWT
统一登录打通之后,接着要处理的就是业务系统怎么拿到用户身份。我见过不少团队在这个问题上走了弯路:他们让每个业务系统都去配置 soular 的 JWT 公钥,自己解析 token,结果就是升级公钥时要改动所有系统,出问题时每个系统都要查一遍日志。
我们的建议是,如果业务系统都跑在内部网络且通过 Hadess 网关统一入口访问,那么业务服务不解析 JWT,只读取网关传递过来的请求头。网关负责和 soular 通信、验签、解析 JWT,然后把身份信息以 X-User-Id、X-User-Name 等请求头形式透传给下游服务。
这样做最直观的好处是业务系统的接入成本极低。业务服务不需要引入任何 OAuth2 相关依赖,只需要约定一个“当前用户 ID 从哪个请求头读取”,后续不管认证中心换成什么、JWT 格式怎么变,业务服务都不受影响。相应的代价是,一旦有业务服务暴露到了网关之外,或者内部服务之间存在直接的互相调用而没有经过网关,就需要单独评估身份传递的方案。如果没有统一网关做收口,直接信任请求头会有很大的安全风险。
4.2 会话有效期、刷新策略与“踢人”需求
登录态的生命周期设计是容易被忽视但实际影响特别大的点。OAuth2 里,access token 的有效期通常比较短,比如半小时到两小时,而 refresh token 的有效期可以比较长,比如几天甚至几周。这两个有效期并不直接等同于用户的登录时长,因为我们的网关还会用自己的会话机制再包一层。
实际使用时,用户感受到的登录时长取决于网关会话的过期时间。我们内部设置的策略是:网关会话空闲超时为 8 小时,用户在 8 小时内不操作就需要重新登录;access token 的有效时间为 30 分钟,当用户继续访问时,如果发现 token 快过期,网关会自动使用 refresh token 获取新的 access token,这个操作对用户完全无感。
开发时有一个容易出问题的地方:如果没有正确配置 refresh token,网关访问令牌过期后,用户就会莫名其妙被登出,即使网关会话还是有效的。排查这个问题时,要重点看 soular 颁发的 refresh token 是否按期刷新,以及网关侧是否配置了对应的 refresh token 过期的处理策略。如果不想让用户频繁重新登录,建议把 refresh token 的有效期设得明显长于网关会话的空闲过期时间,让刷新操作发生在会话真正结束之前。
4.3 多套环境的 redirect_uri 管理
如果你的项目有开发、测试、预发、生产多套环境,建议在 soular 后台为每套环境单独配置一套应用参数。开始我们为了省事,开发和生产共用同一个 client_id,结果开发环境调试时经常互相影响登录状态,最后排查到半夜才发现是两套环境抢同一个认证应用导致的。
每套环境独立的配置方式很简单:soular 后台分别创建 hadess-dev、hadess-test、hadess-prod 三个应用,每个应用配置对应的回调地址。网关配置则可以通过 Spring Boot 的多环境配置文件来区分,例如 application-dev.yml 里填开发环境参数,application-prod.yml 里填生产环境参数。这样环境之间天然隔离,不会出现开发调试时挤掉生产登录态,也不会因为回调地址不匹配导致登录直接失败。
5. 集成过程中最容易踩的坑
5.1 重定向 URI 不一致:必现且报错最直接
先说一个我们遇到频率最高的报错:invalid redirect_uri。这个问题的原因几乎都是 soular 后台注册的回调地址和网关实际发起授权请求时携带的 redirect_uri 不一致。soular 对回调地址做的是精确匹配,差一个端口、差一个路径、甚至 http 和 https 不一致都会直接拒绝。
实际排查时,不要凭感觉改配置,先把浏览器地址栏里的授权请求完整复制下来,看看 redirect_uri 参数到底是什么,然后和 soular 后台注册地址逐字符比对。常见的坑包括:本地开发时用 127.0.0.1 访问,但 soular 后台注册的是 localhost;网关前面挂了 Nginx,Nginx 监听 443,网关实际监听 8080,导致回调地址里端口不一致;还有前端页面和网关不在同一个域名下,回调地址写成了前端页面地址。这些都属于细节问题,但任何一个都足以让登录完全不可用。
5.2 登录成功后回到页面还是匿名
登录流程看起来完全正常,soular 页面也跳转回来了,但页面刷新一下又变成未登录。这类问题我们调试了很久,最后定位到几个原因。
第一个原因是网关会话 cookie 的域名问题。网关部署在某个内部域名下,设置了 HttpOnly 和 Secure 属性的 cookie,但前端页面通过 IP 加端口访问时,浏览器可能会因为 Secure 属性不匹配而不保存 cookie,或保存后请求时未携带。开发环境下如果 HTTPS 证书没有正确配置,建议临时关闭 Secure 属性,或者在浏览器里确认请求是否带上了会话 cookie。
第二个原因是前端请求默认不携带 cookie。现在很多前端框架的 HTTP 库默认不会带上跨域请求的凭据,如果网关和前端不是同源,需要在请求配置里把 withCredentials 设置为 true,同时网关也要开启对应的 CORS 配置。否则即使登录成功,后续的 API 请求也会因为没有 cookie 而被判定为未登录。
第三个原因是网关回调接口和业务路由的 Session 没有共享。如果网关有多个实例,且没有配置 Redis Session 共享,用户第一次登录的会话只存在其中一个实例上,下一次请求被负载均衡转发到另一个实例,登录态就丢了。这个问题在下一个小节单独说。
5.3 多实例部署后 Token/Session 互相不认
网关在生产环境不可能只跑一个实例,只要有两个及以上实例,就一定会遇到登录态不共享的问题。Spring Security 默认把 OAuth2 客户端信息和会话信息放在 JVM 内存里,实例一登录成功,实例二完全不感知,负载均衡把下一个请求打到实例二时,就认为用户没登录。
解决思路有两个方向。一个方向是把会话存储切换到 Redis,让所有网关实例访问同一个 Redis,这样登录态就能共享。另一个方向是通过配置开启 Spring Session,把 session 的存储介质改为 Redis,同时设置好 session 的 key 前缀和过期时间。我们没有直接修改 Spring Security 的默认实现,而是引入了 spring-session-data-redis,在 yaml 里配置 Redis 连接后,登录态自动就共享了。
如果你是手动做 JWT 校验而不是完全依赖 Spring Security,还需要注意 JWT 验签密钥在多实例下必须一致。有些团队在开发环境使用随机生成的密钥,单实例没问题,一旦多实例部署,不同实例用不同密钥解密同一 JWT,会频繁报错。正确的做法是把密钥配置到统一配置中心或环境变量里,确保所有实例读到的是同一份。
5.4 跨域预检和自写请求头带来的坑
业务服务接入统一登录后,经常会通过网关调用下游接口,并且前端会在请求头里放一些自定义字段,比如 X-Request-Id。浏览器在发起非简单请求前,会先发一个 OPTIONS 预检请求。这个预检请求通常不会携带业务身份,如果网关对 OPTIONS 请求也做强制认证,预检就会返回 401,导致浏览器以为接口不可用。
处理方式并不复杂,网关层需要单独放行 OPTIONS 请求,并正确返回 CORS 响应头。如果网关前面还有 Nginx,也需要在 Nginx 层面对 OPTIONS 做类似处理,返回 204 并带上 Access-Control-Allow-Origin、Access-Control-Allow-Headers、Access-Control-Allow-Methods 等响应头。这里有一个容易漏掉的细节:业务服务如果自己读取了 X-User-Id,那跨域响应里的 Access-Control-Allow-Headers 必须包含 X-User-Id,否则浏览器会拦截实际的业务请求。
最后说一个和排错无关但很影响体验的小习惯。接入完成后,建议把统一定义的请求头命名和用户字段规范写进项目 README,并且在后端约定网关是唯一允许写入 X-User-Id 的信任边界。如果业务服务自己也都信任外部传来的头,很容易被人伪造身份。我们在复盘时发现有三处服务直接信任前端传过来的用户 ID,后来全部改成了只信任网关加签的头部。身份认证做的是信任传递,边界一旦模糊,统一登录的价值就会大打折扣,这是这次集成里我体会最深的一点。
