/*
 * @(#)KeyStoreLoginModule.java	1.12 01/12/03
 *
 * Copyright 2002 Sun Microsystems, Inc. All rights reserved.
 * SUN PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
 */

package com.sun.security.auth.module;

import javax.security.auth.x500.X500Principal;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.PushbackInputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.Key;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.UnrecoverableKeyException;
import java.security.cert.*;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Map;
import java.util.ResourceBundle;
import javax.security.auth.Destroyable;
import javax.security.auth.DestroyFailedException;
import javax.security.auth.Subject;
import javax.security.auth.x500.*;
import javax.security.auth.Subject;
import javax.security.auth.x500.*;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.ConfirmationCallback;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.TextOutputCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.login.FailedLoginException;
import javax.security.auth.login.LoginException;
import javax.security.auth.spi.LoginModule;

import sun.security.util.AuthResources;

/**
 * Provides a JAAS login module that prompts for a key store alias and
 * populates the subject with the alias's principal and credentials. Stores
 * an <code>X500Principal</code> for the subject distinguished name of the 
 * first certificate in the alias's credentials in the subject's principals,
 * the alias's certificate path in the subject's public credentials, and a
 * <code>X500PrivateCredential</code> whose certificate is the first
 * certificate in the alias's certificate path and whose private key is the
 * alias's private key in the subject's private credentials. <p>
 *
 * Recognizes the following options in the JAAS authentication policy file:
 * <dl>
 *
 * <dt> <code>keyStoreURL</code> </dt>
 * <dd> A URL that specifies the location of the key store file.  Defaults to
 *	the .keystore file in the directory specified by the
 *	<code>java.home</code> system property. </dd>
 *
 * <dt> <code>keyStoreType</code> </dt>
 * <dd> The key store type.  If not specified, defaults to the result of
 *      calling <code>KeyStore.getDefaultType()</code>. </dd>
 *
 * <dt> <code>keyStoreProvider</code> </dt>
 * <dd> The key store provider.  If not specified, uses the standard search
 *      order to find the provider. </dd>
 *
 * <dt> <code>keyStoreAlias</code> </dt>
 * <dd> The alias in the key store to login as.  Required when no callback
 *	handler is provided.  No default value. </dd>
 *
 * <dt> <code>keyStorePasswordURL</code> </dt>
 * <dd> A URL that specifies the location of the key store password.  Required
 *	when no callback handler is provided.  No default value. </dd>
 *
 * <dt> <code>privateKeyPasswordURL</code> </dt>
 * <dd> A URL that specifies the location of the specific private key password
 *	needed to access the private key for this alias.  
 *      The keystore password
 *	is used if this value is not specified. </dd>
 * </dl>
 */
public class KeyStoreLoginModule implements LoginModule {

   static final java.util.ResourceBundle rb =
        java.util.ResourceBundle.getBundle("sun.security.util.AuthResources");

    /* -- Fields -- */

    private static final int UNINITIALIZED = 0;
    private static final int INITIALIZED = 1;
    private static final int AUTHENTICATED = 2;
    private static final int LOGGED_IN = 3;

    private Subject subject;
    private CallbackHandler callbackHandler;
    private Map sharedState;
    private Map options;

    private char[] keyStorePassword;
    private char[] privateKeyPassword;

    private String keyStoreURL;
    private String keyStoreType;
    private String keyStoreProvider;
    private String keyStoreAlias;
    private String keyStorePasswordURL;
    private String privateKeyPasswordURL;
    private boolean debug;
    private javax.security.auth.x500.X500Principal principal;
    private Certificate[] fromKeyStore;
    private java.security.cert.CertPath certP = null;
    private X500PrivateCredential privateCredential;
    private int status = UNINITIALIZED;

    /* -- Methods -- */
  
    /**
     * Initialize this <code>LoginModule</code>.
     *
     * <p>
     *
     * @param subject the <code>Subject</code> to be authenticated. <p>
     *
     * @param callbackHandler a <code>CallbackHandler</code> for communicating
     *			with the end user (prompting for usernames and
     *			passwords, for example). <p>
     *
     * @param sharedState shared <code>LoginModule</code> state. <p>
     *
     * @param options options specified in the login
     *			<code>Configuration</code> for this particular
     *			<code>LoginModule</code>.
     */

