Realm.java

/*
 * Copyright 2010-2013 Ning, Inc.
 *
 * This program is licensed to you under the Apache License, version 2.0
 * (the "License"); you may not use this file except in compliance with the
 * License.  You may obtain a copy of the License at:
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
 * License for the specific language governing permissions and limitations
 * under the License.
 *
 */
package org.asynchttpclient;

import org.asynchttpclient.uri.Uri;
import org.asynchttpclient.util.AuthenticatorUtils;
import org.asynchttpclient.util.StringBuilderPool;
import org.asynchttpclient.util.StringUtils;
import org.jetbrains.annotations.Nullable;

import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;

import static java.nio.charset.StandardCharsets.ISO_8859_1;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static org.asynchttpclient.util.HttpConstants.Methods.GET;
import static org.asynchttpclient.util.MiscUtils.isNonEmpty;
import static org.asynchttpclient.util.StringUtils.appendBase16;
import static org.asynchttpclient.util.StringUtils.toHexString;
import org.asynchttpclient.util.MessageDigestUtils;

/**
 * This class is required when authentication is needed. The class support
 * BASIC, DIGEST, NTLM, SPNEGO and KERBEROS.
 */
public class Realm {

    private static final String DEFAULT_NC = "00000001";

    private final @Nullable String principal;
    private final @Nullable String password;
    private final AuthScheme scheme;
    private final @Nullable String realmName;
    private final @Nullable String nonce;
    private final @Nullable String algorithm;
    private final @Nullable String response;
    private final @Nullable String opaque;
    private final @Nullable String qop;
    private final String nc;
    private final @Nullable String cnonce;
    private final @Nullable Uri uri;
    private final boolean usePreemptiveAuth;
    private final Charset charset;
    private final String ntlmHost;
    private final String ntlmDomain;
    private final boolean useAbsoluteURI;
    private final boolean omitQuery;
    private final @Nullable Map<String, String> customLoginConfig;
    private final @Nullable String servicePrincipalName;
    private final boolean useCanonicalHostname;
    private final @Nullable String loginContextName;
    private final boolean stale;
    private final boolean userhash;

    private Realm(@Nullable AuthScheme scheme,
                  @Nullable String principal,
                  @Nullable String password,
                  @Nullable String realmName,
                  @Nullable String nonce,
                  @Nullable String algorithm,
                  @Nullable String response,
                  @Nullable String opaque,
                  @Nullable String qop,
                  String nc,
                  @Nullable String cnonce,
                  @Nullable Uri uri,
                  boolean usePreemptiveAuth,
                  Charset charset,
                  String ntlmDomain,
                  String ntlmHost,
                  boolean useAbsoluteURI,
                  boolean omitQuery,
                  @Nullable String servicePrincipalName,
                  boolean useCanonicalHostname,
                  @Nullable Map<String, String> customLoginConfig,
                  @Nullable String loginContextName,
                  boolean stale,
                  boolean userhash) {

        this.scheme = requireNonNull(scheme, "scheme");
        this.principal = principal;
        this.password = password;
        this.realmName = realmName;
        this.nonce = nonce;
        this.algorithm = algorithm;
        this.response = response;
        this.opaque = opaque;
        this.qop = qop;
        this.nc = nc;
        this.cnonce = cnonce;
        this.uri = uri;
        this.usePreemptiveAuth = usePreemptiveAuth;
        this.charset = charset;
        this.ntlmDomain = ntlmDomain;
        this.ntlmHost = ntlmHost;
        this.useAbsoluteURI = useAbsoluteURI;
        this.omitQuery = omitQuery;
        this.servicePrincipalName = servicePrincipalName;
        this.useCanonicalHostname = useCanonicalHostname;
        this.customLoginConfig = customLoginConfig;
        this.loginContextName = loginContextName;
        this.stale = stale;
        this.userhash = userhash;
    }

    public @Nullable String getPrincipal() {
        return principal;
    }

    public @Nullable String getPassword() {
        return password;
    }

    public AuthScheme getScheme() {
        return scheme;
    }

    public @Nullable String getRealmName() {
        return realmName;
    }

    public @Nullable String getNonce() {
        return nonce;
    }

    public @Nullable String getAlgorithm() {
        return algorithm;
    }

    public @Nullable String getResponse() {
        return response;
    }

    public @Nullable String getOpaque() {
        return opaque;
    }

    public @Nullable String getQop() {
        return qop;
    }

    public String getNc() {
        return nc;
    }

    public @Nullable String getCnonce() {
        return cnonce;
    }

