All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.marklogic.xcc.impl.Credentials Maven / Gradle / Ivy

The newest version!
/*
 * Copyright (c) 2024 MarkLogic Corporation
 *
 * Licensed 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 com.marklogic.xcc.impl;

import com.marklogic.io.Base64;
import com.marklogic.io.IOHelper;
import com.marklogic.xcc.UserCredentials;
import org.ietf.jgss.*;
import sun.security.krb5.KrbException;
import sun.security.krb5.PrincipalName;

import javax.security.auth.Subject;
import javax.security.auth.kerberos.KerberosTicket;
import javax.security.auth.login.AppConfigurationEntry;
import javax.security.auth.login.Configuration;
import javax.security.auth.login.LoginContext;
import javax.security.auth.login.LoginException;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.security.PrivilegedAction;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;

/**
 * Class Credentials used to be a static inner class of ContentSourceImpl.
 * Pulling it out as a public class for refactoring purpose to support the
 * new token-based authentication for MarkLogic Cloud.
 */
public class Credentials implements UserCredentials {
    private static Random random = new Random();
    private static final String DEFAULT_TOKEN_ENDPOINT = "/token";
    private static final String DEFAULT_GRANT_TYPE = "apikey";
    public static final int DEFAULT_TOKEN_DURATION = 60;

    // For http basic and digest
    private String user;
    private char[] password;
    private String basicAuth;
    private String HA1;

    // For http negotiate
    private LoginContext loginContext;

    // For mlcloud auth
    private MLCloudAuthConfig mlCloudAuthConfig;

    // For OAuth
    private char[] OAuthToken;

    public Credentials(char[] OAuthToken) {
        this.OAuthToken = OAuthToken;
    }

    public Credentials(String user, char[] password) {
        this.user = user;
        this.password = password;
        if (user != null && password != null) {
            initBasicAuth();
        }
    }

    public Credentials(char[] apiKey, String tokenEndpoint, String grantType,
        int tokenDuration) {
        mlCloudAuthConfig = new MLCloudAuthConfig(
            apiKey, tokenEndpoint, grantType, tokenDuration);
    }

    public Credentials() {}

    public String getUserName() {
        return user;
    }

    public MLCloudAuthConfig getMLCloudAuthConfig() {
        return mlCloudAuthConfig;
    }

    public char[] getOAuthToken() { return OAuthToken; }

    void initBasicAuth(Charset encoding)
        throws UnsupportedEncodingException {
        byte[] ubytes = (user + ":").getBytes(encoding);
        ByteBuffer pbuf = encoding.encode(CharBuffer.wrap(password));
        byte[] upbytes = new byte[pbuf.remaining() + ubytes.length];
        System.arraycopy(ubytes, 0, upbytes, 0, ubytes.length);
        pbuf.get(upbytes, ubytes.length, pbuf.remaining());
        basicAuth = "basic " +
            Base64.encodeBytes(upbytes, Base64.DONT_BREAK_LINES);
    }

    void initBasicAuth() {
        try {
            initBasicAuth(StandardCharsets.UTF_8);
        } catch (UnsupportedEncodingException e) {
            try {
                initBasicAuth(Charset.defaultCharset());
            } catch (UnsupportedEncodingException e1) {
            }
        }
    }

    public String toHttpBasicAuth() {
        if ((user == null) || ((password == null) && basicAuth == null)) {
            throw new IllegalStateException("Invalid authentication credentials");
        }
        if (password != null) {
            Arrays.fill(password, (char) 0);
            password = null;
        }
        return basicAuth;
    }

    private static final AtomicLong nonceCounter = new AtomicLong();