    public void initialize(Subject subject,
			   CallbackHandler callbackHandler,
			   Map sharedState,
			   Map options)
    {
 	this.subject = subject;
	this.callbackHandler = callbackHandler;
	this.sharedState = sharedState;
	this.options = options;

	processOptions();
	status = INITIALIZED;
    }

    private void processOptions() {
	keyStoreURL = (String) options.get("keyStoreURL");
	if (keyStoreURL == null) {
	    keyStoreURL =
		"file:" +
		System.getProperty("user.home").replace(
		    File.separatorChar, '/') +
		'/' + ".keystore";
	}
	keyStoreType = (String) options.get("keyStoreType");
	if (keyStoreType == null) {
	    keyStoreType = KeyStore.getDefaultType();
	}

	keyStoreProvider = (String) options.get("keyStoreProvider");

	keyStoreAlias = (String) options.get("keyStoreAlias");

	keyStorePasswordURL = (String) options.get("keyStorePasswordURL");

	privateKeyPasswordURL = (String) options.get("privateKeyPasswordURL");

	debug = "true".equalsIgnoreCase((String) options.get("debug"));
	if (debug)
	    debugPrint("keyStoreURL=" + keyStoreURL +
		   " keyStoreAlias=" + keyStoreAlias +
		   " keyStorePasswordURL=" + keyStorePasswordURL +
		   " privateKeyPasswordURL=" + privateKeyPasswordURL);
	
    }
    
    /**
     * Authenticate the user .
     *
     * <p> Prompt the user for the Keystore alias and the password. Retrieve
     * the alias's principal and credentials from the Keystore.
     *
     * <p>
     *
     * @exception FailedLoginException if the authentication fails. <p>
     *
     * @return true in all cases (this <code>LoginModule</code>
     *		should not be ignored).
     */

    public boolean login() throws LoginException {
	switch (status) {
	case UNINITIALIZED:
	default:
	    throw new LoginException("The login module is not initialized");
	case INITIALIZED:
	case AUTHENTICATED:
	    getAliasAndPassword();
	    getKeyStoreInfo();
	    status = AUTHENTICATED;
	    return true;
	case LOGGED_IN:
	    return true;
	}
    }

