/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file 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.apache.zeppelin.realm.kerberos;

import static org.apache.hadoop.util.PlatformName.IBM_JAVA;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.regex.Pattern;

import org.apache.directory.server.kerberos.shared.keytab.Keytab;
import org.apache.directory.server.kerberos.shared.keytab.KeytabEntry;
import org.ietf.jgss.GSSException;
import org.ietf.jgss.Oid;

import javax.security.auth.Subject;
import javax.security.auth.kerberos.KerberosTicket;
import javax.security.auth.kerberos.KeyTab;

/**
 * Created for {@link KerberosRealm}, from
 * org.apache.hadoop.security.authentication.util.KerberosUtil
 *
 * TODO(vr): This should be removed once we move to Apache Hadoop 2.8+
 */
public class KerberosUtil {

  /* Return the Kerberos login module name */
  public static String getKrb5LoginModuleName() {
    return System.getProperty("java.vendor").contains("IBM")
      ? "com.ibm.security.auth.module.Krb5LoginModule"
      : "com.sun.security.auth.module.Krb5LoginModule";
  }

  public static final Oid GSS_SPNEGO_MECH_OID =
    getNumericOidInstance("1.3.6.1.5.5.2");
  public static final Oid GSS_KRB5_MECH_OID =
    getNumericOidInstance("1.2.840.113554.1.2.2");
  public static final Oid NT_GSS_KRB5_PRINCIPAL_OID =
    getNumericOidInstance("1.2.840.113554.1.2.2.1");

  // numeric oids will never generate a GSSException for a malformed oid.
  // use to initialize statics.
  private static Oid getNumericOidInstance(String oidName) {
    try {
      return new Oid(oidName);
    } catch (GSSException ex) {
      throw new IllegalArgumentException(ex);
    }
  }

  public static Oid getOidInstance(String oidName)
    throws ClassNotFoundException, GSSException, NoSuchFieldException,
    IllegalAccessException {
    Class<?> oidClass;
    if (IBM_JAVA) {
      if ("NT_GSS_KRB5_PRINCIPAL".equals(oidName)) {
        // IBM JDK GSSUtil class does not have field for krb5 principal oid
        return new Oid("1.2.840.113554.1.2.2.1");
      }
      oidClass = Class.forName("com.ibm.security.jgss.GSSUtil");
    } else {
      oidClass = Class.forName("sun.security.jgss.GSSUtil");
    }
    Field oidField = oidClass.getDeclaredField(oidName);
    return (Oid)oidField.get(oidClass);
  }

  public static String getDefaultRealm()
    throws ClassNotFoundException, NoSuchMethodException,
    IllegalArgumentException, IllegalAccessException,
    InvocationTargetException {
    Object kerbConf;
    Class<?> classRef;
    Method getInstanceMethod;
    Method getDefaultRealmMethod;
    if (System.getProperty("java.vendor").contains("IBM")) {
      classRef = Class.forName("com.ibm.security.krb5.internal.Config");
    } else {
      classRef = Class.forName("sun.security.krb5.Config");
    }
    getInstanceMethod = classRef.getMethod("getInstance", new Class[0]);
    kerbConf = getInstanceMethod.invoke(classRef, new Object[0]);
    getDefaultRealmMethod = classRef.getDeclaredMethod("getDefaultRealm",
      new Class[0]);
    return (String)getDefaultRealmMethod.invoke(kerbConf, new Object[0]);
  }

  public static String getDefaultRealmProtected() {
    String realmString = null;
    try {
      realmString = getDefaultRealm();
    } catch (RuntimeException rte) {
      //silently catch everything
    } catch (Exception e) {
      //silently return null
    }
    return realmString;
  }

  /*
   * For a Service Host Principal specification, map the host's domain
   * to kerberos realm, as specified by krb5.conf [domain_realm] mappings.
   * Unfortunately the mapping routines are private to the security.krb5
   * package, so have to construct a PrincipalName instance to derive the realm.
   *
   * Many things can go wrong with Kerberos configuration, and this is not
   * the place to be throwing exceptions to help debug them.  Nor do we choose
   * to make potentially voluminous logs on every call to a communications API.
   * So we simply swallow all exceptions from the underlying libraries and
   * return null if we can't get a good value for the realmString.
   *
   * @param shortprinc A service principal name with host fqdn as instance, e.g.
   *     "HTTP/myhost.mydomain"
   * @return String value of Kerberos realm, mapped from host fqdn
   *     May be default realm, or may be null.
   */
  public static String getDomainRealm(String shortprinc) {
    Class<?> classRef;
    Object principalName; //of type sun.security.krb5.PrincipalName or IBM equiv
    String realmString = null;
    try {
      if (System.getProperty("java.vendor").contains("IBM")) {
        classRef = Class.forName("com.ibm.security.krb5.PrincipalName");
      } else {
        classRef = Class.forName("sun.security.krb5.PrincipalName");
      }
      int tKrbNtSrvHst = classRef.getField("KRB_NT_SRV_HST").getInt(null);
      principalName = classRef.getConstructor(String.class, int.class).
        newInstance(shortprinc, tKrbNtSrvHst);
      realmString = (String)classRef.getMethod("getRealmString", new Class[0]).
        invoke(principalName, new Object[0]);
    } catch (RuntimeException rte) {
      //silently catch everything
    } catch (Exception e) {
      //silently return default realm (which may itself be null)
    }
    if (null == realmString || realmString.equals("")) {
      return getDefaultRealmProtected();
    } else {
      return realmString;
    }
  }