    public String toHttpDigestAuth(String method, String uri, String challengeHeader) {
        if ((user == null) || (password == null && HA1 == null)) {
            throw new IllegalStateException("Invalid authentication credentials");
        }

        if ((challengeHeader == null) || !challengeHeader.startsWith("Digest ")) {
            return null;
        }

        String pairs[] = challengeHeader.substring("Digest ".length()).split(", +");

        Map params = new HashMap<>();

        for (String pair : pairs) {
            String nv[] = pair.split("=", 2);
            params.put(nv[0].toLowerCase(), nv[1].substring(1, nv[1].length() - 1));
        }

        String realm = params.get("realm");

        if (HA1 == null) {
            HA1 = digestCalcHA1(user, realm, password);
        }

        String nonce = params.get("nonce");
        String qop = params.get("qop");
        String opaque = params.get("opaque");

        byte[] bytes = new byte[16];

        synchronized (random) {
            random.nextBytes(bytes);
        }

        String cNonce = IOHelper.bytesToHex(bytes);

        String nonceCount = Long.toHexString(nonceCounter.incrementAndGet());

        String response = digestCalcResponse(HA1, nonce, nonceCount, cNonce,
            qop, method, uri);

        StringBuilder buf = new StringBuilder();

        buf.append("Digest username=\"");
        buf.append(user);
        buf.append("\", realm=\"");
        buf.append(realm);
        buf.append("\", nonce=\"");
        buf.append(nonce);
        buf.append("\", uri=\"");
        buf.append(uri);
        buf.append("\", qop=\"auth\", nc=\"");
        buf.append(nonceCount);
        buf.append("\", cnonce=\"");
        buf.append(cNonce);
        buf.append("\", response=\"");
        buf.append(response);
        buf.append("\", opaque=\"");
        buf.append(opaque);
        buf.append("\"");

        return buf.toString();
    }