    public @Nullable Uri getUri() {
        return uri;
    }

    public Charset getCharset() {
        return charset;
    }

    /**
     * Return true is preemptive authentication is enabled
     *
     * @return true is preemptive authentication is enabled
     */
    public boolean isUsePreemptiveAuth() {
        return usePreemptiveAuth;
    }

    /**
     * Return the NTLM domain to use. This value should map the JDK
     *
     * @return the NTLM domain
     */
    public String getNtlmDomain() {
        return ntlmDomain;
    }

    /**
     * Return the NTLM host.
     *
     * @return the NTLM host
     */
    public String getNtlmHost() {
        return ntlmHost;
    }

    public boolean isUseAbsoluteURI() {
        return useAbsoluteURI;
    }

    public boolean isOmitQuery() {
        return omitQuery;
    }

    public @Nullable Map<String, String> getCustomLoginConfig() {
        return customLoginConfig;
    }

    public @Nullable String getServicePrincipalName() {
        return servicePrincipalName;
    }

    public boolean isUseCanonicalHostname() {
        return useCanonicalHostname;
    }

    public @Nullable String getLoginContextName() {
        return loginContextName;
    }

    public boolean isStale() {
        return stale;
    }

    public boolean isUserhash() {
        return userhash;
    }

    @Override
    public String toString() {
        return "Realm{" +
                "principal='" + principal + '\'' +
                ", password='" + password + '\'' +
                ", scheme=" + scheme +
                ", realmName='" + realmName + '\'' +
                ", nonce='" + nonce + '\'' +
                ", algorithm='" + algorithm + '\'' +
                ", response='" + response + '\'' +
                ", opaque='" + opaque + '\'' +
                ", qop='" + qop + '\'' +
                ", nc='" + nc + '\'' +
                ", cnonce='" + cnonce + '\'' +
                ", uri=" + uri +
                ", usePreemptiveAuth=" + usePreemptiveAuth +
                ", charset=" + charset +
                ", ntlmHost='" + ntlmHost + '\'' +
                ", ntlmDomain='" + ntlmDomain + '\'' +
                ", useAbsoluteURI=" + useAbsoluteURI +
                ", omitQuery=" + omitQuery +
                ", customLoginConfig=" + customLoginConfig +
                ", servicePrincipalName='" + servicePrincipalName + '\'' +
                ", useCanonicalHostname=" + useCanonicalHostname +
                ", loginContextName='" + loginContextName + '\'' +
                '}';
    }

    public enum AuthScheme {
        BASIC, DIGEST, NTLM, SPNEGO, KERBEROS
    }

    /**
     * A builder for {@link Realm}
     */
    public static class Builder {

        private final @Nullable String principal;
        private final @Nullable String password;
        private @Nullable AuthScheme scheme;
        private @Nullable String realmName;
        private @Nullable String nonce;
        private @Nullable String algorithm;
        private @Nullable String response;
        private @Nullable String opaque;
        private @Nullable String qop;
        private String nc = DEFAULT_NC;
        private @Nullable String cnonce;
        private @Nullable Uri uri;
        private String methodName = GET;
        private boolean usePreemptive;
        private String ntlmDomain = System.getProperty("http.auth.ntlm.domain");
        private Charset charset = UTF_8;
        private String ntlmHost = "localhost";
        private boolean useAbsoluteURI;
        private boolean omitQuery;
        private Charset digestCharset = ISO_8859_1;   // RFC default
        /**
         * Kerberos/Spnego properties
         */
        private @Nullable Map<String, String> customLoginConfig;
        private @Nullable String servicePrincipalName;
        private boolean useCanonicalHostname;
        private @Nullable String loginContextName;
        private @Nullable String cs;
        private boolean stale;
        private boolean userhash;
        private @Nullable String entityBodyHash;

        public Builder() {
            principal = null;
            password = null;
        }

        public Builder(@Nullable String principal, @Nullable String password) {
            this.principal = principal;
            this.password = password;
        }

        public Builder setNtlmDomain(String ntlmDomain) {
            this.ntlmDomain = ntlmDomain;
            return this;
        }

        public Builder setNtlmHost(String host) {
            ntlmHost = host;
            return this;
        }

        public Builder setScheme(AuthScheme scheme) {
            this.scheme = scheme;
            return this;
        }

        public Builder setRealmName(@Nullable String realmName) {
            this.realmName = realmName;
            return this;
        }

        public Builder setNonce(@Nullable String nonce) {
            this.nonce = nonce;
            return this;
        }

