/*
 * 
 * Copyright 2004 Sun Microsystems, Inc. All Rights Reserved.
 * 
 * The contents of this file are subject to the Sun Community Source
 * License v3.0/Jini Technology Specific Attachment v 1.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://wwws.sun.com/software/jini/licensing/SCSL3_JiniTSA1.html.
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See
 * the License for the specific language governing rights and limitations
 * under the License.
 * 
 * The Reference Code is Jini Technology Core Platform code, v2.0. The
 * Developer of the Reference Code is Sun Microsystems, Inc.
 * 
 * Contributor(s): Sun Microsystems, Inc.
 * 
 * The contents of this file comply with the Jini Technology Core
 * Platform Compatibility Kit, v2.0A.
 * 
 * Tester(s): Sun Microsystems, Inc.
 * 
 * Test Platform(s):
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Solaris(TM)SPARC/x86
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Linux (Intel x86)
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Windows (Intel Platform)
 * 
 */

package com.sun.jini.jeri.internal.http;

import com.sun.jini.jeri.internal.runtime.BASE64Encoder;
import java.net.Authenticator;
import java.net.InetAddress;
import java.net.PasswordAuthentication;
import java.net.UnknownHostException;
import java.security.AccessController;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Remote HTTP server version/authentication information.
 * 
 * REMIND: need manage/null out password more strictly?
 *
 * @author Sun Microsystems, Inc.
 * 
 */
class ServerInfo implements Cloneable {
    
    /** blank timestamp value */
    static final long NO_TIMESTAMP = -1L;
    
    /** hexadecimal char conversion table */
    private static final char[] hexChars = {
	'0', '1', '2', '3', '4', '5', '6', '7',
	'8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
    };

    /** server host name */
    final String host;
    /** server port */
    final int port;
    /** HTTP major version */
    int major = 1;
    /** HTTP minor version */
    int minor = 0;
    /** authentication scheme, if any */
    String authScheme;
    /** authentication realm */
    String authRealm;
    /** authentication algorithm */
    String authAlgorithm;
    /** authentication cookie */
    String authOpaque;
    /** authentication challenge */
    String authNonce;
    /** authentication username */
    String authUser;
    /** authentication password */
    String authPassword;
    /** time of last update */
    long timestamp = NO_TIMESTAMP;

    /**
     * Creates new ServerInfo for server at given host/port.
     */
    ServerInfo(String host, int port) {
	this.host = host;
	this.port = port;
    }
    
    /**
     * Sets authentication information based on contents of given challenge
     * string (which should be the value of either the "WWW-Authenticate" or
     * "Proxy-Authenticate" header fields).  If given string is null or empty,
     * clears any previous authentication information.
     */
    void setAuthInfo(String str) throws HttpParseException {
	if (str == null || str.length() == 0) {
	    authScheme = null;
	    authRealm = null;
	    authAlgorithm = null;
	    authOpaque = null;
	    authNonce = null;
	    authUser = null;
	    authPassword = null;
	    return;
	}
	
	LineParser lp = new LineParser(str);
	Map entries = lp.getEntries("Digest");
	if (entries != null) {
	    String realm = (String) entries.get("realm");
	    String nonce = (String) entries.get("nonce");
	    if (realm != null && nonce != null) {
		authScheme = "Digest";
		authRealm = realm;
		authNonce = nonce;
		authAlgorithm = (String) entries.get("algorithm");
		authOpaque = (String) entries.get("opaque");
		if (!"true".equalsIgnoreCase((String) entries.get("stale"))) {
		    authUser = null;
		    authPassword = null;
		}
		return;
	    }
	}

	if ((entries = lp.getEntries("Basic")) != null) {
	    String realm = (String) entries.get("realm");
	    if (realm != null) {
		authScheme = "Basic";
		authRealm = realm;
		authAlgorithm = null;
		authOpaque = null;
		authNonce = null;
		authUser = null;
		authPassword = null;
		return;
	    }
	}

	// REMIND: no supported schemes found; clear auth info?
    }

