spring-projects / spring-security-kerberos

Spring Security Kerberos
https://spring.io/projects/spring-security-kerberos
185 stars 226 forks source link

401 when Server responds with multiple WWW-Authenticate due to HTTPCLIENT-1489 #133

Open micheljung opened 5 years ago

micheljung commented 5 years ago

Since this project uses Apache's httpclient 4.3.3, it suffers from https://jira.apache.org/jira/browse/HTTPCLIENT-1489

The issue has been resolved in httpclient 5.0 Alpha1 but users can't just upgrade because the API is incompatible.

If I come up with a workaround, I'll post it here or create a pull request.

micheljung commented 5 years ago

Here comes my workaround. Specify a class:

import org.apache.http.FormattedHeader;
import org.apache.http.Header;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.auth.AUTH;
import org.apache.http.auth.MalformedChallengeException;
import org.apache.http.impl.client.TargetAuthenticationStrategy;
import org.apache.http.message.BasicHeader;
import org.apache.http.protocol.HTTP;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.Args;
import org.apache.http.util.CharArrayBuffer;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

/**
 * Workaround for <a href="https://jira.apache.org/jira/browse/HTTPCLIENT-1489">HTTPCLIENT-1489</a>.
 */
public class HttpClient1489TargetAuthenticationStrategy extends TargetAuthenticationStrategy {

    private final String challengeName;

    public HttpClient1489TargetAuthenticationStrategy(String challengeName) {
        this.challengeName = challengeName;
    }

    /**
     * Generates a map of challenge auth-scheme =&gt; Header entries.
     *
     * @return map: key=lower-cased auth-scheme name, value=Header that contains the challenge
     */
    @Override
    public Map<String, Header> getChallenges(
            final HttpHost authhost,
            final HttpResponse response,
            final HttpContext context
    ) throws MalformedChallengeException {

        Args.notNull(response, "HTTP response");
        final Header[] headers = filterChallenge(response.getHeaders(AUTH.WWW_AUTH), challengeName);
        final Map<String, Header> map = new HashMap<>(headers.length);

        for (final Header header : headers) {
            final CharArrayBuffer buffer;
            int pos;
            if (header instanceof FormattedHeader) {
                buffer = ((FormattedHeader) header).getBuffer();
                pos = ((FormattedHeader) header).getValuePos();
            } else {
                final String s = header.getValue();
                if (s == null) {
                    throw new MalformedChallengeException("Header value is null");
                }
                buffer = new CharArrayBuffer(s.length());
                buffer.append(s);
                pos = 0;
            }
            while (pos < buffer.length() && HTTP.isWhitespace(buffer.charAt(pos))) {
                pos++;
            }
            final int beginIndex = pos;
            while (pos < buffer.length() && !HTTP.isWhitespace(buffer.charAt(pos))) {
                pos++;
            }
            final int endIndex = pos;
            final String s = buffer.substring(beginIndex, endIndex);
            map.put(s.toLowerCase(Locale.ROOT), header);
        }
        return map;
    }

    /**
     * Removes all but the specified {@code WWW-Authenticate} challenge.
     * <p>
     * For instance, the header:
     * <pre>
     *   WWW-Authenticate: X-MobileMe-AuthToken realm="Newcastle", Basic realm="Newcastle"
     * </pre>
     * becomes:
     * <pre>
     *   WWW-Authenticate: X-MobileMe-AuthToken realm="Newcastle"
     * </pre>
     * if this class has been instantiated with "X-MobileMe-AuthToken" or:
     * <pre>
     *   WWW-Authenticate: Basic realm="Newcastle
     * </pre>
     * if this class has been instantiated with "Basic". An exception is thrown if the specified
     * challenge could not be found.
     * </p>
     */
    private Header[] filterChallenge(Header[] headers) {
      // CAVEAT: Calling header.getElements() here is prone to error if the base64 string ends with "="
      return Arrays.stream(headers)
              .map(header -> Arrays.stream(header.getValue().split(","))
                      .map(String::trim)
                      .filter(headerElement -> headerElement
                              .toLowerCase(Locale.US)
                              .startsWith(challengeName.toLowerCase(Locale.US)))
                      .findFirst()
                      .orElseThrow(() -> new IllegalArgumentException("There must be exactly one challenge with name '"
                              + challengeName + "' in headers: " + Arrays.toString(headers)))
              )
              .map(headerElement -> new BasicHeader(AUTH.WWW_AUTH, headerElement))
              .toArray(Header[]::new);
    }
}

And create your own HttpClient, with the customized authentication strategy set:

  private static HttpClient buildHttpClient() {
    HttpClientBuilder builder = HttpClientBuilder.create();
    Lookup<AuthSchemeProvider> authSchemeRegistry = RegistryBuilder.<AuthSchemeProvider>create()
            .register(AuthSchemes.SPNEGO, new SPNegoSchemeFactory(true)).build();
    builder.setDefaultAuthSchemeRegistry(authSchemeRegistry);

    BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
    credentialsProvider.setCredentials(new AuthScope(null, -1, null), new NullCredentials());
    builder.setDefaultCredentialsProvider(credentialsProvider);

    builder.setTargetAuthenticationStrategy(new HttpClient1489TargetAuthenticationStrategy("negotiate"));

    return builder.build();
  }

  private static class NullCredentials implements Credentials {

    @Override
    public Principal getUserPrincipal() {
      return null;
    }

    @Override
    public String getPassword() {
      return null;
    }
  }