        public Builder setAlgorithm(@Nullable String algorithm) {
            this.algorithm = algorithm;
            return this;
        }

        public Builder setResponse(String response) {
            this.response = response;
            return this;
        }

        public Builder setOpaque(@Nullable String opaque) {
            this.opaque = opaque;
            return this;
        }

        public Builder setQop(@Nullable String qop) {
            if (isNonEmpty(qop)) {
                this.qop = qop;
            }
            return this;
        }

        public Builder setNc(String nc) {
            this.nc = nc;
            return this;
        }

        public Builder setUri(@Nullable Uri uri) {
            this.uri = uri;
            return this;
        }

        public Builder setMethodName(String methodName) {
            this.methodName = methodName;
            return this;
        }

        public Builder setUsePreemptiveAuth(boolean usePreemptiveAuth) {
            usePreemptive = usePreemptiveAuth;
            return this;
        }

        public Builder setUseAbsoluteURI(boolean useAbsoluteURI) {
            this.useAbsoluteURI = useAbsoluteURI;
            return this;
        }

        public Builder setOmitQuery(boolean omitQuery) {
            this.omitQuery = omitQuery;
            return this;
        }

        public Builder setCharset(Charset charset) {
            this.charset = charset;
            return this;
        }

        public Builder setCustomLoginConfig(@Nullable Map<String, String> customLoginConfig) {
            this.customLoginConfig = customLoginConfig;
            return this;
        }

        public Builder setServicePrincipalName(@Nullable String servicePrincipalName) {
            this.servicePrincipalName = servicePrincipalName;
            return this;
        }

        public Builder setUseCanonicalHostname(boolean useCanonicalHostname) {
            this.useCanonicalHostname = useCanonicalHostname;
            return this;
        }

        public Builder setLoginContextName(@Nullable String loginContextName) {
            this.loginContextName = loginContextName;
            return this;
        }

        public Builder setStale(boolean stale) {
            this.stale = stale;
            return this;
        }

        public boolean isStale() {
            return stale;
        }

        public Builder setUserhash(boolean userhash) {
            this.userhash = userhash;
            return this;
        }

        public Builder setEntityBodyHash(@Nullable String entityBodyHash) {
            this.entityBodyHash = entityBodyHash;
            return this;
        }

        public @Nullable String getQopValue() {
            return qop;
        }

        public @Nullable String getNonceValue() {
            return nonce;
        }

        private static @Nullable String parseRawQop(String rawQop) {
            String[] rawServerSupportedQops = rawQop.split(",");
            String[] serverSupportedQops = new String[rawServerSupportedQops.length];
            for (int i = 0; i < rawServerSupportedQops.length; i++) {
                serverSupportedQops[i] = rawServerSupportedQops[i].trim();
            }

            // prefer auth over auth-int
            for (String rawServerSupportedQop : serverSupportedQops) {
                if ("auth".equals(rawServerSupportedQop)) {
                    return rawServerSupportedQop;
                }
            }

            for (String rawServerSupportedQop : serverSupportedQops) {
                if ("auth-int".equals(rawServerSupportedQop)) {
                    return rawServerSupportedQop;
                }
            }

            return null;
        }

        public Builder parseWWWAuthenticateHeader(String headerLine) {
            setRealmName(matchParam(headerLine, "realm"))
                    .setNonce(matchParam(headerLine, "nonce"))
                    .setOpaque(matchParam(headerLine, "opaque"))
                    .setScheme(isNonEmpty(nonce) ? AuthScheme.DIGEST : AuthScheme.BASIC);
            String algorithm = matchParam(headerLine, "algorithm");
            String cs = matchParam(headerLine, "charset");
            if ("UTF-8".equalsIgnoreCase(cs)) {
                this.digestCharset = UTF_8;
            }
            if (isNonEmpty(algorithm)) {
                setAlgorithm(algorithm);
            }

            String rawQop = matchParam(headerLine, "qop");
            if (rawQop != null) {
                setQop(parseRawQop(rawQop));
            }

            // Parse stale flag
            String staleStr = matchParam(headerLine, "stale");
            this.stale = "true".equalsIgnoreCase(staleStr);

            // Parse userhash flag
            String userhashStr = matchParam(headerLine, "userhash");
            this.userhash = "true".equalsIgnoreCase(userhashStr);

            return this;
        }

