/*
 * This library is part of OpenCms -
 * the Open Source Content Management System
 *
 * Copyright (c) Alkacon Software GmbH & Co. KG (http://www.alkacon.com)
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library 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
 * Lesser General Public License for more details.
 *
 * For further information about Alkacon Software GmbH & Co. KG, please see the
 * company website: http://www.alkacon.com
 *
 * For further information about OpenCms, please see the
 * project website: http://www.opencms.org
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */

package org.opencms.db;

import org.opencms.file.CmsObject;
import org.opencms.file.CmsUser;
import org.opencms.main.CmsLog;
import org.opencms.main.OpenCms;
import org.opencms.security.CmsAuthentificationException;
import org.opencms.security.CmsRole;
import org.opencms.security.CmsRoleViolationException;
import org.opencms.security.CmsUserDisabledException;
import org.opencms.security.Messages;
import org.opencms.util.CmsStringUtil;

import java.util.Date;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.Map;
import java.util.Set;

import org.apache.commons.logging.Log;

/**
 * Provides functions used to check the validity of a user login.<p>
 *
 * Stores invalid login attempts and disables a user account temporarily in case
 * the configured threshold of invalid logins is reached.<p>
 *
 * The invalid login attempt storage operates on a combination of user name, login remote IP address and
 * user type. This means that a user can be disabled for one remote IP, but still be enabled for
 * another remote IP.<p>
 *
 * Also allows to temporarily disallow logins (for example in case of maintenance work on the system).<p>
 *
 * @since 6.0.0
 */
public class CmsLoginManager {

    /**
     * Contains the data stored for each user in the storage for invalid login attempts.<p>
     */
    private class CmsUserData {

        /** The start time this account was disabled. */
        private long m_disableTimeStart;

        /** The count of the failed attempts. */
        private int m_invalidLoginCount;

        /**
         * Creates a new user data instance.<p>
         */
        protected CmsUserData() {

            // a new instance is creted only if there already was one failed attempt
            m_invalidLoginCount = 1;
        }

        /**
         * Returns the bad attempt count for this user.<p>
         *
         * @return the bad attempt count for this user
         */
        protected Integer getInvalidLoginCount() {

            return new Integer(m_invalidLoginCount);
        }

        /**
         * Returns the date this disabled user is released again.<p>
         *
         * @return the date this disabled user is released again
         */
        protected Date getReleaseDate() {

            return new Date(m_disableTimeStart + m_disableMillis + 1);
        }

        /**
         * Increases the bad attempt count, disables the data in case the
         * configured threshold is reached.<p>
         */
        protected void increaseInvalidLoginCount() {

            m_invalidLoginCount++;
            if (m_invalidLoginCount >= m_maxBadAttempts) {
                // threshold for bad login attempts has been reached for this user
                if (m_disableTimeStart == 0) {
                    // only disable in case this user has not already been disabled
                    m_disableTimeStart = System.currentTimeMillis();
                }
            }
        }

        /**
         * Returns <code>true</code> in case this user has been temporarily disabled.<p>
         *
         * @return <code>true</code> in case this user has been temporarily disabled
         */
        protected boolean isDisabled() {

            if (m_disableTimeStart > 0) {
                // check if the disable time is already over
                long currentTime = System.currentTimeMillis();
                if ((currentTime - m_disableTimeStart) > m_disableMillis) {
                    // disable time is over
                    m_disableTimeStart = 0;
                }
            }
            return m_disableTimeStart > 0;
        }
    }

    /** Default token lifetime. */
    public static final long DEFAULT_TOKEN_LIFETIME = 3600 * 24 * 1000;

    /** Default lock time if treshold for bad login attempts is reached. */
    public static final int DISABLE_MINUTES_DEFAULT = 15;

    /** Default setting for the security option. */
    public static final boolean ENABLE_SECURITY_DEFAULT = false;

    /** Separator used for storage keys. */
    public static final String KEY_SEPARATOR = "_";