  /* Return fqdn of the current host */
  static String getLocalHostName() throws UnknownHostException {
    return InetAddress.getLocalHost().getCanonicalHostName();
  }

  /**
   * Create Kerberos principal for a given service and hostname,
   * inferring realm from the fqdn of the hostname. It converts
   * hostname to lower case. If hostname is null or "0.0.0.0", it uses
   * dynamically looked-up fqdn of the current host instead.
   * If domain_realm mappings are inadequately specified, it will
   * use default_realm, per usual Kerberos behavior.
   * If default_realm also gives a null value, then a principal
   * without realm will be returned, which by Kerberos definitions is
   * just another way to specify default realm.
   *
   * @param service
   *          Service for which you want to generate the principal.
   * @param hostname
   *          Fully-qualified domain name.
   * @return Converted Kerberos principal name.
   * @throws UnknownHostException
   *           If no IP address for the local host could be found.
   */
  public static final String getServicePrincipal(String service,
    String hostname)
    throws UnknownHostException {
    String fqdn = hostname;
    String shortprinc;
    String realmString;
    if (null == fqdn || fqdn.equals("") || fqdn.equals("0.0.0.0")) {
      fqdn = getLocalHostName();
    }
    // convert hostname to lowercase as kerberos does not work with hostnames
    // with uppercase characters.
    fqdn = fqdn.toLowerCase(Locale.ENGLISH);
    shortprinc = service + "/" + fqdn;
    // Obtain the realm name inferred from the domain of the host
    realmString = getDomainRealm(shortprinc);
    if (null == realmString || realmString.equals("")) {
      return shortprinc;
    } else {
      return shortprinc + "@" + realmString;
    }
  }

  /**
   * Get all the unique principals present in the keytabfile.
   *
   * @param keytabFileName
   *          Name of the keytab file to be read.
   * @return list of unique principals in the keytab.
   * @throws IOException
   *          If keytab entries cannot be read from the file.
   */
  static final String[] getPrincipalNames(String keytabFileName) throws IOException {
    Keytab keytab = Keytab.read(new File(keytabFileName));
    Set<String> principals = new HashSet<>();
    List<KeytabEntry> entries = keytab.getEntries();
    for (KeytabEntry entry: entries){
      principals.add(entry.getPrincipalName().replace("\\", "/"));
    }
    return principals.toArray(new String[0]);
  }

  /**
   * Get all the unique principals from keytabfile which matches a pattern.
   *
   * @param keytab Name of the keytab file to be read.
   * @param pattern pattern to be matched.
   * @return list of unique principals which matches the pattern.
   * @throws IOException if cannot get the principal name
   */
  public static final String[] getPrincipalNames(String keytab,
    Pattern pattern) throws IOException {
    String[] principals = getPrincipalNames(keytab);
    if (principals.length != 0) {
      List<String> matchingPrincipals = new ArrayList<>();
      for (String principal : principals) {
        if (pattern.matcher(principal).matches()) {
          matchingPrincipals.add(principal);
        }
      }
      principals = matchingPrincipals.toArray(new String[0]);
    }
    return principals;
  }

  /**
   * Check if the subject contains Kerberos keytab related objects.
   * The Kerberos keytab object attached in subject has been changed
   * from KerberosKey (JDK 7) to KeyTab (JDK 8)
   *
   *
   * @param subject subject to be checked
   * @return true if the subject contains Kerberos keytab
   */
  public static boolean hasKerberosKeyTab(Subject subject) {
    return !subject.getPrivateCredentials(KeyTab.class).isEmpty();
  }

  /**
   * Check if the subject contains Kerberos ticket.
   *
   *
   * @param subject subject to be checked
   * @return true if the subject contains Kerberos ticket
   */
  public static boolean hasKerberosTicket(Subject subject) {
    return !subject.getPrivateCredentials(KerberosTicket.class).isEmpty();
  }