    /** Get the alias and passwords to use for looking up in the KeyStore. */
    private void getAliasAndPassword() throws LoginException {
	if (callbackHandler == null) {

	    /*
	     * No callback handler.  Check for alias and password files
	     * specified in the options.
	     */
	    if (keyStoreAlias == null) {
		throw new LoginException(
		    "Need to specify an alias option to use " +
		    "KeyStoreLoginModule non-interactively.");
	    }
	    if (keyStorePasswordURL == null) {
		throw new LoginException(
		    "Need to specify passwordFile option to use " +
		    "KeyStoreLoginModule non-interactively.");
	    }
	    try {
		InputStream in = new URL(keyStorePasswordURL).openStream();
		keyStorePassword = readPassword(in);
		in.close();
	    } catch (IOException e) {
		throw new LoginException(
		    "Problem accessing keystore password \"" +
		    keyStorePasswordURL + "\": " + e);
	    }

	    if (privateKeyPasswordURL == null) {
		privateKeyPassword = keyStorePassword;
	    } else {
		try {
		    InputStream in =
			new URL(privateKeyPasswordURL).openStream();
		    privateKeyPassword = readPassword(in);
		    in.close();
		} catch (IOException e) {
		    throw new LoginException(
			"Problem accessing private key password \"" +
			privateKeyPasswordURL + "\": " + e);
		}
	    }


	} else {
	    TextOutputCallback bannerCallback =
		new TextOutputCallback(
		    TextOutputCallback.INFORMATION,
		    rb.getString("Please login to keystore"));

	    NameCallback aliasCallback;
	    if (keyStoreAlias == null || keyStoreAlias.length() == 0) {
		aliasCallback = new NameCallback(
				        rb.getString("Keystore alias: "));
	    } else {
		aliasCallback =
		    new NameCallback(rb.getString("Keystore alias: "), 
				     keyStoreAlias);
	    }
	    PasswordCallback keyStorePasswordCallback =
		new PasswordCallback(rb.getString("Keystore password: "),
						 false);

	    PasswordCallback privateKeyPasswordCallback =
		new PasswordCallback(
		    rb.getString("Private key password (optional): "), false);

	    ConfirmationCallback confirmationCallback =
		new ConfirmationCallback(
		    ConfirmationCallback.INFORMATION,
		    ConfirmationCallback.OK_CANCEL_OPTION,
		    ConfirmationCallback.OK);

	    try {
		callbackHandler.handle(
		    new Callback[] {
			bannerCallback, aliasCallback,
			keyStorePasswordCallback, privateKeyPasswordCallback,
		        confirmationCallback
		    });
	    } catch (IOException e) {
		throw new LoginException(
		    "Exception while getting keystore alias and password: " +
		    e);
	    } catch (UnsupportedCallbackException e) {
		throw new LoginException(
		    "Error: " + e.getCallback().toString() +
		    " is not available to retrieve authentication " +
		    " information from the user");
	    }

	    int confirmationResult = confirmationCallback.getSelectedIndex();

	    if (confirmationResult == ConfirmationCallback.CANCEL) {
		throw new LoginException("Login cancelled");
	    }

	    keyStoreAlias = aliasCallback.getName();

	    char[] tmpPassword = keyStorePasswordCallback.getPassword();
	    if (tmpPassword == null) {
		/* Treat a NULL password as an empty password */
		tmpPassword = new char[0];
	    }
	    keyStorePassword = new char[tmpPassword.length];
	    System.arraycopy(tmpPassword, 0,
			     keyStorePassword, 0, tmpPassword.length);
	    keyStorePasswordCallback.clearPassword();

	    tmpPassword = privateKeyPasswordCallback.getPassword();
	    if (tmpPassword == null
		|| tmpPassword.length == 0)
	    {
		/*
		 * Use keystore password if no private key password is
		 * specified.
		 */
		privateKeyPassword = keyStorePassword;
	    } else {
		privateKeyPassword = new char[tmpPassword.length];
		System.arraycopy(tmpPassword, 0,
				 privateKeyPassword, 0, tmpPassword.length);
		for (int i=0; i <tmpPassword.length ; i++)
		    tmpPassword[0] = ' ';
		tmpPassword=null;
		privateKeyPasswordCallback.clearPassword();
	    }
	    if (debug)
		debugPrint("alias=" + keyStoreAlias);
	}
    }

    /** Get the credentials from the KeyStore. */
    private void getKeyStoreInfo() throws LoginException {

	/* Get KeyStore instance */
	KeyStore keyStore;
	try {
	    if (keyStoreProvider == null) {
		keyStore = KeyStore.getInstance(keyStoreType);
	    } else {
		keyStore =
		    KeyStore.getInstance(keyStoreType, keyStoreProvider);
	    }
	} catch (KeyStoreException e) {
	    throw new LoginException(
		"The specified keystore type was not available: " + e);
	} catch (NoSuchProviderException e) {
	    throw new LoginException(
		"The specified keystore provider was not available: " + e);
	}

	/* Load KeyStore contents from file */
	try {
	    InputStream in = new URL(keyStoreURL).openStream();
	    keyStore.load(in, keyStorePassword);
	    in.close();
	} catch (MalformedURLException e) {
	    throw new LoginException("Incorrect keyStoreURL option: " + e);
	} catch (GeneralSecurityException e) {
	    throw new LoginException("Error initializing keystore: " + e);
	} catch (IOException e) {
	    throw new LoginException("Error initializing keystore: " + e);
	}

	/* Get certificate chain and create a certificate path */
	try {
	    fromKeyStore =
		keyStore.getCertificateChain(keyStoreAlias);
	    if (fromKeyStore == null
		|| fromKeyStore.length == 0
		|| !(fromKeyStore[0] instanceof X509Certificate))
	    {
		throw new FailedLoginException(
		    "Unable to find X.509 certificate chain in keystore");
	    } else {
		LinkedList certList = new LinkedList();
		for (int i=0; i < fromKeyStore.length; i++) {
		    certList.add(fromKeyStore[i]);
		}
		CertificateFactory certF= 
		    CertificateFactory.getInstance("X.509");
		certP = 
		    certF.generateCertPath(certList);	
	    }
	} catch (KeyStoreException e) {
	    throw new LoginException("Error using keystore: " + e);
	} catch (CertificateException ce) {
	    throw new LoginException("Error: X.509 Certificate type unavailable: " + ce);
	}

	/* Get principal and keys */
	try {
	    X509Certificate certificate = (X509Certificate)fromKeyStore[0];
	    principal = new javax.security.auth.x500.X500Principal
		(certificate.getSubjectDN().getName());
	    Key privateKey =
		keyStore.getKey(keyStoreAlias, privateKeyPassword);
	    if (privateKey == null
		|| !(privateKey instanceof PrivateKey))
	    {
		throw new FailedLoginException(
		    "Unable to recover key from keystore");
	    }

	    privateCredential = new X500PrivateCredential(
		certificate, (PrivateKey) privateKey, keyStoreAlias);
	} catch (KeyStoreException e) {
	    throw new LoginException("Error using keystore: " + e);
	} catch (NoSuchAlgorithmException e) {
	    throw new LoginException("Error using keystore: " + e);
	} catch (UnrecoverableKeyException e) {
	    throw new FailedLoginException(
					   "Unable to recover key from " +
					   "keystore: " + e);
	}
	if (debug) {
	    debugPrint("principal=" + principal +
		       "\n certificate="
		       + privateCredential.getCertificate() +
		       "\n alias =" + privateCredential.getAlias());
	}
    }