    /** Default for bad login attempts. */
    public static final int MAX_BAD_ATTEMPTS_DEFAULT = 3;

    /** The logger instance for this class. */
    private static final Log LOG = CmsLog.getLog(CmsLoginManager.class);

    /** The milliseconds to disable an account if the threshold is reached. */
    protected int m_disableMillis;

    /** The minutes to disable an account if the threshold is reached. */
    protected int m_disableMinutes;

    /** The flag to determine if the security option ahould be enabled on the login dialog. */
    protected boolean m_enableSecurity;

    /** The number of bad login attempts allowed before an account is temporarily disabled. */
    protected int m_maxBadAttempts;

    /** The storage for the bad login attempts. */
    protected Map<String, CmsUserData> m_storage;

    /** The token lifetime. */
    protected String m_tokenLifetimeStr;

    /** The login message, setting this may also disable logins for non-Admin users. */
    private CmsLoginMessage m_loginMessage;

    /** Max inactivity time. */
    private String m_maxInactive;

    /** Password change interval. */
    private String m_passwordChangeInterval;

    /** User data check interval. */
    private String m_userDateCheckInterval;

    /**
     * Creates a new storage for invalid logins.<p>
     *
     * @param disableMinutes the minutes to disable an account if the threshold is reached
     * @param maxBadAttempts the number of bad login attempts allowed before an account is temporarily disabled
     * @param enableSecurity flag to determine if the security option should be enabled on the login dialog
     * @param tokenLifetime the lifetime of authorization tokens, i.e. the time for which they are valid
     * @param maxInactive maximum inactivity time
     * @param passwordChangeInterval the password change interval
     * @param userDataCheckInterval the user data check interval
     */
    public CmsLoginManager(
        int disableMinutes,
        int maxBadAttempts,
        boolean enableSecurity,
        String tokenLifetime,
        String maxInactive,
        String passwordChangeInterval,
        String userDataCheckInterval) {

        m_maxBadAttempts = maxBadAttempts;
        if (m_maxBadAttempts >= 0) {
            // otherwise the invalid login storage is sisabled
            m_disableMinutes = disableMinutes;
            m_disableMillis = disableMinutes * 60 * 1000;
            m_storage = new Hashtable<String, CmsUserData>();
        }
        m_enableSecurity = enableSecurity;
        m_tokenLifetimeStr = tokenLifetime;
        m_maxInactive = maxInactive;
        m_passwordChangeInterval = passwordChangeInterval;
        m_userDateCheckInterval = userDataCheckInterval;
    }

    /**
     * Returns the key to use for looking up the user in the invalid login storage.<p>
     *
     * @param userName the name of the user
     * @param remoteAddress the remore address (IP) from which the login attempt was made
     *
     * @return the key to use for looking up the user in the invalid login storage
     */
    private static String createStorageKey(String userName, String remoteAddress) {

        StringBuffer result = new StringBuffer();
        result.append(userName);
        result.append(KEY_SEPARATOR);
        result.append(remoteAddress);
        return result.toString();
    }

    /**
     * Checks whether a user account can be locked because of inactivity.
     *
     * @param cms the CMS context
     * @param user the user to check
     * @return true if the user may be locked after being inactive for too long
     */
    public boolean canLockBecauseOfInactivity(CmsObject cms, CmsUser user) {

        return !user.isManaged()
            && !user.isWebuser()
            && !OpenCms.getDefaultUsers().isDefaultUser(user.getName())
            && !OpenCms.getRoleManager().hasRole(cms, user.getName(), CmsRole.ROOT_ADMIN);
    }

