把微服务安全链路拆开看,其实就两件事:服务之间互相不认识怎么办,外部请求来了怎么确认身份。很多人一上来就去翻 Spring Security 文档,结果配置了半小时,连证书都还没生成。这篇文章就按我实际搭过的方式,把从证书生成到 JWT 验证的完整链路走一遍,POM、配置、yaml 全部贴出来,照着做 30 分钟内能跑通一条最小可用链路。适合刚开始搞微服务安全、被证书和 Token 绕晕的同学,也适合需要给内部系统快速加一层服务间认证的开发者。
我先说结论:这套方案不是一个重型的权限框架,它没有权限模型、没有用户角色管理,只解决“你是谁”和“你配不配进这个服务”的问题。业务层面的权限判断,你可以在这个底座上继续加。我见过很多团队一上来就想接一套完整的安全框架,结果被复杂配置拖垮,连最基本的链路都没跑通。所以这篇文章的核心思路就是:先跑通,再扩展。
1. 方案选型与整体设计思路
1.1 微服务安全到底要解决什么问题
微服务拆开之后,原来单体应用里的“登录态”就不太好用了。单体时代你往 Session 里塞个用户信息,所有请求都在同一个进程里处理,天然共享。服务拆分以后,用户请求要先经过服务 A,然后再由服务 A 调服务 B,这时 Service B 怎么知道这个请求是哪个用户发起的?如果沿用 Session 方案,要么所有服务共享同一个 Session 存储,要么每次调用都带上 Session ID 去查一次,前者把服务耦合死了,后者又慢又要处理分布式会话的复杂度。
另一个问题是服务之间的身份确认。以前所有模块在同一个进程里,互相调用不需要考虑身份。拆成独立进程之后,服务 A 调用服务 B 的时候,B 怎么确信来的就是 A,而不是某个伪装请求?这就是服务间身份认证,也是很多人容易漏掉的一层。外部的用户身份认证(JWT)和服务间的身份认证(证书)是两码事,不能混着谈。
我把这个链路拆成两层来设计:
| 层级 | 解决什么问题 | 常用手段 | 作用范围 |
|---|---|---|---|
| 传输层 | 服务间通信是否加密、双方是不是可信节点 | 双向 TLS(mTLS)、证书 | 服务到服务 |
| 应用层 | 当前请求是哪个用户、令牌是否有效 | JWT、OAuth2 Token | 客户端到服务 |
这两层缺一不可。只有 JWT 没有证书,服务之间的通信就是明文,Token 可以被截获,而且在调试环境里爬到包就能看到整个链路的数据。只有证书没有 JWT,服务只知道“你这个节点可信”,但不知道“这个请求代表哪个用户”,业务层面的鉴权就无从谈起。
1.2 为什么是 JWT 加双向证书这套组合
JWT 的好处一句话就能说清楚:无状态、可验证、跨服务传递方便。签发方对 Token 签名,验证方只需要拿到公钥就能验签,不需要回源查数据库,也不需要在各个服务之间同步 Session。这在微服务场景下非常合适——每个服务自己就能完成令牌校验,不需要依赖一个集中的认证服务。市面上很多 API 网关的令牌方案也是基于 JWT 的。
证书选双向 TLS,是因为它解决的是“通信双方身份是否可信”的问题,和 JWT 完全是两个维度。JWT 是业务身份,证书是节点身份。比如 auth-service 调用 business-service 的时候,business-service 的 Tomcat 在 TLS 握手阶段就会要求客户端出示证书,认证通过才继续处理请求,这就把“谁能调用我”这个判断下沉到了网络层,业务代码完全不用操心。就算写代码的人忘了加任何鉴权逻辑,不可信节点也连不上来。
为什么不直接上 Spring Security OAuth2 那套完整方案?一个字:重。如果你只是内部几个服务互相调用,引入一套授权服务器、客户端注册、Token 端点,光概念就够你学半天。JWT 加证书这套轻量组合,足够覆盖大多数中小规模微服务的真实需求,而且出了问题好排查——证书、Token 各管各的,不会出现一个配置错误影响全局的情况。
1.3 一条完整链路的全景拆解
我设计了一个最小可跑通的示例,包含两个服务:
- auth-service:负责登录,校验用户名密码(这里演示就写死一个账号),签发 JWT。
- business-service:业务服务,负责校验 JWT,同时要求客户端提供可信证书才能访问。
调用链路是这样的:客户端用 curl 或 Postman 访问 auth-service 的登录接口,拿到 JWT;然后用同一个客户端访问 business-service 的业务接口,请求里带上 JWT,而 TLS 握手阶段,business-service 会要求客户端提供证书。这里注意,你要意识到是“同一个客户端”在做这两件事,所以证书要在客户端侧准备好。在真实场景里,更常见的做法是网关统一做登录认证,拿到 JWT 后网关再带着 Token 去调下游服务,同时网关持有客户端证书完成 mTLS。我示例里省略了网关,但原理完全一样。
在这个链路中,auth-service 自己也要访问 business-service 吗?其实在我的 demo 里不需要,因为 JWT 是客户端拿到之后自己携带的。但真实场景中,服务间调用经常需要转发票据,这时候被调用方怎么知道调用方是谁?答案是调用方服务持有客户端证书,被调用方在 TLS 层验证书,在应用层验 JWT,两层都要过。后面我讲 yaml 配置的时候会把这两个层面的配置项都列出来。
2. 证书生成与基础环境准备
2.1 三张证书的体系设计
自签名证书可以直接作为服务器证书用吗?能用,但只解决加密问题,解决不了身份问题。因为自签名证书没有上级 CA 背书,客户端无法验证它是可信的。所以我推荐用本地自建 CA 的方式:先造一个 CA 根证书,然后用这个 CA 去签发各个服务的证书。
这个体系里通常有三张证书:
- CA 根证书:你信任的根,所有证书都由它签发。
- 服务端证书:给每个服务用,比如 business-service 的服务器证书。
- 客户端证书:给调用方用,比如 auth-service 或 curl 客户端。
信任关系很简单:谁持有 CA 根证书,谁就信任由这个 CA 签发的所有证书。所以在配置 truststore 的时候,只需要把 CA 根证书导进去就行,不用把每个服务的证书都导一遍。这在服务数量多的时候特别省事——新加一个服务,签发一张新证书,所有已经信任这个 CA 的客户端自动就能识别。
有人会问:为什么不用 keytool 直接生成自签名证书?也可以,但 keytool 生成的证书默认不带 Subject Alternative Name(SAN),而 Spring Boot 到新版对证书要求越来越严格,很多场景下浏览器和 HTTP 客户端会直接报错。用 openssl 加 SAN 扩展更可控,所以我的实操命令全用 openssl。
2.2 openssl 一条龙生成证书
完整的命令序列我贴在这里,你在自己机器上执行的时候只需要换一下 CN 和文件名。
# 1. 生成 CA 私钥 openssl genrsa -out ca.key 2048 # 2. 用 CA 私钥生成自签名根证书,有效期 10 年 openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt \ -subj "/CN=MyLocalCA" # 3. 生成服务端(business-service)私钥 openssl genrsa -out business-service.key 2048 # 4. 生成服务端证书签名请求(CSR) openssl req -new -key business-service.key -out business-service.csr \ -subj "/CN=business-service" # 5. 用 CA 签发服务端证书,有效期 365 天,带 SAN 扩展 openssl x509 -req -in business-service.csr -CA ca.crt -CAkey ca.key \ -CAcreateserial -out business-service.crt -days 365 -sha256 \ -extfile <(printf "subjectAltName=DNS:localhost,DNS:business-service") # 6. 生成客户端私钥和证书 openssl genrsa -out client.key 2048 openssl req -new -key client.key -out client.csr -subj "/CN=client" openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -out client.crt -days 365 -sha256 \ -extfile <(printf "subjectAltName=DNS:localhost,DNS:client")命令看着多,但逻辑就三步:造 CA、造证书请求、用 CA 签发。第 5 步那个-extfile一定要加,不加的话 Java 的 HTTP 客户端在 TLS 握手阶段会因 SAN 缺失拒绝连接,这是我之前踩过最典型的坑。DNS:localhost是为了方便本地调试,DNS:business-service是为了让服务通过内部域名访问时也能匹配上。
证书生成之后把 .key 和 .crt 合并成 PKCS12 格式,Spring Boot 认这个格式:
# 服务端 PKCS12 openssl pkcs12 -export -in business-service.crt -inkey business-service.key \ -name business-service -out business-service.p12 -passout pass:changeit # 客户端 PKCS12 openssl pkcs12 -export -in client.crt -inkey client.key \ -name client -out client.p12 -passout pass:changeit如果你想在 Java 的 keystore 里管理这些证书,也可以用 keytool 把 PKCS12 导成 JKS。但现在 JDK 默认就支持 PKCS12,而且这是跨平台标准格式,我建议直接沿用 PKCS12,别折腾 JKS 了。
2.3 导入信任库与密钥库的实操命令
服务端有两个库要准备:keystore 放自己的证书和私钥,truststore 放要信任的 CA 根证书。客户端只需要一个 truststore 放 CA 根证书。
生成信任库的命令:
keytool -import -trustcacerts -alias myca -file ca.crt \ -keystore truststore.p12 -storetype PKCS12 -storepass changeit -noprompt如果你直接在 Spring Boot 项目里用 classpath 引用证书,就把business-service.p12和truststore.p12放到src/main/resources目录下。注意:这只是演示,生产环境千万不能把含私钥的 keystore 提交到 Git 仓库,后面我会专门说敏感信息的处理。
这里有个容易被忽略的细节:生成 CA 私钥的时候,文件权限要收紧,chmod 600 ca.key。我见过同事把 ca.key 放到共享目录里忘了设权限,结果整个内网的证书体系名存实亡。证书私钥不保密,前面所有工作都白搭。
3. JWT 认证体系与密钥配置
3.1 JWT 三段结构速览
JWT 长这样:eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9.签名,用点分成三段。
- Header:声明算法和 Token 类型,常见的是
{"alg":"RS256","typ":"JWT"}。 - Payload:放业务声明,比如
sub(用户标识)、exp(过期时间)、iat(签发时间)、iss(签发者)。 - Signature:签名,保证前两段内容没被篡改。
这三段并不是加密的,是 Base64URL 编码。Payload 里的信息任何人拿解码工具都能读出来,所以千万别把密码、身份证号这类敏感信息往 JWT 里塞。我在之前的项目里看到有人把用户手机号直接塞进去,虽然调用链路上是 HTTPS,但 Token 本身是明文编码的,这就不该发生。JWT 里放用户 ID、角色、必要的业务属性就够了,一旦泄露,能暴露的信息也要控制在最小范围。
验证 JWT 的核心动作就一个:用公钥验证签名。签名有效,且校验了过期时间、签发者这些声明,就认为这个 Token 可信。因为验签只需要公钥,验证方不需要跟签发方保持会话或频繁通信,这就是我说它适合微服务的原因。
3.2 对称算法够用吗:非对称才是微服务常态
JWT 签名算法分为对称和非对称。
对称算法 HS256:签名和验签都用同一个密钥。好处是计算快,实现简单;坏处是持有密钥的各方都能生成 Token,一旦某个服务私钥泄露,整个系统的令牌就形同虚设。如果只有一个认证服务、其他服务都只负责验签,那其他服务也必须持有同一个密钥,这等于把签发权交到了每个验签服务手里,风险有点大。
非对称算法 RS256 / ES256:私钥签名,公钥验签。认证服务持有私钥,其他服务只需要公钥就能验证,公钥就算泄露了也无法伪造 Token,安全性好很多。因为微服务是多方参与的架构,我强烈建议直接用非对称算法,这也是现在比较主流的做法。
生成密钥对:
openssl genrsa -out jwt-private.pem 2048 openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem注意这里是 JWT 用的密钥对,跟前面 TLS 证书用的密钥对完全是两回事。一个是给 TLS 握手用的 X.509 证书体系,一个是给应用层验签用的 RSA 密钥。我在教别人搭这套链路的时候,几乎每次都有人搞混,把证书私钥拿来当 JWT 私钥用,或者把 JWT 公钥配到 SSL 的 truststore 里。这俩没有任何关系,各管各的。
RSA 密钥位数建议 2048 位起步,别用 1024,不安全。ES256 性能更好,密钥更短,但对于大部分业务系统来说 RS256 更成熟、更容易排查问题,密钥管理工具支持也广。先用 RS256 跑通,再考虑优化。
3.3 JWT 工具类实现与校验细节
我习惯用一个独立的 JwtService 来封装生成和解析逻辑,下面这段是基于常见的 JJWT 库写的核心逻辑:
// 生成 Token JwtBuilder builder = Jwts.builder() .setSubject(userId) .setIssuer("my-platform") .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expirationSeconds * 1000)) .claim("role", role) .signWith(getPrivateKey(), SignatureAlgorithm.RS256); String token = builder.compact(); // 解析 Token Jws<Claims> jws = Jwts.parserBuilder() .setSigningKey(getPublicKey()) .requireIssuer("my-platform") .build() .parseClaimsJws(token); Claims claims = jws.getBody();有几个细节是很多人容易忽略的:
第一,requireIssuer这种校验尽量加上。如果签发方和验证方都由你控制,那么签发者是一个固定的值。校验收发者可以在密钥泄露之外多一道防线,防止有人用另一个系统签发的 Token 打进来。
第二,过期时间的校验。JJWT 在parseClaimsJws的时候会自动校验exp,如果过期会抛ExpiredJwtException。但要注意客户端和服务端的时间差,如果服务端跟客户端时间偏差太大,用户可能刚登录就被提示 Token 过期。这就是为什么生产环境里服务的时钟同步很重要,至少要开 NTP。我们之前排查过一个问题:用户反映 Token 刚拿到就失效,最后发现是某个测试服务器的系统时间慢了整整十分钟。
第三,私钥的读取方式。在 Spring Boot 里你可以用@Value注入密钥文件路径,也可以把 PEM 内容放配置中心。但无论哪种方式,千万不要把私钥明文硬编码在代码里,这是最低要求。后面 yaml 配置部分我会给出一种比较合适的做法。
4. POM 依赖与 yaml 配置全解
4.1 POM 依赖版本对照与避坑
先看 auth-service 和 business-service 共用的核心依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>这里我用了jjwt 0.11.5,这个版本很稳定,网上大量资料也都对得上。如果你熟悉较新的版本也可以升级,但要注意 jjwt 0.12.x 的 API 有一些变化,比如parserBuilder()换成了parser(),如果你是照着老教程写代码,升级后编译会报错。
还有几个依赖坑要提一下:
第一,jjwt-impl和jjwt-jackson的 scope 一定要是 runtime。这不是我随便写的,是因为这两个包只在运行期被 jjwt-api 动态调用,编译期用不到。如果漏了 runtime,或者把三个包版本搞得不一致,运行时会出现Unable to discover JWK这类莫名其妙的错误。
第二,Spring Boot 的版本和 Spring Security 的自动配置有联动。如果你引入了spring-boot-starter-security,工程里默认会开启所有请求的认证拦截,启动的时候还会打印一条默认密码的日志。很多人第一次跑起来发现所有接口返回 401,就是这个原因。后面我会讲怎么用自定义 SecurityFilterChain 覆盖掉这个默认行为。
第三,如果服务之间是用 Feign 调用的,可能需要额外引入 Feign 的依赖。我下面的 example 里直接用 RestTemplate 演示,是把链路最简化。Feign 的配置原理类似,但多一层自定义RequestInterceptor和SSLContext的准备,等你跑通了再扩展也不迟。
4.2 application.yml 关键配置逐项说明
我把两个服务的 yaml 核心配置分开写。
business-service 的 application.yml:
server: port: 8443 ssl: enabled: true key-store: classpath:business-service.p12 key-store-type: PKCS12 key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit} key-alias: business-service trust-store: classpath:truststore.p12 trust-store-type: PKCS12 trust-store-password: ${SSL_TRUSTSTORE_PASSWORD:changeit} client-auth: want jwt: public-key: classpath:jwt-public.pem issuer: my-platform expiration-seconds: 3600逐个解释:
server.port:如果是 https,端口默认用 8443 比较合适,因为 443 一般需要 root 权限。server.ssl.key-store:服务端自己的 p12 文件,包含证书和私钥。server.ssl.key-store-password:p12 的密码,这里用了${SSL_KEYSTORE_PASSWORD:changeit}环境变量占位,本地开发给默认值。server.ssl.trust-store:信任库,里面放 CA 根证书。server.ssl.client-auth: want:这个值很关键。want表示客户端可以提供证书但不是强制,need表示必须提供证书。本地调试建议先用want,因为有时候用 Postman 调试忘了装客户端证书,最多是握手失败,不会把整个服务搞挂。确认一切正常之后可以改成need。
auth-service 的 application.yml:
server: port: 8442 ssl: enabled: true key-store: classpath:auth-service.p12 key-store-type: PKCS12 key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit} key-alias: auth-service trust-store: classpath:truststore.p12 trust-store-type: PKCS12 trust-store-password: ${SSL_TRUSTSTORE_PASSWORD:changeit} client-auth: want jwt: private-key: classpath:jwt-private.pem public-key: classpath:jwt-public.pem issuer: my-platform expiration-seconds: 3600认证服务需要同时持有私钥和公钥,私钥用来签名,公钥用来在需要的时候验签。业务服务只需要公钥,所以我在 yaml 里分开写。你可以在此基础上再拆配置中心,让私钥不出认证服务。
jwt.*这一段是我自定义的配置,Spring Boot 不会自动识别,需要你写一个配置类用@ConfigurationProperties(prefix = "jwt")绑进去。这样比@Value一个一个注入干净得多。
4.3 敏感信息保护的三种方式
先强调一句:私钥文件、密钥库密码、客户端证书统统不能出现在 Git 仓库里。我在标题里说“POM / 配置 / yaml 全给你”,意思是给你结构、给你模板,但真要用到生产环境,密码和密钥必须换成环境变量或配置中心动态注入。
三种常见方式:
- 环境变量:Spring Boot 的
${SSL_KEYSTORE_PASSWORD:changeit}语法就是读取环境变量SSL_KEYSTORE_PASSWORD。本地给默认值方便开发,生产环境不提供默认值或给随机值,强制运维通过环境变量注入。 - 配置中心:如果你们用了 Nacos、Consul 这类配置中心,把证书密码和密钥文件的 Base64 内容放配置中心,服务启动时拉取到本地临时文件。这样证书轮换的时候只需要改配置中心而不用动服务。
- K8s Secret:如果部署在 Kubernetes 环境,把 p12 文件和 PEM 密钥做成 Secret,挂载到容器的指定路径,yaml 里用绝对路径引用。
我踩过的一个真实教训:当时开发环境所有服务共用一套默认密码changeit,结果测试环境被人扫到管理端口,顺手把 keystore 复制走,整个测试环境的 TLS 就形同虚设了。从那之后我要求所有环境变量里的密码必须单独设置,哪怕是内网也不能偷懒用默认值。
5. 实操过程:从零搭建一条完整链路
5.1 两个服务的 SSL 与 JWT 配置落地
先把证书和密钥按路径放好,目录结构这样:
auth-service/src/main/resources/ auth-service.p12 truststore.p12 jwt-private.pem jwt-public.pem business-service/src/main/resources/ business-service.p12 truststore.p12 jwt-public.pem注意 auth-service 持有的证书是auth-service.p12,business-service 持有的是business-service.p12。这里每个服务一个证书,各自签发给各自的 CN。如果服务多了,可以沿用同一套 CA,每个服务单独签一张证书。
然后配置类加载 yaml 里的自定义属性:
@Configuration @ConfigurationProperties(prefix = "jwt") public class JwtProperties { private Resource privateKey; private Resource publicKey; private String issuer; private long expirationSeconds; // getter/setter 省略 }把jwt-private.pem和jwt-public.pem放到 resources 目录后,Spring Boot 会把它们作为Resource注入。读取 PEM 内容的时候要注意,Java 原生不认识 PEM 格式,需要把中间的 Base64 内容解码成 PKCS8EncodedKeySpec 才能生成PrivateKey对象。很多人第一次就在这里栽跟头,照着网上抄了一段 Base64 解码代码,但没处理 PEM 头和尾的-----BEGIN PRIVATE KEY-----行。标准做法是去掉头和尾,再Base64.getDecoder()解码。
接下去配置 Spring Security。关键点是自定义 SecurityFilterChain,覆盖默认的 Spring Security 配置:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeHttpRequests(auth -> auth .antMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }这里的jwtAuthFilter是核心,它做的事情就是:从请求头拿Authorization: Bearer xxx,调用 JwtService 解析,解析成功就把用户 ID 放到 SecurityContext,解析失败就返回 401。为什么用addFilterBefore而不是直接改UsernamePasswordAuthenticationFilter?因为默认的 Spring Security 过滤器链会先做 Basic Auth 之类的处理,你把它替换掉,才能确保 JWT 是唯一的认证方式。我之前图省事直接配了个OncePerRequestFilter,结果 Spring Security 默认的登录页还是被加载,调了半天才发现是过滤器顺序的问题。
5.2 认证侧怎么签 JWT,业务侧怎么验 JWT
auth-service 需要暴露一个登录接口。为了演示方便,我用内存写死账号:
@PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest req) { if ("admin".equals(req.getUsername()) && "admin123".equals(req.getPassword())) { String token = jwtService.createToken("user123", "admin"); return ResponseEntity.ok(Map.of("token", token)); } return ResponseEntity.status(401).body(Map.of("error", "invalid credentials")); }调用者拿到 Token 后,再请求 business-service 的业务接口。业务侧的关键点在JwtAuthFilter里,它会拦截所有非放行路径,把 Token 解析出来。
@Component public class JwtAuthFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { Claims claims = jwtService.parseToken(token); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(claims.getSubject(), null, null); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (JwtException | IllegalArgumentException e) { SecurityContextHolder.clearContext(); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("invalid or expired token"); return; } } filterChain.doFilter(request, response); } }这段过滤器的判断逻辑我简化了一些,实际项目里建议把角色、权限也放进去。注意解析失败时要clearContext(),不然前一个请求的认证信息可能被残留到当前线程,这在并发场景下很致命。Tomcat 的线程池会复用线程,SecurityContextHolder默认是 ThreadLocal 存储,如果你不清空,下一次请求可能就会用上一次的用户身份,这是安全漏洞级别的 bug。
5.3 用 curl 和日志验证链路是否真的通了
本地启动两个服务后,先用 curl 访问 auth-service 的登录接口拿 Token:
curl -k -X POST https://localhost:8442/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"admin123"}'-k是跳过证书校验,因为本地用的自建 CA,没有在系统信任库里,开发调试可以这样用,生产环境千万别用。返回的 JSON 里就有 Token。
拿到 Token 之后,访问 business-service:
curl -k -X GET https://localhost:8443/api/hello \ -H "Authorization: Bearer <你的Token>"这里注意,curl -k只跳过了服务端证书的验证。business-service 如果配置了client-auth: need,curl 还需要带上客户端证书:
curl -k --cert client.crt --key client.key -X GET https://localhost:8443/api/hello \ -H "Authorization: Bearer <你的Token>"如果你看到 HTTP 200,说明 TLS 握手通过、JWT 校验通过,整条链路就通了。如果返回 401,说明 JWT 没通过;如果返回 400 Bad Request 或者握手失败,通常是证书出了问题。通过切换“不带证书”“带证书但带错 Token”“带正确证书但没有 Token”这几种组合,你可以快速定位问题出在哪一层。
日志层面,建议在 JwtAuthFilter 里打印关键信息,比如“token subject: user123, expired: false”,在 business-service 的入口打印“client cn: client, tls cipher: xxx”。这些日志是你后续排查生产问题的底气,别嫌多。
6. 常见问题与排查技巧
6.1 PKIX path building failed 八成是信任库问题
这个报错的本意是:TLS 握手时,客户端找不到能验证服务端证书的信任链。常见原因是 truststore 里没有导入 CA 根证书,或者服务端返回的证书链不完整。
排查思路分三步。第一步检查服务端配的证书是不是由同一个 CA 签发的:如果 business-service 的证书是 CA1 签的,而客户端 truststore 里放的是 CA2,那必然握手失败。第二步检查证书链完整性:openssl 签发的证书默认只包含服务端证书,不包含中间 CA。如果你用的是私有 CA 而且没有中间层,通常不会有链不完整的问题;但如果你引用了企业内部的 CA 体系,就可能需要把中间证书也拼到 keystore 里。第三步,用 openssl 手工验证连接:
openssl s_client -connect localhost:8443 -showcerts这个命令会显示完整握手过程和证书链,是排查 TLS 问题最直接的工具。执行之后看Verify return code,如果不是 0,那原因就写在上面了。
6.2 JWT 验签失败的五个高频原因
| 现象 | 根因 | 处理方法 |
|---|---|---|
JwtSignatureException | 验签公钥和签名私钥不匹配 | 检查 jwt-public.pem 是否和 jwt-private.pem 是一对 |
ExpiredJwtException | Token 已过期 | 检查系统时间、时区,确认过期时间配置 |
IncorrectClaimException | issuer 或 subject 不匹配 | 检查 yaml 里 issuer 和签发时的 issuer 是否一致 |
MalformedJwtException | Token 不是合法 JWT | 确认提取 Token 时没把Bearer前缀也传进去 |
UnsupportedJwtException | 算法版本不匹配 | 检查签发和验签用的算法是否都是 RS256 |
我遇到过最隐蔽的一次是:业务服务和认证服务在同一个 Git 仓库里,但密钥文件是两份,按理说一致。后来发现某次分支合并时,公共模块的jwt-public.pem被某个依赖构建过程悄悄替换成了另一个环境的文件,导致线上验签全部失败。这种低频问题最折磨人,所以我现在要求在日志里记录验签用的公钥指纹(SHA256),一查就知道是不是密钥对匹配。
6.3 链路排查的三板斧
第一板斧:分跳测试。先把客户端直接打 auth-service 的 login 接口,确保拿到 Token 正常。再把带 Token 的请求打到 business-service。哪一步挂了就查哪一层,不要把两层混在一起猜。
第二板斧:看日志关键点。TLS 层,看 Tomcat 有没有打印握手错误;应用层,看 JwtAuthFilter 有没有打印 Token 解析失败的原因。如果你发现日志里什么都没有,先确认是不是日志级别太低,把org.springframework.web和org.apache.catalina的级别调到 DEBUG 再试。
第三板斧:临时降级。本地调试的时候,把client-auth: want改成need之前,先保证在want模式下能正常通过。把jwt.issuer校验先关掉,验证签名链路没问题后再打开。这种“逐步收紧”的策略能让你快速定位问题,而不是一次性把所有安全开关都打开,出错了都不知道从哪查。
还有一个很实用的小技巧:用在线调试工具或者写个简单的java -jar命令行工具,把 Token 解码看一下 Payload 里的exp和iat是不是合理。这样可以排除“Token 根本就是写错了”这种低级问题,省得在服务端配置里找半天。
结尾
这套链路我前后搭过好几遍,最明显的体会是:证书折腾半天,往往不是命令问题,而是信任关系没理清楚;JWT 看着简单,真正出问题的大多是密钥不匹配这类低级失误。建议第一次搭的时候,把每一步的中间产物都保留下来,按上面的排查表逐项对照。后续如果服务多了,再引入配置中心把各个服务的证书和密钥统一管理,会比每个服务手工维护一份配置省心得多。最后再分享一个小经验:把证书有效期设长一点,但定期轮换,同时在监控里加一个证书到期前 30 天的告警,这能帮你避免很多凌晨三点被叫起来换证书的尴尬。