        public Builder parseProxyAuthenticateHeader(String headerLine) {
            setRealmName(matchParam(headerLine, "realm"))
                    .setNonce(matchParam(headerLine, "nonce"))
                    .setOpaque(matchParam(headerLine, "opaque"))
                    .setScheme(isNonEmpty(nonce) ? AuthScheme.DIGEST : AuthScheme.BASIC);
            String algorithm = matchParam(headerLine, "algorithm");
            if (isNonEmpty(algorithm)) {
                setAlgorithm(algorithm);
            }

            String rawQop = matchParam(headerLine, "qop");
            if (rawQop != null) {
                setQop(parseRawQop(rawQop));
            }

            String cs = matchParam(headerLine, "charset");
            if ("UTF-8".equalsIgnoreCase(cs)) {
                this.digestCharset = UTF_8;
            }

            // Parse stale flag
            String staleStr = matchParam(headerLine, "stale");
            this.stale = "true".equalsIgnoreCase(staleStr);

            // Parse userhash flag
            String userhashStr = matchParam(headerLine, "userhash");
            this.userhash = "true".equalsIgnoreCase(userhashStr);

            return this;
        }

        /**
         * Extracts the value of a token from a WWW-Authenticate or Proxy-Authenticate header line.
         * Handles both quoted values (token="value") and unquoted values (token=value).
         * Example: matchParam('Digest realm="test", algorithm=SHA-256', "realm") returns "test"
         * Example: matchParam('Digest algorithm=SHA-256', "algorithm") returns "SHA-256"
         */
        public static @Nullable String matchParam(String headerLine, String token) {
            if (headerLine == null || token == null) {
                return null;
            }
            // Look for token= (case-insensitive token match)
            int len = headerLine.length();
            int tokenLen = token.length();
            int i = 0;
            while (i < len) {
                int idx = headerLine.indexOf('=', i);
                if (idx == -1) {
                    return null;
                }
                // Walk backwards from '=' to find the start of the key (skip whitespace)
                int keyEnd = idx;
                while (keyEnd > i && headerLine.charAt(keyEnd - 1) == ' ') {
                    keyEnd--;
                }
                int keyStart = keyEnd - tokenLen;
                if (keyStart >= 0
                        && headerLine.regionMatches(true, keyStart, token, 0, tokenLen)
                        && (keyStart == 0 || headerLine.charAt(keyStart - 1) == ' ' || headerLine.charAt(keyStart - 1) == ',')) {
                    // Found matching token, now extract value
                    int valStart = idx + 1;
                    // skip whitespace after '='
                    while (valStart < len && headerLine.charAt(valStart) == ' ') {
                        valStart++;
                    }
                    if (valStart < len && headerLine.charAt(valStart) == '"') {
                        // Quoted value
                        int valEnd = headerLine.indexOf('"', valStart + 1);
                        if (valEnd == -1) {
                            return null;
                        }
                        return headerLine.substring(valStart + 1, valEnd);
                    } else {
                        // Unquoted value ��� terminated by ',' or end-of-string
                        int valEnd = valStart;
                        while (valEnd < len && headerLine.charAt(valEnd) != ',' && headerLine.charAt(valEnd) != ' ') {
                            valEnd++;
                        }
                        if (valEnd > valStart) {
                            return headerLine.substring(valStart, valEnd);
                        }
                        return null;
                    }
                }
                i = idx + 1;
            }
            return null;
        }

        private void newCnonce(MessageDigest md) {
            byte[] b = new byte[8];
            ThreadLocalRandom.current().nextBytes(b);
            byte[] full = md.digest(b);
            // trim to first 8 bytes ��� 16 hex chars
            byte[] small = Arrays.copyOf(full, Math.min(8, full.length));
            cnonce = toHexString(small);
        }

        private static byte[] digestFromRecycledStringBuilder(StringBuilder sb, MessageDigest md, Charset enc) {
            md.update(StringUtils.charSequence2ByteBuffer(sb, enc));
            sb.setLength(0);
            return md.digest();
        }

        private static MessageDigest getDigestInstance(String algorithm) {
            if ("SHA-512/256".equalsIgnoreCase(algorithm)) algorithm = "SHA-512-256";
            if (algorithm == null || "MD5".equalsIgnoreCase(algorithm) || "MD5-sess".equalsIgnoreCase(algorithm)) {
                return MessageDigestUtils.pooledMd5MessageDigest();
            } else if ("SHA-256".equalsIgnoreCase(algorithm) || "SHA-256-sess".equalsIgnoreCase(algorithm)) {
                return MessageDigestUtils.pooledSha256MessageDigest();
            } else if ("SHA-512-256".equalsIgnoreCase(algorithm) || "SHA-512-256-sess".equalsIgnoreCase(algorithm)) {
                return MessageDigestUtils.pooledSha512_256MessageDigest();
            } else {
                throw new UnsupportedOperationException("Digest algorithm not supported: " + algorithm);
            }
        }