    public String toHttpNegotiateAuth(String hostName, String challenge) {

        try {
            if (loginContext == null)
                buildSubjectCredentials();
            return "Negotiate " + getAuthorizationHeader("HTTP/" + hostName);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public String toMLCloudAuth() {
        return "Bearer " + MLCloudAuthManager.getSessionToken(
            this.getMLCloudAuthConfig().getApiKey());
    }

    public String toOAuth() {
        return "Bearer " + new String(OAuthToken);
    }

    @Override
    public String toString() {
        return "user=" + user;
    }

    public static String digestCalcResponse(String HA1, String nonce,
                                            String nonceCount, String cNonce,
                                            String qop, String method,
                                            String uri) {

        try {
            MessageDigest digest = MessageDigest.getInstance("MD5");

            StringBuilder plaintext = new StringBuilder();

            plaintext.append(method);
            plaintext.append(":");
            plaintext.append(uri);

            digest.update(plaintext.toString().getBytes(), 0, plaintext.length());

            String HA2 = IOHelper.bytesToHex(digest.digest());

            plaintext.setLength(0);
            plaintext.append(HA1);
            plaintext.append(":");
            plaintext.append(nonce);
            plaintext.append(":");
            if (qop != null) {
                plaintext.append(nonceCount);
                plaintext.append(":");
                plaintext.append(cNonce);
                plaintext.append(":");
                plaintext.append(qop);
                plaintext.append(":");
            }
            plaintext.append(HA2);

            digest.update(plaintext.toString().getBytes(), 0, plaintext.length());

            return IOHelper.bytesToHex(digest.digest());
        } catch (NoSuchAlgorithmException e) {
            // this really shouldn't happen
            throw new RuntimeException(e);
        }
    }

    public static String digestCalcHA1(String userName, String realm,
                                       char[] password) {
        try {
            MessageDigest digest = MessageDigest.getInstance("MD5");

            StringBuilder plaintext = new StringBuilder();

            plaintext.append(userName);
            plaintext.append(":");
            plaintext.append(realm);
            plaintext.append(":");
            byte[] ubytes = plaintext.toString().getBytes();

            ByteBuffer pbuf =
                Charset.defaultCharset().encode(CharBuffer.wrap(password));
            byte[] upbytes = new byte[pbuf.remaining() + ubytes.length];
            System.arraycopy(ubytes, 0, upbytes, 0, ubytes.length);
            pbuf.get(upbytes, ubytes.length, pbuf.remaining());

            digest.update(upbytes, 0, upbytes.length);
            return IOHelper.bytesToHex(digest.digest());
        } catch (NoSuchAlgorithmException e) {
            // this really shouldn't happen
            throw new RuntimeException(e);
        }
    }


    // -------------------------------------------------------------
    /**
     * Class to create Kerberos Configuration object which specifies the
     * Kerberos Login Module to be used for authentication.
     */
    private class KerberosLoginConfiguration extends Configuration {
        @Override
        public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
            Map options = new HashMap<>();
            options.put("refreshKrb5Config", "true");
            options.put("useTicketCache", "true");
            return new AppConfigurationEntry[]{
                new AppConfigurationEntry(
                    "com.sun.security.auth.module.Krb5LoginModule",
                    AppConfigurationEntry.LoginModuleControlFlag.REQUIRED,
                    options)};
        }
    }

    /**
     * This method checks the validity of the TGT in the cache and build the
     * Subject inside the LoginContext using Krb5LoginModule and the TGT cached
     * by the Kerberos client. It assumes that a valid TGT is already present in
     * the kerberos client's cache.
     *
     * @throws KrbException
     * @throws IOException
     * @throws LoginException
     */
    private void buildSubjectCredentials()
        throws KrbException, IOException, LoginException {
        Subject subject = new Subject();
        sun.security.krb5.Credentials cred;
        // Check if the cache already has valid TGT information. If not, throw exceptions
        if (user != null && !user.equals("")) {
            cred = sun.security.krb5.Credentials.acquireTGTFromCache(
                new PrincipalName(user), null);
        } else {
            cred = sun.security.krb5.Credentials.acquireTGTFromCache(null, null);
        }
        if (cred == null) {
            throw new KrbException("No ticket granting ticket in the cache");
        } else {
            Date endTime = cred.getEndTime();
            if (endTime != null) {
                if (endTime.compareTo(new Date()) == -1) {
                    throw new KrbException("The ticket granting ticket in " +
                        "the cache is no longer valid");
                }
            }
        }

        /*
         * We are not getting the TGT from KDC here. The actual TGT is got from
         * the KDC using kinit or equivalent but we use the cached TGT in order
         * to build the LoginContext and populate the TGT inside the Subject
         * using Krb5LoginModule
         */
        loginContext = new LoginContext("Krb5LoginContext", subject, null,
            new KerberosLoginConfiguration());
        loginContext.login();
    }

    /**
     * Creates a privileged action which will be executed as the Subject using
     * Subject.doAs() method. We do this in order to create a context of the
     * user who has the service ticket and reuse this context for subsequent
     * requests
     */
    private static class CreateAuthorizationHeaderAction implements PrivilegedAction {
        String clientPrincipalName;
        String serverPrincipalName;

        private StringBuffer outputToken = new StringBuffer();

        private CreateAuthorizationHeaderAction(final String clientPrincipalName,
                                                final String serverPrincipalName) {
            this.clientPrincipalName = clientPrincipalName;
            this.serverPrincipalName = serverPrincipalName;
        }

        private String getNegotiateToken() {
            return outputToken.toString();
        }

        /*
         * Here GSS API takes care of getting the service ticket from the Subject
         * cache or by using the TGT information populated in the subject which is
         * done by buildSubjectCredentials method. The service ticket received is
         * populated in the subject's private credentials along with the TGT
         * information since we will be executing this method as the Subject.
         * For subsequent requests, the cached service ticket will be re-used.
         * For this to work the System property
         * javax.security.auth.useSubjectCredsOnly must be set to true.
         */
        public Object run() {
            try {
                Oid krb5Mechanism = new Oid("1.2.840.113554.1.2.2");
                Oid krb5PrincipalNameType = new Oid("1.2.840.113554.1.2.2.1");
                final GSSManager manager = GSSManager.getInstance();
                final GSSName clientName = manager.createName(clientPrincipalName, krb5PrincipalNameType);
                final GSSCredential clientCred = manager.createCredential(clientName, 8 * 3600, krb5Mechanism,
                    GSSCredential.INITIATE_ONLY);
                final GSSName serverName = manager.createName(serverPrincipalName, krb5PrincipalNameType);

                final GSSContext context = manager.createContext(serverName, krb5Mechanism, clientCred,
                    GSSContext.DEFAULT_LIFETIME);

                byte[] inToken = new byte[0]; // since
                byte[] outToken = context.initSecContext(inToken, 0, inToken.length);
                outputToken.append(new String(Base64.encodeBytes(outToken, Base64.DONT_BREAK_LINES)));
                context.dispose();
            } catch (GSSException exception) {
                throw new RuntimeException(exception.getMessage());
            }
            return null;
        }
    }

    /**
     * This method builds the Authorization header for Kerberos. It
     * generates a request token based on the service ticket, client principal name and
     * time-stamp
     *
     * @param serverPrincipalName the name registered with the KDC of the service for which we
     *                            need to authenticate
     * @return the HTTP Authorization header token
     */
    private String getAuthorizationHeader(String serverPrincipalName)
        throws GSSException, LoginException, KrbException, IOException {
        /*
         * Get the principal from the Subject's private credentials and populate
         * the client and server principal name for the GSS API
         */
        final String clientPrincipal = getClientPrincipalName();
        final CreateAuthorizationHeaderAction action =
            new CreateAuthorizationHeaderAction(
                clientPrincipal, serverPrincipalName);

        /*
         * Check if the TGT in the Subject's private credentials are valid. If
         * valid, then we use the TGT in the Subject's private credentials. If
         * not, we build the Subject's private credentials again from valid TGT
         * in the Kerberos client cache.
         */
        Set privateCreds = loginContext.getSubject().getPrivateCredentials();
        for (Object privateCred : privateCreds) {
            if (privateCred instanceof KerberosTicket) {
                String serverPrincipalTicketName =
                    ((KerberosTicket) privateCred).getServer().getName();
                if ((serverPrincipalTicketName.startsWith("krbtgt"))
                    && ((KerberosTicket) privateCred).getEndTime().
                    compareTo(new Date()) == -1) {
                    buildSubjectCredentials();
                    break;
                }
            }
        }

        /*
         * Subject.doAs takes in the Subject context and the action to be run as
         * arguments. This method executes the action as the Subject given in
         * the argument. We do this in order to provide the Subject's context so
         * that we reuse the service ticket which will be populated in the
         * Subject rather than getting the service ticket from the KDC for each
         * request. The GSS API populates the service ticket in the Subject and
         * reuses it
         *
         */
        Subject.doAs(loginContext.getSubject(), action);
        return action.getNegotiateToken();
    }

    /**
     * This method is responsible for getting the client principal name from the
     * subject's principal set
     *
     * @return String the Kerberos principal name populated in the subject
     * @throws IllegalStateException if there is more than 0 or more than 1 principal is present
     */
    private String getClientPrincipalName() {
        final Set principalSet = loginContext.getSubject().getPrincipals();
        if (principalSet.size() != 1)
            throw new IllegalStateException(
                "Only one principal per subject is expected. " +
                    "Found 0 or more than one principals :" + principalSet);
        return principalSet.iterator().next().getName();
    }

    // -------------------------------------------------------------
    public class MLCloudAuthConfig {
        private final char[] apiKey;
        private final String tokenEndpoint;
        private final String grantType;
        private final int tokenDuration;

        public MLCloudAuthConfig(char[] apiKey, String tokenEndpoint,
                                 String grantType, int tokenDuration) {
            this.apiKey = apiKey;
            this.tokenEndpoint = (tokenEndpoint == null) ?
                DEFAULT_TOKEN_ENDPOINT : tokenEndpoint;
            this.grantType = (grantType == null) ?
                DEFAULT_GRANT_TYPE : grantType;
            this.tokenDuration = (tokenDuration == 0) ?
                DEFAULT_TOKEN_DURATION : tokenDuration;
        }

        public char[] getApiKey() {
            return apiKey;
        }

        public String getTokenEndpoint() {
            return tokenEndpoint;
        }

        public String getGrantType() {
            return grantType;
        }

        public int getTokenDuration() {
            return tokenDuration;
        }
    }
}