    /**
     * Checks whether the given user has been inactive for longer than the configured limit.<p>
     *
     * If no max inactivity time is configured, always returns false.
     *
     * @param user the user to check
     * @return true if the user has been inactive for longer than the configured limit
     */
    public boolean checkInactive(CmsUser user) {

        if (m_maxInactive == null) {
            return false;
        }

        try {
            long maxInactive = CmsStringUtil.parseDuration(m_maxInactive, Long.MAX_VALUE);
            return (System.currentTimeMillis() - user.getLastlogin()) > maxInactive;
        } catch (Exception e) {
            LOG.warn(e.getLocalizedMessage(), e);
            return false;
        }
    }

    /**
     * Checks if the threshold for the invalid logins has been reached for the given user.<p>
     *
     * In case the configured threshold is reached, an Exception is thrown.<p>
     *
     * @param userName the name of the user
     * @param remoteAddress the remote address (IP) from which the login attempt was made
     *
     * @throws CmsAuthentificationException in case the threshold of invalid login attempts has been reached
     */
    public void checkInvalidLogins(String userName, String remoteAddress) throws CmsAuthentificationException {

        if (m_maxBadAttempts < 0) {
            // invalid login storage is disabled
            return;
        }

        String key = createStorageKey(userName, remoteAddress);
        // look up the user in the storage
        CmsUserData userData = m_storage.get(key);
        if ((userData != null) && (userData.isDisabled())) {
            // threshold of invalid logins is reached
            throw new CmsUserDisabledException(
                Messages.get().container(
                    Messages.ERR_LOGIN_FAILED_TEMP_DISABLED_4,
                    new Object[] {
                        userName,
                        remoteAddress,
                        userData.getReleaseDate(),
                        userData.getInvalidLoginCount()}));
        }
    }

    /**
     * Checks if a login is currently allowed.<p>
     *
     * In case no logins are allowed, an Exception is thrown.<p>
     *
     * @throws CmsAuthentificationException in case no logins are allowed
     */
    public void checkLoginAllowed() throws CmsAuthentificationException {

        if ((m_loginMessage != null) && (m_loginMessage.isLoginCurrentlyForbidden())) {
            // login message has been set and is active
            throw new CmsAuthentificationException(
                Messages.get().container(Messages.ERR_LOGIN_FAILED_WITH_MESSAGE_1, m_loginMessage.getMessage()));
        }
    }

    /**
     * Returns the minutes an account gets disabled after too many failed login attempts.<p>
     *
     * @return the minutes an account gets disabled after too many failed login attempts
     */
    public int getDisableMinutes() {

        return m_disableMinutes;
    }

    /**
     * Returns the current login message that is displayed if a user logs in.<p>
     *
     * if <code>null</code> is returned, no login message has been currently set.<p>
     *
     * @return  the current login message that is displayed if a user logs in
     */
    public CmsLoginMessage getLoginMessage() {

        return m_loginMessage;
    }

    /**
     * Returns the number of bad login attempts allowed before an account is temporarily disabled.<p>
     *
     * @return the number of bad login attempts allowed before an account is temporarily disabled
     */
    public int getMaxBadAttempts() {

        return m_maxBadAttempts;
    }

    /**
     * Gets the max inactivity time.<p>
     *
     * @return the max inactivity time
     */
    public String getMaxInactive() {

        return m_maxInactive;
    }

    /**
     * Gets the password change interval.<p>
     *
     * @return the password change interval
     */
    public long getPasswordChangeInterval() {

        if (m_passwordChangeInterval == null) {
            return Long.MAX_VALUE;
        } else {
            return CmsStringUtil.parseDuration(m_passwordChangeInterval, Long.MAX_VALUE);
        }
    }

    /**
     * Gets the raw password change interval string.<p>
     *
     * @return the configured string for the password change interval
     */
    public String getPasswordChangeIntervalStr() {

        return m_passwordChangeInterval;
    }

    /**
     * Gets the authorization token lifetime in milliseconds.<p>
     *
     * @return the authorization token lifetime in milliseconds
     */
    public long getTokenLifetime() {

        if (m_tokenLifetimeStr == null) {
            return DEFAULT_TOKEN_LIFETIME;
        }
        return CmsStringUtil.parseDuration(m_tokenLifetimeStr, DEFAULT_TOKEN_LIFETIME);
    }