        private byte[] ha1(StringBuilder sb, MessageDigest md) {
            // if algorithm is "MD5" or is unspecified => A1 = username ":" realm-value ":"
            // passwd
            // if algorithm is "MD5-sess" => A1 = MD5( username-value ":" realm-value ":"
            // passwd ) ":" nonce-value ":" cnonce-value

            sb.append(principal).append(':').append(realmName).append(':').append(password);
            byte[] core = digestFromRecycledStringBuilder(sb, md, digestCharset);

            if (algorithm == null || "MD5".equalsIgnoreCase(algorithm) || "SHA-256".equalsIgnoreCase(algorithm) || "SHA-512-256".equalsIgnoreCase(algorithm)) {
                // A1 = username ":" realm-value ":" passwd
                return core;
            }
            if ("MD5-sess".equalsIgnoreCase(algorithm) || "SHA-256-sess".equalsIgnoreCase(algorithm) || "SHA-512-256-sess".equalsIgnoreCase(algorithm)) {
                // A1 = HASH(username ":" realm-value ":" passwd ) ":" nonce ":" cnonce
                appendBase16(sb, core);
                sb.append(':').append(nonce).append(':').append(cnonce);
                return digestFromRecycledStringBuilder(sb, md, digestCharset);
            }
            throw new UnsupportedOperationException("Digest algorithm not supported: " + algorithm);
        }

        private byte[] ha2(StringBuilder sb, String digestUri, MessageDigest md) {

            // if qop is "auth" or is unspecified => A2 = Method ":" digest-uri-value
            // if qop is "auth-int" => A2 = Method ":" digest-uri-value ":" H(entity-body)
            sb.append(methodName).append(':').append(digestUri);
            if ("auth-int".equals(qop)) {
                sb.append(':');
                if (entityBodyHash != null) {
                    sb.append(entityBodyHash);
                } else {
                    // Hash of empty body using the current algorithm
                    sb.append(toHexString(md.digest()));
                }
            } else if (qop != null && !"auth".equals(qop)) {
                throw new UnsupportedOperationException("Digest qop not supported: " + qop);
            }

            return digestFromRecycledStringBuilder(sb, md, digestCharset);
        }

        private void appendMiddlePart(StringBuilder sb) {
            // request-digest = MD5(H(A1) ":" nonce ":" nc ":" cnonce ":" qop ":" H(A2))
            sb.append(':').append(nonce).append(':');
            if ("auth".equals(qop) || "auth-int".equals(qop)) {
                sb.append(nc).append(':').append(cnonce).append(':').append(qop).append(':');
            }
        }

        private void newResponse(MessageDigest md) {
            // when using preemptive auth, the request uri is missing
            if (uri != null) {
                // BEWARE: compute first as it uses the cached StringBuilder
                String digestUri = AuthenticatorUtils.computeRealmURI(uri, useAbsoluteURI, omitQuery);

                StringBuilder sb = StringBuilderPool.DEFAULT.stringBuilder();

                // WARNING: DON'T MOVE, BUFFER IS RECYCLED!!!!
                byte[] ha1 = ha1(sb, md);
                byte[] ha2 = ha2(sb, digestUri, md);

                appendBase16(sb, ha1);
                appendMiddlePart(sb);
                appendBase16(sb, ha2);

                byte[] responseDigest = digestFromRecycledStringBuilder(sb, md, digestCharset);
                response = toHexString(responseDigest);
            }
        }

        /**
         * Build a {@link Realm}
         *
         * @return a {@link Realm}
         */
        public Realm build() {

            // Avoid generating
            if (isNonEmpty(nonce)) {
                // Defensive: if algorithm is null, default to MD5
                String algo = (algorithm != null) ? algorithm : "MD5";
                MessageDigest md = getDigestInstance(algo);
                newCnonce(md);
                newResponse(md);
            }

            return new Realm(scheme,
                    principal,
                    password,
                    realmName,
                    nonce,
                    algorithm,
                    response,
                    opaque,
                    qop,
                    nc,
                    cnonce,
                    uri,
                    usePreemptive,
                    (scheme == AuthScheme.DIGEST ? digestCharset : charset),
                    ntlmDomain,
                    ntlmHost,
                    useAbsoluteURI,
                    omitQuery,
                    servicePrincipalName,
                    useCanonicalHostname,
                    customLoginConfig,
                    loginContextName,
                    stale,
                    userhash);
        }
    }
}