    /**
     * Abstract method to commit the authentication process (phase 2).
     *
     * <p> This method is called if the LoginContext's
     * overall authentication succeeded
     * (the relevant REQUIRED, REQUISITE, SUFFICIENT and OPTIONAL LoginModules
     * succeeded).
     *
     * <p> If this LoginModule's own authentication attempt
     * succeeded (checked by retrieving the private state saved by the
     * <code>login</code> method), then this method associates a
     * <code>X500Principal</code> for the subject distinguished name of the 
     * first certificate in the alias's credentials in the subject's
     * principals,the alias's certificate path in the subject's public 
     * credentials, and a<code>X500PrivateCredential</code> whose certificate
     * is the first  certificate in the alias's certificate path and whose
     * private key is the alias's private key in the subject's private
     * credentials.  If this LoginModule's own
     * authentication attempted failed, then this method removes
     * any state that was originally saved.
     *
     * <p>
     *
     * @exception LoginException if the commit fails
     *
     * @return true if this LoginModule's own login and commit
     *		attempts succeeded, or false otherwise.
     */

    public boolean commit() throws LoginException {
	switch (status) {
	case UNINITIALIZED:
	default:
	    throw new LoginException("The login module is not initialized");
	case INITIALIZED:
	    logoutInternal();
	    throw new LoginException("Authentication failed");
	case AUTHENTICATED:
	    if (commitInternal()) {
		return true;
	    } else {
		logoutInternal();
		throw new LoginException("Unable to retrieve certificates");
	    }
	case LOGGED_IN:
	    return true;
	}
    }

    private boolean commitInternal() throws LoginException {
	/* If the subject is not readonly add to the principal and credentials
	 * set; otherwise just return true
	 */
	if (subject.isReadOnly()) {
	    throw new LoginException ("Subject is set readonly");
	} else {
	    subject.getPrincipals().add(principal);
	    subject.getPublicCredentials().add(certP);
	    subject.getPrivateCredentials().add(privateCredential);
	    status = LOGGED_IN;
	    return true;
	}
    }

    /**
     * <p> This method is called if the LoginContext's
     * overall authentication failed.
     * (the relevant REQUIRED, REQUISITE, SUFFICIENT and OPTIONAL LoginModules
     * did not succeed).
     *
     * <p> If this LoginModule's own authentication attempt
     * succeeded (checked by retrieving the private state saved by the
     * <code>login</code> and <code>commit</code> methods),
     * then this method cleans up any state that was originally saved.
     *
     * <p>
     *
     * @exception LoginException if the abort fails.
     *
     * @return false if this LoginModule's own login and/or commit attempts
     *		failed, and true otherwise.
     */
	
