背景
需要用 Java 访问一个被 SSO 保护的特殊接口获取信息。
方案设计
假如我们用浏览器来实现这个功能,步骤简单如下:
- 输入目标 API 地址
- (浏览器自动重定向到 SSO 登陆页面)
- 输入用户名密码登陆
- (浏览器重定向回到 API 地址,并附带认证信息)
- 获取目标 API 的资源信息
其中需要用户操作的是步骤 1 和步骤 3。
但要求是用 Java 来实现上述功能,关键点在于如何获取认证信息。有了认证信息,我们便能直接 call 目标 API。 所以我们需要模拟浏览器的登陆行为,此时的初步想法是 [HttpClient (v4.5)](https://hc.apache.org/httpcomponents-client-4.5.x/quickstart.html) + CookieStore :HttpClient 来发送 Http 请求,CookieStore 来缓存 Cookie 信息,相当于保存上下文(context)。
方案实施与问题描述
模拟浏览器登陆实现起来稍显复杂:
- 创建
CookieStore ,并添加到 HttpClient 来保存上下文信息。 HttpClient 访问目标 API,因为需要 SSO 登陆,所以得到的 response 是一个 html 页面,即登陆页面。- 解析步骤 2 中返回的登陆页面,得到登陆表单提交的地址。
- 构建登陆请求,填入用户名密码信息,并用
HttpClient 提交登陆请求。 - 获取步骤 4 的 response。此时认证信息已经被添加到
CookieStore 中,HttpClient 可以直接访问目标 API。
简化后的代码如下:
CookieStore cookieStore = new BasicCookieStore();
HttpClient httpClient = HttpClientBuilder.create()
.setRedirectStrategy(new LaxRedirectStrategy())
.setDefaultCookieStore(cookieStore)
.build();
HttpResponse response = httpClient.execute(new HttpGet("{apiPath}"));
String result = EntityUtils.toString(response.getEntity());
Pattern pattern = Pattern.compile("<form id=\"login-form\" method=\"post\" name=\"login-form\" action=\"(.+?)\">");
Matcher matcher = pattern.matcher(result);
if (!matcher.find()) {
}
String loginUrl = matcher.group(1);
HttpPost httpPost = new HttpPost(loginUrl);
List<NameValuePair> nameValuePairs = new ArrayList<>();
nameValuePairs.add(new BasicNameValuePair("username", "{username}"));
nameValuePairs.add(new BasicNameValuePair("password", "{password}"));
httpPost.setEntity(new UrlEncodedFormEntity(nameValuePairs));
HttpResponse finalResponse = httpClient.execute(httpPost);
但结果是,finalResponse 的状态码是 403 Forbidden,说明认证并没有成功。
问题追踪
上述步骤是没有问题的,所以错误肯定出在 CookieStore 上,由于某种原因,认证信息相关的 cookie 没有正确获取到。
通过与浏览器的 Network Trace 对比发现,Java 版本确实丢失了某个关键 Domain 的 cookie,见如下二图。
经过一番 Google 搜索,并没有太大的收获,于是决定看源码来调试解决。以下是 HttpClient 的执行链路:
CloseableHttpClient.execute() -> InternalHttpClient.doExecute() -> RetryExec.execute() -> ProtocolExec.execute() -> MainClientExec.execute() -> HttpRequestExecutor.execute()
其中大多数步骤都是条件判断设置参数,较为关键的地方在 ProtocolExec.execute() 中:
this.httpProcessor.process(request, context);
final CloseableHttpResponse response = this.requestExecutor.execute(route, request,
context, execAware);
context.setAttribute(HttpCoreContext.HTTP_RESPONSE, response);
this.httpProcessor.process(response, context);
return response;
可以看到,在发送请求的前后都有 interceptor 来做处理,所以问题的线索就埋在 this.httpProcessor.process(response, context) 的逻辑之中。
public final class ImmutableHttpProcessor implements HttpProcessor {
@Override
public void process( final HttpResponse response, final HttpContext context) throws IOException, HttpException {
for (final HttpResponseInterceptor responseInterceptor : this.responseInterceptors) {
responseInterceptor.process(response, context);
}
}
通过 debug 发现,默认的 HttpClient 添加了两个 HttpResponseInterceptor ,分别是 ResponseProcessCookies 和 ResponseContentEncoding 。cookie 处理相关的逻辑就在这里!让我们来进去探个究竟。
public class ResponseProcessCookies implements HttpResponseInterceptor {
@Override
public void process(final HttpResponse response, final HttpContext context)
throws HttpException, IOException {
final HttpClientContext clientContext = HttpClientContext.adapt(context);
final CookieSpec cookieSpec = clientContext.getCookieSpec();
final CookieStore cookieStore = clientContext.getCookieStore();
final CookieOrigin cookieOrigin = clientContext.getCookieOrigin();
HeaderIterator it = response.headerIterator(SM.SET_COOKIE);
processCookies(it, cookieSpec, cookieOrigin, cookieStore);
}
private void processCookies(final HeaderIterator iterator, final CookieSpec cookieSpec, final CookieOrigin cookieOrigin,
final CookieStore cookieStore) {
while (iterator.hasNext()) {
final Header header = iterator.nextHeader();
try {
final List<Cookie> cookies = cookieSpec.parse(header, cookieOrigin);
for (final Cookie cookie : cookies) {
try {
cookieSpec.validate(cookie, cookieOrigin);
cookieStore.addCookie(cookie);
} catch (final MalformedCookieException ex) {
this.log.warn("Cookie rejected [" + formatCooke(cookie) + "] " + ex.getMessage());
}
}
} catch (final MalformedCookieException ex) {
if (this.log.isWarnEnabled()) {
this.log.warn("Invalid cookie header: \""
+ header + "\". " + ex.getMessage());
}
}
}
}
}
当调试到这里,发现 cookie 解析报错了,问题出在 cookieSpec.parse(header, cookieOrigin) ,默认的 HttpClient 对应的是 DefaultCookieSpec ,它会根据情况调用不同的子 CookieSpec 来处理:
public class DefaultCookieSpec implements CookieSpec {
private final RFC2965Spec strict;
private final RFC2109Spec obsoleteStrict;
private final NetscapeDraftSpec netscapeDraft;
}
走到了 NetscapeDraftSpec.parse() 方法中,此方法会对 cookie 的各个属性,如 secure ,httponly ,expres 分别调用对应的 handler 来处理:
for (int j = attribs.length - 1; j >= 0; j--) {
final NameValuePair attrib = attribs[j];
final String s = attrib.getName().toLowerCase(Locale.ROOT);
cookie.setAttribute(s, attrib.getValue());
final CookieAttributeHandler handler = findAttribHandler(s);
if (handler != null) {
handler.parse(cookie, attrib.getValue());
}
}
最终,在 BasicExpiresHandler 处理 expres 属性时,看到了报错:
public class BasicExpiresHandler extends AbstractCookieAttributeHandler implements CommonCookieAttributeHandler {
@Override
public void parse(final SetCookie cookie, final String value) throws MalformedCookieException {
if (value == null) {
throw new MalformedCookieException("Missing value for 'expires' attribute");
}
final Date expiry = DateUtils.parseDate(value, this.datepatterns);
if (expiry == null) {
throw new MalformedCookieException("Invalid 'expires' attribute: "
+ value);
}
cookie.setExpiryDate(expiry);
}
}
因为默认的 datepatterns 是 EEE, dd-MMM-yy HH:mm:ss z ,而我们 cookie 中的格式为 Sat, 23 Apr 2022 06:40:13 GMT ,格式不对应导致了错误。
问题解决
解决办法是换一个 CookieSpec :
CookieStore cookieStore = new BasicCookieStore();
RequestConfig requestConfig = RequestConfig.custom()
.setCookieSpec(CookieSpecs.STANDARD)
.build();
HttpClient httpClient = HttpClientBuilder.create()
.setRedirectStrategy(new LaxRedirectStrategy())
.setDefaultRequestConfig(requestConfig)
.setDefaultCookieStore(cookieStore)
.build();
历史上出过多个版本的 Cookie 规范,如 rfc2965,rfc2019,rfc6265 等,至于为什么默认 HttpClient 无法正确识别 cookie 的版本及格式也没有继续深究,或许是一个 bug 吧。
Reference
How to do a HTTP POST to a URL having SSO Authentication in Java or vbscript?
HttpClient HTTP state management
|