  /**
   * Extract the TGS server principal from the given gssapi kerberos or spnego
   * wrapped token.
   * @param rawToken bytes of the gss token
   * @return String of server principal
   * @throws IllegalArgumentException if token is undecodable
   */
  public static String getTokenServerName(byte[] rawToken) {
    // subsequent comments include only relevant portions of the kerberos
    // DER encoding that will be extracted.
    DER token = new DER(rawToken);
    // InitialContextToken ::= [APPLICATION 0] IMPLICIT SEQUENCE {
    //     mech   OID
    //     mech-token  (NegotiationToken or InnerContextToken)
    // }
    DER oid = token.next();
    if (oid.equals(DER.SPNEGO_MECH_OID)) {
      // NegotiationToken ::= CHOICE {
      //     neg-token-init[0] NegTokenInit
      // }
      // NegTokenInit ::= SEQUENCE {
      //     mech-token[2]     InitialContextToken
      // }
      token = token.next().get(0xa0, 0x30, 0xa2, 0x04).next();
      oid = token.next();
    }
    if (!oid.equals(DER.KRB5_MECH_OID)) {
      throw new IllegalArgumentException("Malformed gss token");
    }
    // InnerContextToken ::= {
    //     token-id[1]
    //     AP-REQ
    // }
    if (token.next().getTag() != 1) {
      throw new IllegalArgumentException("Not an AP-REQ token");
    }
    // AP-REQ ::= [APPLICATION 14] SEQUENCE {
    //     ticket[3]      Ticket
    // }
    DER ticket = token.next().get(0x6e, 0x30, 0xa3, 0x61, 0x30);
    // Ticket ::= [APPLICATION 1] SEQUENCE {
    //     realm[1]       String
    //     sname[2]       PrincipalName
    // }
    // PrincipalName ::= SEQUENCE {
    //     name-string[1] SEQUENCE OF String
    // }
    String realm = ticket.get(0xa1, 0x1b).getAsString();
    DER names = ticket.get(0xa2, 0x30, 0xa1, 0x30);
    StringBuilder sb = new StringBuilder();
    while (names.hasNext()) {
      if (sb.length() > 0) {
        sb.append('/');
      }
      sb.append(names.next().getAsString());
    }
    return sb.append('@').append(realm).toString();
  }

  // basic ASN.1 DER decoder to traverse encoded byte arrays.
  private static class DER implements Iterator<DER> {
    static final DER SPNEGO_MECH_OID = getDER(GSS_SPNEGO_MECH_OID);
    static final DER KRB5_MECH_OID = getDER(GSS_KRB5_MECH_OID);

    private static DER getDER(Oid oid) {
      try {
        return new DER(oid.getDER());
      } catch (GSSException ex) {
        // won't happen.  a proper OID is encodable.
        throw new IllegalArgumentException(ex);
      }
    }

    private final int tag;
    private final ByteBuffer bb;

    DER(byte[] buf) {
      this(ByteBuffer.wrap(buf));
    }

    DER(ByteBuffer srcbb) {
      tag = srcbb.get() & 0xff;
      int length = readLength(srcbb);
      bb = srcbb.slice();
      bb.limit(length);
      srcbb.position(srcbb.position() + length);
    }

    int getTag() {
      return tag;
    }

    // standard ASN.1 encoding.
    private static int readLength(ByteBuffer bb) {
      int length = bb.get();
      if ((length & (byte)0x80) != 0) {
        int varlength = length & 0x7f;
        length = 0;
        for (int i=0; i < varlength; i++) {
          length = (length << 8) | (bb.get() & 0xff);
        }
      }
      return length;
    }

    DER choose(int subtag) {
      while (hasNext()) {
        DER der = next();
        if (der.getTag() == subtag) {
          return der;
        }
      }
      return null;
    }

    DER get(int... tags) {
      DER der = this;
      for (int i=0; i < tags.length; i++) {
        int expectedTag = tags[i];
        // lookup for exact match, else scan if it's sequenced.
        if (der.getTag() != expectedTag) {
          der = der.hasNext() ? der.choose(expectedTag) : null;
        }
        if (der == null) {
          StringBuilder sb = new StringBuilder("Tag not found:");
          for (int ii=0; ii <= i; ii++) {
            sb.append(" 0x").append(Integer.toHexString(tags[ii]));
          }
          throw new IllegalStateException(sb.toString());
        }
      }
      return der;
    }

    String getAsString() {
      return new String(bb.array(), bb.arrayOffset() + bb.position(),
        bb.remaining(), StandardCharsets.UTF_8);
    }

    @Override
    public int hashCode() {
      return 31 * tag + bb.hashCode();
    }

    @Override
    public boolean equals(Object o) {
      return (o instanceof DER) &&
        tag == ((DER)o).tag && bb.equals(((DER)o).bb);
    }

    @Override
    public boolean hasNext() {
      // it's a sequence or an embedded octet.
      return ((tag & 0x30) != 0 || tag == 0x04) && bb.hasRemaining();
    }

    @Override
    public DER next() {
      if (!hasNext()) {
        throw new NoSuchElementException();
      }
      return new DER(bb);
    }

    @Override
    public String toString() {
      return "[tag=0x"+Integer.toHexString(tag)+" bb="+bb+"]";
    }

    @Override
    public void remove() {
      throw new UnsupportedOperationException();
    }
  }


}