    public boolean abort() throws LoginException {
	switch (status) {
	case UNINITIALIZED:
	default:
	    return false;
	case INITIALIZED:
	    return false;
	case AUTHENTICATED:
	    logoutInternal();
	    return true;
	case LOGGED_IN:
	    logoutInternal();
	    return true;
	}
    }
    /**
     * Logout a user.
     *
     * <p> This method removes the Principals, public credentials and the
     * private credentials that were added by the <code>commit</code> method.
     *
     * <p>
     *
     * @exception LoginException if the logout fails.
     *
     * @return true in all cases since this <code>LoginModule</code>
     *		should not be ignored.
     */

    public boolean logout() throws LoginException {
	if (debug)
	    debugPrint("Entering logout " + status);
	switch (status) {
	case UNINITIALIZED:
	    throw new LoginException
		("The login module is not initialized");
	case INITIALIZED:
	case AUTHENTICATED:
	default:
	   // impossible for LoginModule to be in AUTHENTICATED 
	   // state
	   // assert status != AUTHENTICATED;
	    return false;
	case LOGGED_IN:
	    logoutInternal();
	    return true;
	}
    }

    private void logoutInternal() throws LoginException {
	if (debug)
	    debugPrint("Entering logoutInternal");
	Arrays.fill(keyStorePassword, '\0');
	keyStorePassword = null;
	Arrays.fill(privateKeyPassword, '\0');
	privateKeyPassword = null;
	if (subject.isReadOnly()) {
	    // attempt to destroy the private credential
	    // even if the Subject is read-only
	    principal = null;
	    certP = null;
	    status = INITIALIZED;
	    // destroy the private credential
	    Iterator it = subject.getPrivateCredentials().iterator();
	    while (it.hasNext()) {
		Object obj = it.next();
		if (privateCredential.equals(obj)) {
		    privateCredential = null;
		    try {
			((Destroyable)obj).destroy();
			if (debug)
			    debugPrint("Destroyed private credential, " +
				       obj.getClass().getName());
			break;
		    } catch (DestroyFailedException dfe) {
			throw new LoginException
			    ("Unable to destroy private credential, " 
			     + obj.getClass().getName()
			     + ": " + dfe.getMessage());
		    }
		}
	    }
	    
	    // throw an exception because we can not remove
	    // the principal and public credential from this
	    // read-only Subject
	    throw new LoginException
		("Unable to remove Principal (" 
		 + "X500Principal "
		 + ") and public credential (certificatepath) "
		 + "from read-only Subject");
	}
	if (principal != null) {
	    subject.getPrincipals().remove(principal);
	    principal = null;
	}
	if (certP != null) {
	    subject.getPublicCredentials().remove(certP);
	    certP = null;
	}
	if (privateCredential != null) {
	    subject.getPrivateCredentials().remove(privateCredential);
	    privateCredential = null;
	}
	status = INITIALIZED;
    }

    /** Reads user password from given input stream. */
    private char[] readPassword(InputStream in) throws IOException {
	char[] lineBuffer;
	char[] buf;
	int i;

	buf = lineBuffer = new char[128];

	int room = buf.length;
	int offset = 0;
	int c;

	boolean done = false;
	while (!done) {
	    switch (c = in.read()) {
	      case -1: 
	      case '\n':
		  done = true;
		  break;

	      case '\r':
		int c2 = in.read();
		if ((c2 != '\n') && (c2 != -1)) {
		    if (!(in instanceof PushbackInputStream)) {
			in = new PushbackInputStream(in);
		    }
		    ((PushbackInputStream)in).unread(c2);
		} else {
		    done = true;
		    break;
		}

	      default:
		if (--room < 0) {
		    buf = new char[offset + 128];
		    room = buf.length - offset - 1;
		    System.arraycopy(lineBuffer, 0, buf, 0, offset);
		    Arrays.fill(lineBuffer, ' ');
		    lineBuffer = buf;
		}
		buf[offset++] = (char) c;
		break;
	    }
	}

	if (offset == 0) {
	    return null;
	}

	char[] ret = new char[offset];
	System.arraycopy(buf, 0, ret, 0, offset);
	Arrays.fill(buf, ' ');

	return ret;
    }

    private void debugPrint(String message) {
	// we should switch to logging API
	    System.err.println("Debug KeyStoreLoginModule: " + message);
    }
}