    /**
     * Updates authentication information based on contents of given string
     * (which should be the value of either the "Authorization-Info" or
     * "Proxy-Authorization-Info" header fields).  If given string is null or
     * empty, current authentication settings are left unchanged.
     */
    void updateAuthInfo(String str) throws HttpParseException {
	if (str == null || str.length() == 0) {
	    return;
	}
	if ("Digest".equals(authScheme)) {
	    LineParser lp = new LineParser(str);
	    Map entries = lp.getAllEntries();
	    String nextNonce = (String) entries.get("nextnonce");
	    if (nextNonce != null) {
		authNonce = nextNonce;
	    }
	}
    }
    
    /**
     * Returns (possibly null) authorization string based on current
     * authentication information in conjunction with the given request
     * arguments.
     */
    String getAuthString(String protocol, String method, String uri) {
	if (authScheme == null) {
	    return null;
	}

	if (authUser == null) {
	    PasswordAuthentication pa = getPassword(protocol);
	    if (pa == null) {
		return null;
	    }
	    String user = pa.getUserName();
	    char[] password = pa.getPassword();
	    if (user == null || password == null) {
		return null;
	    }
	    authUser = user;
	    authPassword = new String(password);
	}
	
	if (authScheme.equals("Basic")) {
	    BASE64Encoder enc = new BASE64Encoder();
	    return "Basic " + 
		enc.encode((authUser + ":" + authPassword).getBytes());
	} else if (authScheme.equals("Digest")) {
	    String digest;
	    try {
		digest = computeDigest(method, uri);
	    } catch (NoSuchAlgorithmException ex) {
		return null;
	    }

	    String response = "Digest " +
		"username=\"" + authUser + "\", " +
		"realm=\"" + authRealm + "\", " +
		"nonce=\"" + authNonce + "\", " +
		"uri=\"" + uri + "\", " +
		"response=\"" + digest + "\"";
	    if (authOpaque != null) {
		response += ", opaque=\"" + authOpaque + "\"";
	    }
	    if (authAlgorithm != null) {
		response += ", algorithm=" + authAlgorithm;
	    }
	    return response;
	} else {
	    throw new InternalError();
	}
    }
    
    /**
     * Computes digest authentication response for request using the given
     * method and uri.  Throws NoSuchAlgorithmException if server-specified
     * digest algorithm not supported.
     */
    private String computeDigest(String method, String uri) 
	throws NoSuchAlgorithmException
    {
	// REMIND: cache MessageDigest?
	MessageDigest md = MessageDigest.getInstance(
	    (authAlgorithm != null) ? authAlgorithm : "MD5");
	String hashA1 = 
	    encode(md, authUser + ":" + authRealm + ":" + authPassword);
	String hashA2 = encode(md, method + ":" + uri);
	return encode(md, hashA1 + ":" + authNonce + ":" + hashA2);
    }
    
    /**
     * Returns digest of the given string, represented as string of hexadecimal
     * digits.
     */
    private String encode(MessageDigest md, String str) {
	md.reset();
	byte[] digest = md.digest(str.getBytes());
	StringBuffer sbuf = new StringBuffer(digest.length * 2);
	for (int i = 0; i < digest.length; i++) {
	    sbuf.append(hexChars[(digest[i] >>> 4) & 0xF]);
	    sbuf.append(hexChars[digest[i] & 0xF]);
	}
	return sbuf.toString();
    }

    public Object clone() {
	try {
	    return super.clone();
	} catch (CloneNotSupportedException ex) {
	    throw new InternalError();
	}
    }
    
    /**
     * Class for parsing multi-part HTTP header lines that may appear as the
     * values of the WWW-Authenticate, Proxy-Authenticate, Authorization-Info
     * or Proxy-Authorization-Info header lines.
     */
    private static class LineParser {
	
	/* token types */
	private static final int EOL    = -1;
	private static final int WORD   = 0;
	private static final int QUOTE  = 1;
	private static final int COMMA  = 2;
	private static final int EQUALS = 3;

	private final List entries = new ArrayList();
	private final char[] ca;
	private int pos = 0;
	private String tokenString = null;
	
