1. 分布式系统认证的核心难题:状态到底放哪儿
先说个很实际的场景。单体应用时代,登录认证非常简单:用户输入账号密码,服务端把登录态写进Session,再往浏览器种一个Cookie,后续请求带着Cookie过来,服务端一比对Session就知道是谁。整个过程全程无感知,你甚至不用关心Session存哪儿,因为应用就部署在一台机器上,Session就是内存里的一张Map。
但到了分布式系统,问题一下冒出来了。你的服务从一台变成了五台、十台,用户第一次请求落在了A机器上,Session写进了A的内存;下一次请求被负载均衡转发到了B机器,B机器内存里根本没有这个Session,用户直接被判定为未登录。这就是分布式系统里最经典的"会话保持"难题。你可以用Nginx的IP Hash策略把同一个用户固定路由到同一台机器,但机器宕机、扩缩容的时候还是会出问题。你可以引入Session共享,把Session集中存到Redis里,但这只是把问题从"内存存不下"变成了"缓存依赖"——而且一旦服务规模继续扩大,这种集中式Session方案的管理成本会直线上升。
所以分布式系统的认证方案,本质上要解决两件大事:第一,登录状态不能继续依赖单一服务器的本地内存,必须做到"无状态"或者"可共享";第二,服务之间的调用、用户身份的传递,必须有一套统一的、安全的标准来约束,不能每个服务自己搞一套。
这个阶段,Spring Security OAuth2.0就成了绕不开的方案。它不光是做第三方授权登录的,在分布式系统里,它实际上承担了"统一认证中心 + 统一令牌发放 + 统一鉴权模型"的职责。这篇文章我会从方案设计的角度出发,把Spring Security OAuth2.0在分布式系统里的认证方案讲透,包括令牌选型、模式选择、网关鉴权、刷新机制,以及我在实际项目里踩过的坑。
2. OAuth2.0为什么能扛起分布式认证这面旗
2.1 OAuth2.0的角色划分恰好对应分布式架构
先把OAuth2.0的四个角色摆出来:资源所有者(Resource Owner)、客户端(Client)、授权服务器(Authorization Server)、资源服务器(Resource Server)。
很多人第一次接触OAuth2.0是在"第三方登录"场景里,比如某个网站可以让你用微信账号登录。这种场景下角色划分很清楚:你自己是资源所有者,那个网站是客户端,微信是授权服务器,微信自己的用户数据接口是资源服务器。
但放到分布式系统内部,这套角色模型同样成立,只是要换个角度理解:
- 授权服务器:统一认证中心,负责登录、发令牌、刷新令牌
- 资源服务器:各业务微服务,负责校验令牌、获取用户信息、做权限控制
- 客户端:可以是前端应用(浏览器、App),也可以是服务之间调用的发起方(比如订单服务调用用户服务)
- 资源所有者:最终用户
这套角色划分天然适合分布式架构。因为你只需要把"认证"这件事集中到一个服务里,其他所有微服务只需要学会"验证令牌",就可以复用同一套身份体系。一个新服务上线,不需要自己写登录逻辑,只需要接入资源服务器的配置,告诉Spring Security"我这边的令牌校验规则是什么",就完成了与认证中心的对接。
我见过很多团队在没有引入OAuth2.0之前,每个微服务自己写一套登录逻辑,有的用JWT自己解析,有的直接调认证接口获取用户信息,有的甚至每个服务都有自己的用户表。结果就是:同一个用户在A服务里叫"张三",在B服务里编码是"u_10086",在C服务里权限模型还是另一套。改一个权限规则,要动五六个服务。这就是典型的分布式系统里没有统一认证模型带来的灾难。
2.2 令牌的作用:用户状态从"会话"变成"凭证"
OAuth2.0的核心产物是令牌(Token)。所谓令牌,就是服务器发给客户端的一个凭证,客户端拿这个凭证去访问资源。
这里有个关键思维转变:单体时代我们靠Session记录状态,状态在服务端,服务端必须记住每个用户;OAuth2.0体系下,状态从"服务端记忆"变成了"客户端持有凭证、服务端验证凭证"。就像你去游乐场,以前是工作人员记着你买了票,现在是你手腕上戴一个纸质手环,每个项目入口的人只认手环,不记你是谁。
这个转变对分布式系统的意义非常大。因为服务端不再需要保存"谁登录了",只需要验证"这个令牌是不是合法签发的、有没有过期、有没有被吊销"。每个服务拿到令牌后,自己本地就能完成大部分校验,不需要每次请求都去认证中心查一遍Session,既降低了认证中心的压力,也让各服务的鉴权逻辑保持统一。
3. 令牌选型:Opaque Token与JWT的本质区别
3.1 不透明令牌的实现逻辑
OAuth2.0默认签发的令牌是一种不透明字符串,也就是Opaque Token。它本身没有任何含义,就是一串随机字符,像"eyJhbGciOi..."这种(其实这串是JWT的样子,但Opaque Token看起来更接近一串乱码)。服务端收到这个令牌后,必须拿着它去授权服务器(或者Token Store)查询,才能知道这个令牌对应哪个用户、什么权限、有没有过期。
好处是安全可控。你可以随时吊销任意一个令牌,只需要从存储里删掉它就行。坏处也很明显:每次请求都要查询一次令牌信息,在分布式系统里,这个"查询"的网络开销和依赖关系会让链路变长。如果认证中心挂了,所有服务都会受影响。
3.2 JWT的结构与自包含特性
JWT(JSON Web Token)则完全不同。它是一种自包含的令牌,意思是说用户信息、权限、过期时间全部写在了令牌本身里,服务端拿到JWT后只需要验签,不需要访问授权服务器就能解析出用户身份。
一个标准JWT由三部分组成:Header(头部)、Payload(负载)、Signature(签名)。Header里通常声明了签名算法(比如RS256);Payload里存放标准声明(sub、exp、iat等)和自定义字段(比如userId、userName、authorities);Signature是对前两部分做了签名运算后的结果。
这里我给一个简单的JWT示例结构:
{ "header": { "alg": "RS256", "typ": "JWT" }, "payload": { "sub": "10086", "userName": "zhangsan", "authorities": ["ROLE_USER", "ROLE_ADMIN"], "exp": 1735689600, "iat": 1735686000, "jti": "a1b2c3d4" } }exp是过期时间的时间戳,iat是签发时间,jti是唯一标识,后面做黑名单吊销就靠它。
资源服务器拿到JWT后,用配置好的公钥验签,验签通过后直接从Payload里取出用户信息和权限,整个过程不需要发起任何远程调用。这就是JWT在分布式系统里如此受欢迎的根本原因:性能好、无状态、可水平扩展。
3.3 我为什么建议分布式系统优先选JWT
这不是绝对的,但大多数场景下JWT确实是更合适的选择。核心原因有三个。
第一个是性能。Opaque Token每次都要回源查询,意味着你每次请求都要经过一次网络IO,如果访问量大了,认证中心扛不住,整个系统的瓶颈就落在Token校验这一环。JWT本地验签,消耗的只是CPU做一次非对称解密运算,这在微服务场景下的优势非常明显。
第二个是扩展性。你的系统可能有十几个微服务,如果每个微服务都需要校验Token,Opaque Token会让每个服务的鉴权逻辑都强依赖于认证中心的可用性。JWT则让每个服务变成了一个完全独立的校验单元,只要有公钥就能干活。
第三个是信息传递。JWT里可以携带用户信息,比如用户ID、角色列表、所属部门。这样下游服务不用为了拿用户详情再调一次用户服务,省掉一次内部调用。这在链路长的业务场景里能明显降低延迟。
当然JWT也有缺点,最大的两个:一是令牌一旦签发,在过期之前没法真正做到"立即失效"(除非引入黑名单机制);二是令牌体积比Opaque Token大,每次请求都要带上,略有带宽开销。但这两个问题在实际工程里都有成熟的缓解方案,后面我会讲到。
4. 模式选择:分布式系统内部到底用授权码模式还是密码模式
4.1 五种Grant Type的适用场景快速梳理
OAuth2.0定义了多种授权模式:授权码模式(authorization_code)、简化模式(implicit)、密码模式(password)、客户端凭证模式(client_credentials)和刷新令牌模式(refresh_token)。
分布式系统内部做认证,最常纠结的就是授权码模式和密码模式怎么选。先说结论:如果是应用系统内部的用户登录(自己开发的账号体系),并且前端是浏览器/App,优先用授权码模式;如果是在服务间做机器对机器的认证,用客户端凭证模式更合适;密码模式在OAuth2.1草案里已经被剔除了,不太推荐。
4.2 授权码模式的完整流程
授权码模式是OAuth2.0里最完善、最安全的流程。用一个门禁卡来类比:你进公司大楼不能直接拿身份证刷进去,而是先去前台登记,前台验证你是员工后,给你一张临时门禁卡,你刷卡进楼,门禁系统再验证卡的有效性。
具体到授权码模式:
- 用户访问前端应用,前端检测到未登录
- 前端将用户重定向到认证中心的登录页
- 用户在认证中心输入账号密码登录
- 认证中心验证通过后,返回一个授权码(Authorization Code),通过前端回调地址传回来
- 前端拿着授权码,在后端通过服务端通信换取Access Token和Refresh Token
- 后续请求带上Access Token访问资源服务器
这里的核心安全性在于:Access Token从未暴露给浏览器。浏览器只拿到授权码,授权码是一次性的、短期的,并且必须配合客户端密钥才能换取Token。即使授权码被截获,没有客户端密钥也换不了Token。
我实际体验下来的感觉是,Spring Security OAuth2.0对授权码模式的支持已经很完善了,配置好Authorization Server和Client后,很多东西都是开箱即用的。但如果你想自定义登录页、接入自己的用户体系,就需要做不少定制开发。
4.3 密码模式的简化与风险
密码模式就简单粗暴很多:用户直接把用户名密码交给客户端,客户端拿用户名密码去授权服务器换Token。这个模式适合"客户端本身就是官方应用"的场景,比如移动端App,因为App本身就是官方出品,用户把密码交给它属于合理行为。
但密码模式有个致命问题:用户密码会被客户端拿到,如果客户端被攻破,用户密码直接泄露。而在分布式系统里,"客户端"可能是另一个微服务,如果是服务间调用,密码模式意味着你要把用户的账号密码传给下游服务,这显然是不可接受的。另外令牌一旦泄露,密码模式没有授权码那一层保护,风险更大。
所以在分布式系统内部做用户认证,我的实际建议是:有浏览器参与的场景,用授权码模式,安全性和用户体验都能兼顾;没有用户参与的纯服务调用,用client_credentials模式,两个服务之间用客户端ID和密钥互认,不需要携带用户身份。
4.4 客户端凭证模式在服务间调用里的使用
服务间调用还有一个常见需求是"不需要代表用户,只需要证明我有权限调用你"。比如定时任务服务要调用订单导出的接口,这里不需要任何用户信息,只需要证明"我是合法的定时任务系统"。这时用client_credentials模式最合适:定时任务服务用自己的client_id和client_secret去认证中心换一个只属于应用的Token,然后拿这个Token去调用目标服务。
这个模式在Spring Security OAuth2.0里的配置非常简单,只需要在认证中心配置好客户端,在资源服务器配置好Token校验规则,调用方使用RestTemplate或者FeignClient带上Token即可。
5. 分布式认证方案落地:Spring Security OAuth2.0关键配置实操
5.1 授权服务器核心配置
在Spring Security OAuth2.0里,授权服务器通常通过继承AuthorizationServerConfigurerAdapter来实现(旧版做法,新版Spring Authorization Server已经改为配置类方式,但核心概念一致)。
实际项目中我倾向于把授权服务器独立成一个服务,专门做统一认证。这里给出一个简化版的授权服务器核心配置:
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private AuthenticationManager authenticationManager; @Autowired private DataSource dataSource; @Autowired private JwtTokenStore tokenStore; @Autowired private JwtAccessTokenConverter jwtAccessTokenConverter; @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.jdbc(dataSource) .withClient("gateway-client") .secret("{noop}gateway-secret") .scopes("read", "write") .authorizedGrantTypes("authorization_code", "refresh_token", "client_credentials") .redirectUris("http://localhost:8081/login/oauth2/code/gateway") .accessTokenValiditySeconds(7200) .refreshTokenValiditySeconds(86400); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authenticationManager(authenticationManager) .tokenStore(tokenStore) .accessTokenConverter(jwtAccessTokenConverter) .userDetailsService(userDetailsService); } @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } }有几个细节值得注意。第一,ClientDetails存在数据库里,而不是写在代码里。这样运营人员可以通过管理后台动态增删客户端,不用发版重启。第二,accessTokenValiditySeconds我配了两小时,refreshTokenValiditySeconds配了一天,这个值要根据业务场景调整,如果用户的权限变更频繁,AccessToken有效期要短一点。第三,tokenKeyAccess配成permitAll(),意味着资源服务器可以访问认证中心的公钥接口来获取验证JWT签名的公钥。checkTokenAccess配成isAuthenticated(),是给使用Opaque Token的资源服务器预留的检查端点。
5.2 JWT签名密钥的两种方案
JWT做签名时,密钥有两种选择:对称加密(HMAC256)和非对称加密(RS256)。这是一个非常重要且容易踩坑的点。
对称加密使用同一个密钥做签名和验签。配置简单,但问题很致命:所有资源服务器都共享同一个密钥,任何一个资源服务器被攻破,攻击者就可以自己伪造Token,整个系统全线崩溃。而且密钥的分发维护也是一件麻烦事,每加一个新服务你都要去把密钥复制一份。
非对称加密则用私钥签名、公钥验签。私钥只保存在授权服务器,资源服务器只保存公钥。授权服务器用私钥签发Token,资源服务器用公钥校验签名。就算某个资源服务器被攻破,攻击者拿到公钥也无法伪造Token,因为伪造需要私钥。安全边界一下就清晰了。
所以生产环境必须用非对称加密。Spring Security OAuth2.0里可以通过配置KeyPair来实现:
@Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setKeyPair(new KeyStoreKeyFactory( new ClassPathResource("jwt.jks"), "keystore-password".toCharArray()) .getKeyPair("jwt")); return converter; }这里用的是JKS密钥库,里面放着一对RSA密钥。授权服务器加载私钥做签名,资源服务器通过配置公钥或者访问认证中心的公钥端点来做验签。
我强烈建议用KeyStore来管理密钥,而不是直接用一个字符串私钥。因为KeyStore本身有密码保护,而且密钥的备份、轮换都更方便。如果你把私钥直接写在配置文件里,一旦配置泄露,整个认证体系就废了,这属于最低级的错误。
5.3 资源服务器的统一接入方式
资源服务器就是各个业务微服务。核心配置是继承ResourceServerConfigurerAdapter,并指定Token校验方式。这里有一个很关键的点:资源服务器怎么拿到公钥。
常见做法有两种。第一种是直接把公钥写到每个服务里,配置一下验签公钥;第二种是配置认证中心的公钥接口地址,资源服务器启动时自动拉取公钥并缓存。第二种方式更灵活,推荐用这种方式,公钥轮换的时候不需要改动每个服务。
资源服务器核心配置如下:
@Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) throws Exception { resources.resourceId("order-service") .tokenStore(jwtTokenStore()) .stateless(true); } @Override public void configure(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() .antMatchers("/api/order/**").authenticated() .antMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN") .anyRequest().permitAll(); } @Bean public JwtTokenStore jwtTokenStore() { return new JwtTokenStore(jwtAccessTokenConverter()); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setAccessTokenConverter(new DefaultUserAuthenticationConverter()); converter.setVerifierKey(getPublicKeyFromAuthServer()); return converter; } }stateless(true)意味着资源服务器不创建Session,每次请求都只通过Token来识别用户。这一点在分布式系统里很重要:无状态才能水平扩展。你加十台实例,每台的行为完全一样,不用担心Session同步。
5.4 网关闸门:统一鉴权还是分散鉴权
分布式系统里,网关是流量的总入口。所以我倾向于在网关层做一层"粗粒度"的Token合法性校验,把非法请求直接挡在门外;到了各个微服务,再做"细粒度"的权限校验,比如某个接口要求ROLE_ADMIN才能访问。
网关层做的事情很简单:从请求头里拿到Authorization字段,取出Token,调用认证中心的公钥接口或者本地公钥做一次验签,验证Token是否有效、是否过期。如果无效,直接返回401;如果有效,把解析出来的用户信息放进请求头,继续转发给下游服务。
这里要注意一个设计细节:网关层不要做太重的权限判断,比如"这个用户是否有权限访问订单接口",这种业务相关的判断应该留给下游服务。为什么?因为网关是公共组件,如果权限规则写在网关里,意味着每次新增一个权限规则都要改网关并重启,这会成为整个系统的瓶颈。网关只做"你是不是一个合法的用户",具体"你能访问什么资源"由各服务自己决定。
我用Gateway实现了一个简单的全局过滤器,核心逻辑就是解析Bearer Token,然后放入请求头:
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private final JwtTokenUtil jwtTokenUtil; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { Claims claims = jwtTokenUtil.parseToken(token); ServerHttpRequest mutatedRequest = exchange.getRequest().mutate() .header("X-User-Id", claims.get("userId").toString()) .header("X-User-Name", claims.get("userName").toString()) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (Exception e) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } } return chain.filter(exchange); } @Override public int getOrder() { return -100; } }注意我把解析出来的用户ID和用户名放进了X-User-Id和X-User-Name请求头,下游服务只需要从前端请求头里取这两个值就能拿到当前用户信息。这样下游服务根本不用感知OAuth2.0的存在,它们只需要知道"网关保证了这个请求是合法用户发出的"。这个设计能大大降低各服务的接入成本,可以说是分布式认证方案里非常实用的一个模式。
6. 令牌生命周期管理:签发、刷新、吊销,一个都不能少
6.1 Access Token与Refresh Token的分工
分布式系统里,令牌不能永久有效,也不能太频繁让用户重新登录。我的方案是Short-lived Access Token + Long-lived Refresh Token的经典组合。
Access Token有效期我一般设两小时,太短会频繁失效,太长又会增加泄露风险。Refresh Token有效期设一天或者一周,取决于业务对安全性的要求。Access Token过期后,客户端拿着Refresh Token去认证中心换一个新的Access Token,认证中心验证Refresh Token有效后签发新的Token,整个过程用户无感知。
这里有个特别容易踩的坑:Refresh Token换新Access Token时,是否要返回新的Refresh Token?有些实现会每次刷新都换一个新的Refresh Token保证安全,有些实现则保持Refresh Token不变。具体取决于Spring Security OAuth2.0里refreshTokenValiditySeconds和AccessTokenValiditySeconds的配置,以及你是否实现了Refresh Token轮换的逻辑。如果不做轮换,一旦Refresh Token泄露,攻击者可以一直续期,风险持续存在。我做项目时会配置成每次刷新都生成新的Refresh Token,旧的就作废。
6.2 令牌吊销:JWT的天然缺陷与弥补方案
JWT无法在签发后主动失效,这是它的基因决定的。用户修改密码后,理论上旧Token在过期前仍然有效,这是不可接受的。
解决办法是引入Token黑名单。我习惯用Redis来实现,当用户登出或修改密码时,把当前Token的唯一标识jti写入Redis,并设置过期时间等于Access Token剩余有效时间。资源服务器每次校验Token时,除了验签和检查过期时间,再去Redis里查一下jti是否在黑名单里。
用Redis做黑名单,还有两个细节要注意:一是设置过期时间时用"Token剩余有效期"而不是固定时间,这样黑名单里的键不会残留太久;二是Redis里存的key要加上前缀,比如token:blacklist:{jti},方便运维排查和清理。
6.3 会话登出与SSO单点登出
分布式系统里登出不是你清掉前端的LocalStorage就完事。用户的Token可能同时存在于多个端,比如浏览器、手机App、还有服务端缓存。登出操作必须做到让用户在所有端都失效。
对我而言,工程实现上能达到的最佳效果是"消息通知 + 黑名单清理"。用户主动登出时,业务服务调用认证中心的登出接口,认证中心把当前用户的Token加入黑名单,同时通过消息队列通知其他相关服务清除本地缓存的用户信息。如果订阅了Spring Security OAuth2.0的TokenStore事件,还可以在TokenRevoked事件里统一做清理。
7. 分布式认证方案里的安全加固要点
7.1 Token传输:必须用HTTPS和Bearer Scheme
JWT如果通过明文HTTP传输,等于把门钥匙挂在门口。分布式系统里任何一次网络请求涉及Token,必须走HTTPS。而在逻辑层面,客户端在请求头里使用Authorization: Bearer {token},这个Scheme本身就是一种安全约定。
此外还需要防范CSRF攻击,登录接口要做好防暴力破解,授权码模式里的client_secret绝不能暴露给前端浏览器。这些细节其实跟单体应用的安全要求一样,只不过在分布式系统里,攻击面更大,网关、各个微服务、认证中心之间的网络链路都要纳入考虑。
7.2 网关层防Token重放与基本风控
在网关层做Token校验时,我还会顺便做一下请求异常行为检测。比如同一个Token在短时间内从多个不同IP发起请求,或者同一个IP在短时间内大量请求不同用户的Token,这些都属于异常信号,网关可以直接拦截并打入风控名单。虽然Spring Security OAuth2.0本身不提供这些能力,但在网关层做基础的风控逻辑,能帮助企业提前发现Token泄露问题。
7.3 用户权限变更后的生效延迟
JWT的Payload里一般会携带authorities(权限列表)。但权限变了之后,Access Token里的权限还是旧的,这就导致用户即使被取消权限,在Token过期前依然能访问受保护资源。
我的建议是:权限敏感性高的操作,不要完全依赖Token里的权限信息,而是让资源服务器在关键接口上再查一次最新的权限库,或者利用Redis缓存用户最新的权限集合,Authorization鉴权时以实时查询结果为准。比如用户被管理员设置为禁言,你可以把"禁用状态"用短TTL缓存到Redis,服务端鉴权时先查缓存再决定是否放行,这样就绕开了JWT权限滞后的缺陷。
8. 按业务场景选型与部署新服务流程
8.1 常见分布式系统的认证实现思路对比
| 方案 | 会话管理方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 传统Session + 共享存储 | Redis/Memcached集中存Session | 开发成本低、近似单体体验 | 耦合存储组件、横向扩展受缓存层限制 | 初创期小规模系统 |
| 服务端Token(Opaque Token) | Token存储在Token Store | 可随时吊销、逻辑简单 | 每次请求回源查询、依赖认证中心 | 对安全要求极高、令牌需要频繁吊销 |
| JWT自包含Token | 无状态,本地验签 | 性能好、无共享依赖、便于传递用户信息 | 无法主动失效、权限更新滞后 | 微服务规模较大、内部认证与授权场景 |
| JWT + Redis黑名单 | 无状态验签 + 黑名单兜底 | 兼顾性能与可吊销性 | 需要额外维护Redis黑名单 | 大多数中大型分布式业务系统 |
这个表格是我在做方案选型时最常用的对照逻辑。可以看到没有一种方案是完美的,关键是你愿意接受哪一方面的取舍。以我接手的几个项目来看,如果团队规模不大、微服务数量在十个以内、Token校验链路不敏感,那么JWT + Redis黑名单是最均衡的方案。
8.2 新服务接入认证体系的标准流程
一个新微服务要接入这套分布式认证体系,大致要经历以下几步:
- 在认证中心注册一个资源服务ID(resourceId),备案接口权限说明
- 确定这个服务的接口需要什么权限(登录可访问 / 指定角色可访问 / 内部服务可调用)
- 引入Spring Security和OAuth2.0依赖,继承ResourceServerConfigurerAdapter
- 配置JWT验签公钥(从认证中心拉取公钥的地址,或直接配置公钥字符串)
- 在权限表达式里写清楚URL与角色的对应关系,比如/order/create需要ROLE_USER
- 联调测试:获取Token -> 调用网关 -> 转发到服务 -> 完成鉴权
这一套流程走下来,一个新服务接入认证中心基本在半天内就能搞定。稳定运行后,后面的服务都可以照着这个模板来做,实现真正的标准化接入。
8.3 部署架构:认证中心独立成军的理由
我强烈建议把你认证中心部署为一个独立的服务,最好不要跟业务代码混在一起。原因有三点:
第一,认证中心是全局安全边界,它应当拥有独立的数据存储(用户表、客户端表、令牌记录),并且访问权限严格限制,单独部署意味着攻击者想要接触到认证中心的数据,必须先穿过你为它单独设置的防护。
第二,认证中心的网络流量特点是高频小包,业务服务的流量特点是业务差异大。混在一起部署,各自扩缩容会互相影响。
第三,认证中心将来大概率要同时服务多个端,比如Web端、App端、内部系统、第三方开放平台。独立成军后,你可以只对它做接口层面的兼容扩展。
9. 高频问题排查与避坑速查表
9.1 406错误:JWT过期后资源服务器报错
场景:用户在操作页面时突然看到白屏,后端日志打出了InvalidTokenException或Access token expired。
排查思路:先看Token过期时间,再查客户端有没有正确携带Authorization请求头,最后看资源服务器与认证服务器之间的时钟是否同步。我遇到过不止一次因为服务器时间偏差导致Token被判定过期的奇葩问题,明明过期时间还没到,但资源服务器的本地时间比认证服务器快了一分钟,结果Token提前报废。建议全链路开启NTP时间同步。
9.2 RSA密钥不一致
场景:资源服务器验签时报SignatureException,提示JWT signature does not match。
这种问题95%是公钥和私钥不匹配造成的。排查时可以走一遍认证中心的/token_key接口看看返回的公钥,再去资源服务器对比配置的公钥;如果公钥是从认证中心自动拉取的,检查一下拉取后缓存的时机,密钥轮换后没有刷新缓存。
9.3 黑名单没生效
场景:用户登出后,拿旧Token依然能调用接口。
排查时先确认黑名单有没有写入Redis、TTL设置是否合理、资源服务器过滤器的执行顺序是否在鉴权之前。我在代码里遇到过过滤器顺序写反的情况,鉴权逻辑先执行了,根本没走到查黑名单的环节。所以黑名单检查逻辑一定要写在资源服务器过滤器链里、权限判断之前。
9.4 内部服务调用时Token透传失败
场景:服务A收到用户Token后,要调用服务B,但服务B反馈没有权限。
问题出在服务A调用服务B时没有把Token透传过去。用RestTemplate时必须在RequestInterceptor里把Authorization请求头塞进去,用Feign同样要配置RequestInterceptor。很多新手容易忽略这一步,导致链路中间某一环丢失Token,最终下游服务拿不到用户身份。
@Bean public RequestInterceptor requestTokenInterceptor() { return requestTemplate -> { // 这里用RequestContextHolder获取当前请求 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes != null) { String authorization = attributes.getRequest() .getHeader("Authorization"); if (authorization != null) { requestTemplate.header("Authorization", authorization); } } }; }9.5 权限配置的坑
在Spring Security OAuth2.0里,hasRole("ADMIN")会自动加ROLE_前缀,而hasAuthority("ROLE_ADMIN")不会。如果你的JWT里放的是"ROLE_ADMIN",那么用hasRole("ADMIN")是可以的;如果JWT里放的是"ADMIN",用hasRole("ADMIN")反而匹配不上。这种细节问题最容易让联调阶段卡壳,建议在写权限表达式之前先明确JWT里存的权限格式。
9.6 避坑速查表汇总
| 问题 | 现象 | 快速定位手段 | 解决办法 |
|---|---|---|---|
| 时钟不同步 | 正常Token被判过期 | 比对资源服务器和认证服务器当前时间 | 启用NTP时间同步 |
| RSA密钥不匹配 | JWT签名验签失败 | 对比公钥配置 | 重新获取并更新公钥 |
| Token黑名单失效 | 登出后Token仍可用 | 检查Redis中jti | 调整过滤器顺序 |
| Token透传失败 | 下游服务401 | 打印转发请求头 | 配置RequestInterceptor |
| 权限前缀混淆 | 有权限但被拒绝 | 解密JWT查看authorities | 统一权限格式与表达式 |
10. 最后分享几个我的实操心得
这几个坑和数据都是我实际项目中学到的经验,老实说也付出了不少加班的代价。
第一个心得是,Token有效期不能抄网上配置。网上很多教程写accessTokenValiditySeconds=3600、refreshTokenValiditySeconds=2592000,那是30天。但实际业务里Refresh Token有效期越长,泄露风险越大。我后来形成了自己的标准:普通用户Access Token两小时、Refresh Token一天;管理员操作的敏感服务Access Token半小时。配合滑屏操作,用户体感上没什么差异,但安全边界比原来干净太多。
第二个心得是,网关层面的Token校验尽量用本地公钥验签,不要每次请求都通过HTTP去认证中心调check_token接口。我最早一版就是这么干的,上线后认证中心压力暴增,一个内部服务每秒几十次请求,导致认证中心和业务服务之间的网络经常拥堵。后来改成网关启动时拉取一次公钥并缓存到本地,验签全部本地完成,认证中心只需要处理登录和刷新Token的请求,压力瞬间降下来了。
第三个心得是,权限变更的实时性问题一定要在设计阶段想清楚。如果你们的业务里有运营人员封禁用户、修改角色权限这类高频操作,纯JWT方案根本撑不住。要么缩短Access Token有效期,要么在关键接口上加实时权限缓存,最好两者并行。别等到线上出现"用户都被封了还能下单"的事故再处理,到那时候业务方的质疑会让你非常被动。
分布式系统的认证方案不是选择题,而是一道组合题。Spring Security OAuth2.0给了你一套标准化的骨架,但真正落到业务里,你要结合自身的用户规模、安全等级、服务拆分程度来做取舍。把这套方案理解透、配置对、坑踩过,你就能在大多数分布式项目里游刃有余。