package com.atlassian.bitbucket.internal.crowd.sso;

import com.atlassian.bitbucket.auth.*;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.user.UserService;
import com.atlassian.crowd.exception.*;
import com.atlassian.crowd.integration.http.CrowdHttpAuthenticator;
import com.atlassian.crowd.model.user.User;
import com.atlassian.bitbucket.Product;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.crowd.sso.CrowdSsoService;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

import static com.atlassian.bitbucket.auth.HttpAuthenticationContext.METHOD_BASIC;

/**
 * {@link HttpAuthenticationHandler} for Crowd SSO authentication.
 */
public class CrowdSsoAuthenticationHandler
        implements HttpAuthenticationHandler, HttpAuthenticationSuccessHandler, HttpLogoutHandler {

    static final String KEY_CROWD_SSO_AUTHENTICATED = "stash.auth.crowd.sso.authenticated";
    private static final Logger log = LoggerFactory.getLogger(CrowdSsoAuthenticationHandler.class);

    private final I18nService i18nService;
    private final CrowdSsoService ssoService;
    private final UserService userService;

    private volatile int logsSuppressed;

    public CrowdSsoAuthenticationHandler(I18nService i18nService, CrowdSsoService ssoService, UserService userService) {
        this.i18nService = i18nService;
        this.ssoService = ssoService;
        this.userService = userService;

        logsSuppressed = 0;
    }

    /**
     * Authenticates users if:
     * <ul>
     *     <li>Crowd SSO integration is enabled and available</li>
     *     <li>AND it's not BASIC authentication (Stash shouldn't create sessions for BASIC auth)</li>
     *     <li>AND the user being authenticated is defined in the Crowd SSO directory in embedded Crowd</li>
     * </ul>
     * <p>
     * If username and password are provided, authentication will be done against the remote Crowd server, resulting in a
     * new SSO session. The SSO session cookie is written to the response.
     * </p>
     * If no username and password are provided but an SSO cookie is provided, the authenticator will check whether that
     * SSO session is still valid. If it is, the associated user is authenticated.
     *
     * @param context the authentication context
     * @return The authenticated user, or {@code null} if the authenticator opts out of authentication (SSO disabled,
     *         not an SSO user or BASIC auth).
     * @throws AuthenticationException SSO authentication is enabled for the provided {@code context} and the provided
     *                                 credentials (username/password or SSO cookie) is invalid.
     */
    @Override
    public ApplicationUser authenticate(@Nonnull HttpAuthenticationContext context) {
        HttpServletRequest request = context.getRequest();
        CrowdHttpAuthenticator authenticator = ssoService.getAuthenticator();
        if (authenticator == null) {
            log.debug("Skipping Crowd SSO as it is not enabled");
            return null;
        }

        if (METHOD_BASIC.equals(context.getMethod())) {
            // no need to create SSO sessions for BASIC authentication: REST calls, SCM requests and the like
            log.debug("Skipping Crowd SSO as the request is using BASIC authentication");
            return null;
        }

        if (!ssoService.isSsoCandidate(request)) {
            log.debug("Skipping Crowd SSO as the request is using a domain which is not configured for SSO. Domain: {}", request.getServerName());
            return null;
        }

        String username = context.getUsername();
        String password = context.getCredentials() instanceof String ? (String) context.getCredentials() : null;
        User user = null;
        try {
            if (StringUtils.isBlank(username) || StringUtils.isEmpty(password)) {
                // no username/password combination provided - try to authenticate using the SSO cookie in the request
                String token = authenticator.getToken(request);
                if (token != null && !authenticator.isAuthenticated(request, context.getResponse())) {
                    clearRemoteSsoSession(request, context.getResponse());
                    return null;
                }
                user = authenticator.getUser(request);
            } else {
                // username and password were provided - try to authenticate with the remote server
                if (ssoService.isManagedBySso(username)) {
                    // only try to login through SSO if the user is from the SSO directory
                    user = authenticator.authenticate(request, context.getResponse(), username, password);
                } else {
                    log.debug("Bypassing SSO for non-Crowd user {}", username);
                }
            }
        } catch (ApplicationPermissionException | OperationFailedException e) {
            // could not authenticate via Crowd because of configuration issues; opt out of authentication and warn
            logThrottled("Authentication with remote Crowd server failed.", e);
            return null;
        } catch (InvalidAuthenticationException e) {
            throw new IncorrectPasswordAuthenticationException(invalidCredentials());
        } catch (ApplicationAccessDeniedException e) {
            throw new InactiveUserAuthenticationException(noAccess());
        } catch (ExpiredCredentialException e) {
            throw new ExpiredPasswordAuthenticationException(passwordExpired());
        } catch (InactiveAccountException e) {
            throw new InactiveUserAuthenticationException(inactiveAccount());
        } catch (InvalidTokenException e) {
            // the token is no longer valid; clear the cookie and opt out of authentication
            clearRemoteSsoSession(request, context.getResponse());
            return null;
        }

        if (user == null) {
            return null;
        }

        request.getSession().setAttribute(KEY_CROWD_SSO_AUTHENTICATED, authenticator.getToken(request));
        return userService.getUserByName(user.getName());
    }

    /**
     * Logs out the user in the remote Crowd server if SSO integration is enabled and the user was authenticated by this
     * authenticator.
     *
     * @param user     the user to log out
     * @param request  the HTTP request
     * @param response the HTTP response
     */
    @Override
    public void logout(@Nonnull ApplicationUser user, @Nonnull HttpServletRequest request,
                       @Nonnull HttpServletResponse response) {
        CrowdHttpAuthenticator authenticator = ssoService.getAuthenticator();
        if (authenticator != null) {
            HttpSession session = request.getSession(false);
            if (session != null && session.getAttribute(KEY_CROWD_SSO_AUTHENTICATED) != null) {
                session.removeAttribute(KEY_CROWD_SSO_AUTHENTICATED);
                try {
                    authenticator.logout(request, response);
                } catch (CrowdException | PermissionException e) {
                    if (log.isDebugEnabled()) {
                        log.debug("Could not log out " + user.getName() + " in remote Crowd {}", e);
                    } else {
                        log.info("Could not log out {} in remote Crowd {}", user.getName(), e.getMessage());
                    }
                }
            }
        }
    }

    @Override
    public boolean onAuthenticationSuccess(@Nonnull HttpAuthenticationSuccessContext context)
            throws ServletException, IOException {
        String username = context.getUser().getName();
        if (!isAuthenticatedByCrowdSso(context) && ssoService.isManagedBySso(username)) {
            // this is an SSO user, but the user was authenticated by another authenticator. Create a SSO session
            // nonetheless.
            try {
                //CrowdSsoService.isManagedBySso(String) returns false if the authenticator is null
                //
                //noinspection ConstantConditions
                ssoService.getAuthenticator().authenticateWithoutValidatingPassword(
                        context.getRequest(), context.getResponse(), username);
            } catch (Exception e) {
                log.info("Could not create SSO session for user {}: {}", context.getUsername(), e.getMessage());
                log.debug("Error while authenticating for Crowd SSO", e);
            }
        }

        return false;
    }

    /**
     * Validates the remote SSO session, if there is one.
     *
     * @param context the authentication context
     * @throws ExpiredAuthenticationException if the user was authenticated by this authenticator and SSO integration
     *                                        has since been disabled, the SSO cookie is not provided on the request, or the remote SSO session has
     *                                        expired.
     */
    @Override
    public void validateAuthentication(@Nonnull HttpAuthenticationContext context) {
        if (!isAuthenticatedByCrowdSso(context)) {
            return;
        }

        CrowdHttpAuthenticator authenticator = ssoService.getAuthenticator();
        if (authenticator != null) {
            String token = authenticator.getToken(context.getRequest());
            String tokenFromSession = getAuthenticatedToken(context.getRequest());
            if (token != null && token.equals(tokenFromSession) &&
                    isAuthenticatedRemotely(authenticator, context.getRequest(), context.getResponse())) {
                // the remote session is still valid
                return;
            } else {
                log.debug("Token is no longer valid token: {} session token: {}", token, tokenFromSession);
            }
        }

        // The user was authenticated using Crowd SSO, but their Crowd session is no longer valid
        logoutUser(context);
        throw new ExpiredAuthenticationException(tokenExpired());
    }

    private KeyedMessage inactiveAccount() {
        return i18nService.createKeyedMessage("bitbucket.auth.crowd.sso.inactive.account");
    }

    private KeyedMessage invalidCredentials() {
        return i18nService.createKeyedMessage("bitbucket.auth.failed");
    }

    private KeyedMessage tokenExpired() {
        return i18nService.createKeyedMessage("bitbucket.auth.crowd.sso.token.expired");
    }

    private KeyedMessage noAccess() {
        return i18nService.createKeyedMessage("bitbucket.auth.crowd.sso.no.permission", Product.NAME);
    }

    private KeyedMessage passwordExpired() {
        return i18nService.createKeyedMessage("bitbucket.auth.crowd.sso.credentials.expired");
    }

    private void clearRemoteSsoSession(HttpServletRequest request, HttpServletResponse response) {
        CrowdHttpAuthenticator authenticator = ssoService.getAuthenticator();
        if (authenticator != null) {
            try {
                authenticator.logout(request, response);
            } catch (Exception e) {
                log.debug("An error occurred during Crowd SSO logout", e);
            }
        }
    }

    private String getAuthenticatedToken(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        return session != null ? (String) session.getAttribute(KEY_CROWD_SSO_AUTHENTICATED) : null;
    }

    private boolean isAuthenticatedRemotely(CrowdHttpAuthenticator authenticator, HttpServletRequest request,
                                            HttpServletResponse response) {
        try {
            return authenticator.isAuthenticated(request, response);
        } catch (OperationFailedException e) {
            if (log.isDebugEnabled()) {
                log.debug("Failed to validate SSO session", e);
            } else {
                log.info("Failed to validate SSO session {}", e.getMessage());
            }

            return false;
        }
    }

    private boolean isAuthenticatedByCrowdSso(HttpAuthenticationContext token) {
        HttpSession session = token.getRequest().getSession(false);
        return session != null && session.getAttribute(KEY_CROWD_SSO_AUTHENTICATED) != null;
    }

    private void logThrottled(String message, Exception e) {
        // not very thread-safe, but not essential and not worth introducing locks/synchronisation
        if (++logsSuppressed % 100 == 1) {
            logsSuppressed = 1;
            log.warn(message, e);
        } else {
            log.debug(message, e);
        }
    }

    private void logoutUser(HttpAuthenticationContext context) {
        HttpServletRequest request = context.getRequest();
        HttpServletResponse response = context.getResponse();
        request.getSession().removeAttribute(KEY_CROWD_SSO_AUTHENTICATED);
        userService.unauthenticate();
        clearRemoteSsoSession(request, response);
    }
}