	/**
	 * Attempts to parse the given line into a series of key/optional value
	 * definitions.  Throws an HttpParseException if the line contains
	 * syntax errors.
	 */
	LineParser(String line) throws HttpParseException {
	    ca = line.toCharArray();
	    int tok = nextToken();

	    while (tok != EOL) {
		String key;

		if (tok == COMMA) {
		    tok = nextToken();
		    continue;
		} else if (tok == WORD) {
		    key = tokenString;
		} else {
		    throw new HttpParseException("illegal key");
		}
		
		tok = nextToken();
		if (tok == COMMA) {
		    entries.add(new String[] { key, null });
		    tok = nextToken();
		    continue;
		} else if (tok != EQUALS) {
		    entries.add(new String[] { key, null });
		    continue;
		}
		
		tok = nextToken();
		if (tok != WORD && tok != QUOTE) {
		    throw new HttpParseException("illegal value");
		}
		entries.add(new String[] { key, tokenString });
		
		tok = nextToken();
		if (tok == COMMA) {
		    tok = nextToken();
		    continue;
		} else if (tok != EOL) {
		    throw new HttpParseException("illegal separator");
		}
	    }
	}
	
	/**
	 * Returns code indicating next token in line.  If token type is WORD,
	 * tokenString is set to the word text; if returned type is QUOTE,
	 * tokenString is set to the quoted string's contents.
	 */
	private int nextToken() throws HttpParseException {
	    int mark;

	    while (pos < ca.length && Character.isWhitespace(ca[pos])) {
		pos++;
	    }
	    if (pos >= ca.length) {
		return EOL;
	    }
	    switch (ca[pos]) {
		case ',':
		    pos++;
		    return COMMA;
		    
		case '=':
		    pos++;
		    return EQUALS;
		    
		case '\"':
		    mark = ++pos;
		    while (pos < ca.length && ca[pos] != '\"') {
			pos++;
		    }
		    if (pos >= ca.length) {
			throw new HttpParseException(
			    "unterminated quote string");
		    }
		    tokenString = new String(ca, mark, pos++ - mark);
		    return QUOTE;
		    
		default:
		    mark = pos;
		    while (pos < ca.length) {
			char c = ca[pos];
			if (c == ',' || c == '=' || c == '\"' ||
			    Character.isWhitespace(c))
			{
			    break;
			}
			pos++;
		    }
		    tokenString = new String(ca, mark, pos - mark);
		    return WORD;
	    }
	}

	/**
	 * Returns all key/value entries associated with the given
	 * authorization scheme in the parsed line, or null if the given scheme
	 * was not described in the parsed line.  Authorization scheme
	 * identifiers are those which appear without a subsequent '=' or ','
	 * character before the next word; key/value entries are associated
	 * with the nearest preceding scheme identifier.  All key strings are
	 * converted to lower case.
	 */
	Map getEntries(String scheme) {
	    Map map = null;
	    String[][] ea = 
		(String[][]) entries.toArray(new String[entries.size()][]);

	    int i;
	    for (i = 0; i < ea.length; i++) {
		if (ea[i][1] == null && scheme.equalsIgnoreCase(ea[i][0])) {
		    map = new HashMap();
		    break;
		}
	    }
	    if (map != null) {
		for (i++; i < ea.length && ea[i][1] != null; i++) {
		    map.put(ea[i][0].toLowerCase(), ea[i][1]);
		}
	    }
	    return map;
	}
	
	/**
	 * Returns the key/value entries encountered in the parsed line.  This
	 * method should be used to obtain the parse results for
	 * Authorization-Info and Proxy-Authorization-Info values, which do not
	 * contain authorization scheme identifiers.  All key strings are
	 * converted to lower case.
	 */
	Map getAllEntries() {
	    Map map = new HashMap();
	    String[][] ea = 
		(String[][]) entries.toArray(new String[entries.size()][]);
	    for (int i = 0; i < ea.length; i++) {
		map.put(ea[i][0].toLowerCase(), ea[i][1]);
	    }
	    return map;
	}
    }
    
    /**
     * Obtains PasswordAuthentication from the currently installed
     * Authenticator.
     */
    private PasswordAuthentication getPassword(final String protocol) {
	return (PasswordAuthentication) AccessController.doPrivileged(
	    new PrivilegedAction() {
		public Object run() {
		    InetAddress addr = null;
		    try {
			addr = InetAddress.getByName(host);
		    } catch (UnknownHostException ex) {
		    }

		    return Authenticator.requestPasswordAuthentication(
			addr, port, protocol, authRealm, authScheme);
		}
	    }
	);
    }
}
