/*
 * RHQ Management Platform
 * Copyright (C) 2005-2008 Red Hat, Inc.
 * All rights reserved.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation version 2 of the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 */
package org.rhq.enterprise.server.auth;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import org.jboss.security.Util;

/**
 * Provides a random password generator and a storage area that helps temporarily authenticate users even though their
 * true passwords are not known. This is necessary when a scheduled action is triggered and we want to run as the user,
 * but we don't know the user's true password. In other words, these temporary passwords should only be used if we have
 * previously validated the user for a given session ID. Session passwords generated by this object can only be used
 * once, and they must be validated fairly quickly after being generated.
 *
 * <p>This object is thread-safe.</p>
 *
 * @author John Mazzitelli
 */
public class TemporarySessionPasswordGenerator {
    /**
     * This is a separator character that will appear between the session ID and a random set of characters in a
     * generated session password. This character must NOT be in {@link #SESSION_PASSWORD_PREFIX}.
     */
    private static final char SESSION_PASSWORD_SEPARATOR = '$';

    /**
     * A prefix to all generated session passwords; this makes it possible to quickly identify a possible session
     * password. While this doesn't exclusively identify only session passwords, it helps eliminate most passwords from
     * being recognized as a session password. None of the characters in this string will be the same as
     * {@link #SESSION_PASSWORD_SEPARATOR}.
     */
    private static final String SESSION_PASSWORD_PREFIX = "_x|=~";

    /**
     * Map keyed on session Integers whose values are Longs indicating when the session's password was created.
     */
    private final Map<Integer, Long> m_sessionPasswordCreationDates;

    /**
     * Map keyed on session Integers whose values are the MD5 strings of the generated passwords.
     */
    private final Map<Integer, String> m_sessionPasswordMD5Strings;

    /**
     * The amount of milliseconds that a temporary password is guaranteed to remain valid (a password may actually live
     * longer than this, but it is not guaranteed to do so)
     */
    private final int m_timeoutMillis;

    /**
     * Constructor for {@link TemporarySessionPasswordGenerator}.
     */
    public TemporarySessionPasswordGenerator() {
        this(60 * 60 * 1000); // be default, give a copious 60 minutes - this is easily enough for what we need to use this class for
    }

    /**
     * Constructor for {@link TemporarySessionPasswordGenerator}.
     *
     * @param timeoutMillis the amount of milliseconds that a temporary password is guaranteed to remain valid (a
     *                      password may actually live longer than this, but it is not guaranteed to do so)
     */
    public TemporarySessionPasswordGenerator(int timeoutMillis) {
        m_sessionPasswordCreationDates = new HashMap<Integer, Long>();
        m_sessionPasswordMD5Strings = new HashMap<Integer, String>();
        m_timeoutMillis = timeoutMillis;

        return;
    }

    /**
     * Given a session identifier, this will generate a temporary password that can be used to identify the user whose
     * session that is. Use {@link #authenticateSessionPassword(String)} to later check for this password's
     * authenticity. This session password can only be authenticated once.
     *
     * @param  sessionId the session of a user who needs a temporary password assigned
     *
     * @return a password that can be used to associate a session with a user
     *
     * @see    #authenticateSessionPassword(String)
     */
    public String generateSessionPassword(int sessionId) {
        StringBuffer pwBuf = new StringBuffer(SESSION_PASSWORD_PREFIX);

        pwBuf.append(sessionId);
        pwBuf.append(SESSION_PASSWORD_SEPARATOR);
        pwBuf.append(generateRandomPassword());

        String pw = pwBuf.toString();

        storeSessionPassword(new Integer(sessionId), pw);

        return pw;
    }