    /**
     * Gets the configured token lifetime as a string.<p>
     *
     * @return the configured token lifetime as a string
     */
    public String getTokenLifetimeStr() {

        return m_tokenLifetimeStr;
    }

    /**
     * Gets the user data check interval.<p>
     *
     * @return the user data check interval
     */
    public long getUserDataCheckInterval() {

        if (m_userDateCheckInterval == null) {
            return Long.MAX_VALUE;
        } else {
            return CmsStringUtil.parseDuration(m_userDateCheckInterval, Long.MAX_VALUE);
        }
    }

    /**
     * Gets the raw user data check interval string.<p>
     *
     * @return the configured string for the user data check interval
     */
    public String getUserDataCheckIntervalStr() {

        return m_userDateCheckInterval;
    }

    /**
     * Returns if the security option ahould be enabled on the login dialog.<p>
     *
     * @return <code>true</code> if the security option ahould be enabled on the login dialog, otherwise <code>false</code>
     */
    public boolean isEnableSecurity() {

        return m_enableSecurity;
    }

    /**
     * Checks if a user is locked due to too many failed logins.<p>
     *
     * @param user the user to check
     *
     * @return true if the user is locked
     */
    public boolean isUserLocked(CmsUser user) {

        Set<String> keysForUser = getKeysForUser(user);
        for (String key : keysForUser) {
            CmsUserData data = m_storage.get(key);
            if ((data != null) && data.isDisabled()) {
                return true;
            }
        }
        return false;
    }

    /**
     * Removes the current login message.<p>
     *
     * This operation requires that the current user has role permissions of <code>{@link CmsRole#ROOT_ADMIN}</code>.<p>
     *
     * @param cms the current OpenCms user context
     *
     * @throws CmsRoleViolationException in case the current user does not have the required role permissions
     */
    public void removeLoginMessage(CmsObject cms) throws CmsRoleViolationException {

        OpenCms.getRoleManager().checkRole(cms, CmsRole.ROOT_ADMIN);
        m_loginMessage = null;
    }

    /**
     * Checks if a user is required to change his password now.<p>
     *
     * @param cms the current CMS context
     * @param user the user to check
     *
     * @return true if the user should be asked to change his password
     */
    public boolean requiresPasswordChange(CmsObject cms, CmsUser user) {

        if (user.isManaged()
            || user.isWebuser()
            || OpenCms.getDefaultUsers().isDefaultUser(user.getName())
            || OpenCms.getRoleManager().hasRole(cms, user.getName(), CmsRole.ROOT_ADMIN)) {
            return false;
        }
        String lastPasswordChangeStr = (String)user.getAdditionalInfo().get(
            CmsUserSettings.ADDITIONAL_INFO_LAST_PASSWORD_CHANGE);
        if (lastPasswordChangeStr == null) {
            return false;
        }
        long lastPasswordChange = Long.parseLong(lastPasswordChangeStr);
        if ((System.currentTimeMillis() - lastPasswordChange) > getPasswordChangeInterval()) {
            return true;
        }
        return false;
    }

    /**
     * Checks if a user is required to change his password now.<p>
     *
     * @param cms the current CMS context
     * @param user the user to check
     *
     * @return true if the user should be asked to change his password
     */
    public boolean requiresUserDataCheck(CmsObject cms, CmsUser user) {

        if (user.isManaged()
            || user.isWebuser()
            || OpenCms.getDefaultUsers().isDefaultUser(user.getName())
            || OpenCms.getRoleManager().hasRole(cms, user.getName(), CmsRole.ROOT_ADMIN)) {
            return false;
        }
        String lastCheckStr = (String)user.getAdditionalInfo().get(
            CmsUserSettings.ADDITIONAL_INFO_LAST_USER_DATA_CHECK);
        if (lastCheckStr == null) {
            return !CmsStringUtil.isEmptyOrWhitespaceOnly(getUserDataCheckIntervalStr());
        }
        long lastCheck = Long.parseLong(lastCheckStr);
        if ((System.currentTimeMillis() - lastCheck) > getUserDataCheckInterval()) {
            return true;
        }
        return false;
    }