    /**
     * Returns the session's ID if the given session's password was one that was generated by this object. Returns
     * <code>null</code> if the password was invalid and thus the user attempting to log in should be denied access.
     * Once authenticated, the password will no longer be valid and can not be used to validate the session again.
     *
     * @param  password the password to test
     *
     * @return session's ID if the session's <code>password</code> was one that was generated by this object; <code>
     *         null</code> otherwise
     *
     * @throws IllegalStateException if the password was in a valid format but the password's MD5 does not match; this
     *                               exception will only be thrown when it looks like someone is trying to hack a
     *                               session password
     *
     * @see    #generateSessionPassword(int)
     */
    public Integer authenticateSessionPassword(String password) throws IllegalStateException {
        int separator = (password != null) ? password.indexOf(SESSION_PASSWORD_SEPARATOR) : -1;

        // let's quickly eliminate most passwords
        if ((separator == -1) || !password.startsWith(SESSION_PASSWORD_PREFIX)) {
            return null;
        }

        String sessionStr = password.substring(SESSION_PASSWORD_PREFIX.length(), separator);
        Integer sessionId;

        try {
            sessionId = new Integer(sessionStr);
        } catch (NumberFormatException e) {
            // kinda looked like our password, but it can't be since this should have been a valid session ID number
            return null;
        }

        String passwordMD5 = generateMD5(password);
        String sessionMD5;

        synchronized (this) {
            purgeOldSessionPasswords(); // let's do this cleanup now; must do this inside the sychronized block

            // we only allow a session password to be authenticated once, so remove all traces of it while retrieving it
            sessionMD5 = m_sessionPasswordMD5Strings.remove(sessionId);
            m_sessionPasswordCreationDates.remove(sessionId);
        }

        // the session password was either already authenticated or was removed because it was too old
        if (sessionMD5 == null) {
            return null;
        }

        // the password was wrong, if we got this far and the password was wrong, someone is trying to hack
        if (!sessionMD5.equals(passwordMD5)) {
            throw new IllegalStateException(
                "WARNING! A session was attempted to be reattached but the login password was invalid");
        }

        return sessionId;
    }

    /**
     * Stores the generated session password in our internal data structures so we can authenticate the session using
     * the password.
     *
     * @param sessionId the session identifier
     * @param pw        the password to store
     */
    private void storeSessionPassword(Integer sessionId, String pw) {
        synchronized (this) {
            purgeOldSessionPasswords(); // let's do this cleanup now; must do this inside the sychronized block

            m_sessionPasswordCreationDates.put(sessionId, new Long(System.currentTimeMillis()));
            m_sessionPasswordMD5Strings.put(sessionId, generateMD5(pw));
        }

        return;
    }

    /**
     * Looks at the current set of session passwords and removes those that are old. Not sure why these passwords would
     * be hanging around, since usually generated session passwords are authenticated fairly quickly thereafter. But
     * just in case something went wrong between the time the password was generated and the time it was to be
     * authenticated, this method is used to clean up.
     *
     * <p>To avoid having to enter another synchronized block and again attempt to aquire a lock, make sure you call
     * this method from within a sychronized block - this method requires "this" object's lock.</p>
     */
    private void purgeOldSessionPasswords() {
        long earliestAllowed = System.currentTimeMillis() - m_timeoutMillis;
        List<Integer> doomedSessions = new ArrayList<Integer>();

        for (Map.Entry<Integer, Long> entry : m_sessionPasswordCreationDates.entrySet()) {
            Long creationTime = entry.getValue();

            if (creationTime.longValue() < earliestAllowed) {
                doomedSessions.add(entry.getKey());
            }
        }

        for (Integer doomedSession : doomedSessions) {
            m_sessionPasswordCreationDates.remove(doomedSession);
            m_sessionPasswordMD5Strings.remove(doomedSession);
        }

        return;
    }

    /**
     * Generates a random password that will be used as part of the session password.
     *
     * @return a random password
     */
    private String generateRandomPassword() {
        // generate a random password - this will usually be used as part of a Basic Authentication to our WebDAV server
        // make sure these can be encoded in a URL (no + signs - don't ask - see UrlUtil.encodeMore)
        // make sure SESSION_PASSWORD_SEPARATOR character is NOT in the chars variable!
        StringBuffer password = new StringBuffer();
        String chars = "0a3X1bWd-2VeU*fTgH7sGtF6YS^KpJqI4rh7RiQj_5P.kOZlNm|0Mn~Lou!9EvD4wACxB8yz";
        Random random = new Random(System.currentTimeMillis());
        int passwordLength = 0;

        while (passwordLength < 64) {
            passwordLength = random.nextInt(129);
        }

        random = new Random(System.currentTimeMillis() + passwordLength);

        for (int i = 0; i < passwordLength; i++) {
            password.append(chars.charAt(random.nextInt(chars.length())));
        }

        return password.toString();
    }

    /**
     * Given a password string, this will return its MD5.
     *
     * @param  password the string whose MD5 is to be calculated
     *
     * @return the MD5 string
     */
    private String generateMD5(String password) {
        return Util.createPasswordHash("MD5", "base64", null, null, password);
    }
}