    /**
     * Sets the login message to display if a user logs in.<p>
     *
     * This operation requires that the current user has role permissions of <code>{@link CmsRole#ROOT_ADMIN}</code>.<p>
     *
     * @param cms the current OpenCms user context
     * @param message the message to set
     *
     * @throws CmsRoleViolationException in case the current user does not have the required role permissions
     */
    public void setLoginMessage(CmsObject cms, CmsLoginMessage message) throws CmsRoleViolationException {

        if (OpenCms.getRunLevel() >= OpenCms.RUNLEVEL_3_SHELL_ACCESS) {
            // during configuration phase no permission check id required
            OpenCms.getRoleManager().checkRole(cms, CmsRole.ROOT_ADMIN);
        }
        m_loginMessage = message;
        if (m_loginMessage != null) {
            m_loginMessage.setFrozen();
        }
    }

    /**
     * Unlocks a user who has exceeded his number of failed login attempts so that he can try to log in again.<p>
     * This requires the "account manager" role.
     *
     * @param cms the current CMS context
     * @param user the user to unlock
     *
     * @throws CmsRoleViolationException if the permission check fails
     */
    public void unlockUser(CmsObject cms, CmsUser user) throws CmsRoleViolationException {

        OpenCms.getRoleManager().checkRole(cms, CmsRole.ACCOUNT_MANAGER.forOrgUnit(cms.getRequestContext().getOuFqn()));
        Set<String> keysToRemove = getKeysForUser(user);
        for (String keyToRemove : keysToRemove) {
            m_storage.remove(keyToRemove);
        }
    }

    /**
     * Adds an invalid attempt to login for the given user / IP to the storage.<p>
     *
     * In case the configured threshold is reached, the user is disabled for the configured time.<p>
     *
     * @param userName the name of the user
     * @param remoteAddress the remore address (IP) from which the login attempt was made
     */
    protected void addInvalidLogin(String userName, String remoteAddress) {

        if (m_maxBadAttempts < 0) {
            // invalid login storage is disabled
            return;
        }

        String key = createStorageKey(userName, remoteAddress);
        // look up the user in the storage
        CmsUserData userData = m_storage.get(key);
        if (userData != null) {
            // user data already contained in storage
            userData.increaseInvalidLoginCount();
        } else {
            // create an new data object for this user
            userData = new CmsUserData();
            m_storage.put(key, userData);
        }
    }

    /**
     * Removes all invalid attempts to login for the given user / IP.<p>
     *
     * @param userName the name of the user
     * @param remoteAddress the remore address (IP) from which the login attempt was made
     */
    protected void removeInvalidLogins(String userName, String remoteAddress) {

        if (m_maxBadAttempts < 0) {
            // invalid login storage is disabled
            return;
        }

        String key = createStorageKey(userName, remoteAddress);
        // just remove the user from the storage
        m_storage.remove(key);
    }

    /**
     * Helper method to get all the storage keys that match a user's name.<p>
     *
     * @param user the user for which to get the storage keys
     *
     * @return the set of storage keys
     */
    private Set<String> getKeysForUser(CmsUser user) {

        Set<String> keysToRemove = new HashSet<String>();
        for (Map.Entry<String, CmsUserData> entry : m_storage.entrySet()) {
            String key = entry.getKey();
            int separatorPos = key.lastIndexOf(KEY_SEPARATOR);
            String prefix = key.substring(0, separatorPos);
            if (user.getName().equals(prefix)) {
                keysToRemove.add(key);
            }
        }
        return keysToRemove;
    }
}