/**
 * Copyright 2005-2013 Restlet S.A.S.
 * 
 * The contents of this file are subject to the terms of one of the following
 * open source licenses: Apache 2.0 or LGPL 3.0 or LGPL 2.1 or CDDL 1.0 or EPL
 * 1.0 (the "Licenses"). You can select the license that you prefer but you may
 * not use this file except in compliance with one of these Licenses.
 * 
 * You can obtain a copy of the Apache 2.0 license at
 * http://www.opensource.org/licenses/apache-2.0
 * 
 * You can obtain a copy of the LGPL 3.0 license at
 * http://www.opensource.org/licenses/lgpl-3.0
 * 
 * You can obtain a copy of the LGPL 2.1 license at
 * http://www.opensource.org/licenses/lgpl-2.1
 * 
 * You can obtain a copy of the CDDL 1.0 license at
 * http://www.opensource.org/licenses/cddl1
 * 
 * You can obtain a copy of the EPL 1.0 license at
 * http://www.opensource.org/licenses/eclipse-1.0
 * 
 * See the Licenses for the specific language governing permissions and
 * limitations under the Licenses.
 * 
 * Alternatively, you can obtain a royalty free commercial license with less
 * limitations, transferable or non-transferable, directly at
 * http://www.restlet.com/products/restlet-framework
 * 
 * Restlet is a registered trademark of Restlet S.A.S.
 */

package org.restlet.ext.crypto;

import java.security.GeneralSecurityException;
import java.util.logging.Level;

import org.restlet.Context;
import org.restlet.Request;
import org.restlet.Response;
import org.restlet.data.ChallengeResponse;
import org.restlet.data.ChallengeScheme;
import org.restlet.data.Cookie;
import org.restlet.data.CookieSetting;
import org.restlet.data.Form;
import org.restlet.data.Method;
import org.restlet.data.Parameter;
import org.restlet.data.Reference;
import org.restlet.engine.util.Base64;
import org.restlet.ext.crypto.internal.CryptoUtils;
import org.restlet.security.ChallengeAuthenticator;

/**
 * Challenge authenticator based on browser cookies. This is useful when the web
 * application requires a finer grained control on the login and logout process
 * and can't rely solely on standard schemes such as
 * {@link ChallengeScheme#HTTP_BASIC}.<br>
 * <br>
 * Login can be automatically handled by intercepting HTTP POST calls to the
 * {@link #getLoginPath()} URI. The request entity should contain an HTML form
 * with two fields, the first one named {@link #getIdentifierFormName()} and the
 * second one named {@link #getSecretFormName()}.<br>
 * <br>
 * Logout can be automatically handled as well by intercepting HTTP GET or POST
 * calls to the {@link #getLogoutPath()} URI.<br>
 * <br>
 * After login or logout, the user's browser can be redirected to the URI
 * provided in a query parameter named by {@link #getRedirectQueryName()}.<br>
 * <br>
 * When the credentials are missing or stale, the
 * {@link #challenge(Response, boolean)} method is invoked by the parent class,
 * and its default behavior is to redirect the user's browser to the
 * {@link #getLoginFormPath()} URI, adding the URI of the target resource as a
 * query parameter of name {@link #getRedirectQueryName()}.<br>
 * <br>
 * Note that credentials, both identifier and secret, are stored in a cookie in
 * an encrypted manner. The default encryption algorithm is AES but can be
 * changed with {@link #setEncryptAlgorithm(String)}. It is also strongly
 * recommended to
 * 
 * @author Remi Dewitte
 * @author Jerome Louvel
 */
public class CookieAuthenticator extends ChallengeAuthenticator {

    /** The name of the cookie that stores log info. */
    private volatile String cookieName;

    /** The name of the algorithm used to encrypt the log info cookie value. */
    private volatile String encryptAlgorithm;

    /**
     * The secret key for the algorithm used to encrypt the log info cookie
     * value.
     */
    private volatile byte[] encryptSecretKey;

    /** The name of the HTML login form field containing the identifier. */
    private volatile String identifierFormName;

    /** Indicates if the login requests should be intercepted. */
    private volatile boolean interceptingLogin;

    /** Indicates if the logout requests should be intercepted. */
    private volatile boolean interceptingLogout;

    /** The URI path of the HTML login form to use to challenge the user. */
    private volatile String loginFormPath;

    /** The login URI path to intercept. */
    private volatile String loginPath;

    /** The logout URI path to intercept. */
    private volatile String logoutPath;

    /** The maximum age of the log info cookie. */
    private volatile int maxCookieAge;

    /**
     * The name of the query parameter containing the URI to redirect the
     * browser to after login or logout.
     */
    private volatile String redirectQueryName;

    /** The name of the HTML login form field containing the secret. */
    private volatile String secretFormName;

    /**
     * Constructor. Use the {@link ChallengeScheme#HTTP_COOKIE} pseudo-scheme.
     * 
     * @param context
     *            The parent context.
     * @param optional
     *            Indicates if this authenticator is optional so alternative
     *            authenticators down the chain can be attempted.
     * @param realm
     *            The name of the security realm.
     * @param encryptSecretKey
     *            The secret key used to encrypt the cookie value.
     */
    public CookieAuthenticator(Context context, boolean optional, String realm,
            byte[] encryptSecretKey) {
        super(context, optional, ChallengeScheme.HTTP_COOKIE, realm);
        this.cookieName = "Credentials";
        this.interceptingLogin = true;
        this.interceptingLogout = true;
        this.identifierFormName = "login";
        this.loginPath = "/login";
        this.logoutPath = "/logout";
        this.secretFormName = "password";
        this.encryptAlgorithm = "AES";
        this.encryptSecretKey = encryptSecretKey;
        this.maxCookieAge = -1;
        this.redirectQueryName = "targetUri";
    }

    /**
     * Constructor for mandatory cookie authenticators.
     * 
     * @param context
     *            The parent context.
     * @param realm
     *            The name of the security realm.
     * @param encryptSecretKey
     *            The secret key used to encrypt the cookie value.
     */
    public CookieAuthenticator(Context context, String realm,
            byte[] encryptSecretKey) {
        this(context, false, realm, encryptSecretKey);
    }

    /**
     * Attempts to redirect the user's browser can be redirected to the URI
     * provided in a query parameter named by {@link #getRedirectQueryName()}.
     * 
     * @param request
     *            The current request.
     * @param response
     *            The current response.
     */
    protected void attemptRedirect(Request request, Response response) {
        String targetUri = request.getResourceRef().getQueryAsForm()
                .getFirstValue(getRedirectQueryName());

        if (targetUri != null) {
            response.redirectSeeOther(Reference.decode(targetUri));
        }
    }

    /**
     * Restores credentials from the cookie named {@link #getCookieName()} if
     * available. The usual processing is the followed.
     */
    @Override
    protected boolean authenticate(Request request, Response response) {
        // Restore credentials from the cookie
        Cookie credentialsCookie = request.getCookies().getFirst(
                getCookieName());

        if (credentialsCookie != null) {
            request.setChallengeResponse(parseCredentials(credentialsCookie
                    .getValue()));
        }

        return super.authenticate(request, response);
    }

    /**
     * Sets or update the credentials cookie.
     */
    @Override
    protected int authenticated(Request request, Response response) {
        try {
            CookieSetting credentialsCookie = getCredentialsCookie(request,
                    response);
            credentialsCookie.setValue(formatCredentials(request
                    .getChallengeResponse()));
            credentialsCookie.setMaxAge(getMaxCookieAge());
        } catch (GeneralSecurityException e) {
            getLogger().log(Level.SEVERE,
                    "Could not format credentials cookie", e);
        }

        return super.authenticated(request, response);
    }

    /**
     * Optionally handles the login and logout actions by intercepting the HTTP
     * calls to the {@link #getLoginPath()} and {@link #getLogoutPath()} URIs.
     */
    @Override
    protected int beforeHandle(Request request, Response response) {
        if (isLoggingIn(request, response)) {
            login(request, response);
        } else if (isLoggingOut(request, response)) {
            return logout(request, response);
        }

        return super.beforeHandle(request, response);
    }

    /**
     * This method should be overridden to return a login form representation.<br>
     * By default, it redirects the user's browser to the
     * {@link #getLoginFormPath()} URI, adding the URI of the target resource as
     * a query parameter of name {@link #getRedirectQueryName()}.<br>
     * In case the getLoginFormPath() is not set, it calls the parent's method.
     */
    @Override
    public void challenge(Response response, boolean stale) {
        if (getLoginFormPath() == null) {
            super.challenge(response, stale);
        } else {
            Reference ref = response.getRequest().getResourceRef();
            String redirectQueryName = getRedirectQueryName();
            String redirectQueryValue = ref.getQueryAsForm().getFirstValue(
                    redirectQueryName, "");

            if ("".equals(redirectQueryValue)) {
                redirectQueryValue = new Reference(getLoginFormPath())
                        .addQueryParameter(redirectQueryName, ref.toString())
                        .toString();
            }

            response.redirectSeeOther(redirectQueryValue);
        }
    }


    /**
     * Formats the raws credentials to store in the cookie.
     * 
     * @param challenge
     *            The challenge response to format.
     * @return The raw credentials.
     * @throws GeneralSecurityException
     */
    protected String formatCredentials(ChallengeResponse challenge)
            throws GeneralSecurityException {
        // Data buffer
        StringBuffer sb = new StringBuffer();

        // Indexes buffer
        StringBuffer isb = new StringBuffer();
        String timeIssued = Long.toString(System.currentTimeMillis());
        int i = timeIssued.length();
        sb.append(timeIssued);

        isb.append(i);

        String identifier = challenge.getIdentifier();
        sb.append('/');
        sb.append(identifier);

        i += identifier.length() + 1;
        isb.append(',').append(i);

        sb.append('/');
        sb.append(challenge.getSecret());

        // Store indexes at the end of the string
        sb.append('/');
        sb.append(isb);

        return Base64.encode(CryptoUtils.encrypt(getEncryptAlgorithm(),
                getEncryptSecretKey(), sb.toString()), false);
    }

    /**
     * Returns the cookie name to use for the authentication credentials. By
     * default, it is is "Credentials".
     * 
     * @return The cookie name to use for the authentication credentials.
     */
    public String getCookieName() {
        return cookieName;
    }

    /**
     * Returns the credentials cookie setting. It first try to find an existing
     * cookie. If necessary, it creates a new one.
     * 
     * @param request
     *            The current request.
     * @param response
     *            The current response.
     * @return The credentials cookie setting.
     */
    protected CookieSetting getCredentialsCookie(Request request,
            Response response) {
        CookieSetting credentialsCookie = response.getCookieSettings()
                .getFirst(getCookieName());

        if (credentialsCookie == null) {
            credentialsCookie = new CookieSetting(getCookieName(), null);
            credentialsCookie.setAccessRestricted(true);
            // authCookie.setVersion(1);

            if (request.getRootRef() != null) {
                String p = request.getRootRef().getPath();
                credentialsCookie.setPath(p == null ? "/" : p);
            } else {
                // authCookie.setPath("/");
            }

            response.getCookieSettings().add(credentialsCookie);
        }

        return credentialsCookie;
    }

    /**
     * Returns the name of the algorithm used to encrypt the log info cookie
     * value. By default, it returns "AES".
     * 
     * @return The name of the algorithm used to encrypt the log info cookie
     *         value.
     */
    public String getEncryptAlgorithm() {
        return encryptAlgorithm;
    }

    /**
     * Returns the secret key for the algorithm used to encrypt the log info
     * cookie value.
     * 
     * @return The secret key for the algorithm used to encrypt the log info
     *         cookie value.
     */
    public byte[] getEncryptSecretKey() {
        return encryptSecretKey;
    }

    /**
     * Returns the name of the HTML login form field containing the identifier.
     * Returns "login" by default.
     * 
     * @return The name of the HTML login form field containing the identifier.
     */
    public String getIdentifierFormName() {
        return identifierFormName;
    }

    /**
     * Returns the URI path of the HTML login form to use to challenge the user.
     * 
     * @return The URI path of the HTML login form to use to challenge the user.
     */
    public String getLoginFormPath() {
        return loginFormPath;
    }

    /**
     * Returns the login URI path to intercept.
     * 
     * @return The login URI path to intercept.
     */
    public String getLoginPath() {
        return loginPath;
    }

    /**
     * Returns the logout URI path to intercept.
     * 
     * @return The logout URI path to intercept.
     */
    public String getLogoutPath() {
        return logoutPath;
    }

    /**
     * Returns the maximum age of the log info cookie. By default, it uses -1 to
     * make the cookie only last until the end of the current browser session.
     * 
     * @return The maximum age of the log info cookie.
     * @see CookieSetting#getMaxAge()
     */
    public int getMaxCookieAge() {
        return maxCookieAge;
    }

    /**
     * Returns the name of the query parameter containing the URI to redirect
     * the browser to after login or logout. By default, it uses "targetUri".
     * 
     * @return The name of the query parameter containing the URI to redirect
     *         the browser to after login or logout.
     */
    public String getRedirectQueryName() {
        return redirectQueryName;
    }

    /**
     * Returns the name of the HTML login form field containing the secret.
     * Returns "password" by default.
     * 
     * @return The name of the HTML login form field containing the secret.
     */
    public String getSecretFormName() {
        return secretFormName;
    }

    /**
     * Indicates if the login requests should be intercepted.
     * 
     * @return True if the login requests should be intercepted.
     */
    public boolean isInterceptingLogin() {
        return interceptingLogin;
    }

    /**
     * Indicates if the logout requests should be intercepted.
     * 
     * @return True if the logout requests should be intercepted.
     */
    public boolean isInterceptingLogout() {
        return interceptingLogout;
    }

    /**
     * Indicates if the request is an attempt to log in and should be
     * intercepted.
     * 
     * @param request
     *            The current request.
     * @param response
     *            The current response.
     * @return True if the request is an attempt to log in and should be
     *         intercepted.
     */
    protected boolean isLoggingIn(Request request, Response response) {
        return isInterceptingLogin()
                && getLoginPath()
                        .equals(request.getResourceRef().getRemainingPart(
                                false, false))
                && Method.POST.equals(request.getMethod());
    }

    /**
     * Indicates if the request is an attempt to log out and should be
     * intercepted.
     * 
     * @param request
     *            The current request.
     * @param response
     *            The current response.
     * @return True if the request is an attempt to log out and should be
     *         intercepted.
     */
    protected boolean isLoggingOut(Request request, Response response) {
        return isInterceptingLogout()
                && getLogoutPath()
                        .equals(request.getResourceRef().getRemainingPart(
                                false, false))
                && (Method.GET.equals(request.getMethod()) || Method.POST
                        .equals(request.getMethod()));
    }

    /**
     * Processes the login request.
     * 
     * @param request
     *            The current request.
     * @param response
     *            The current response.
     */
    protected void login(Request request, Response response) {
        // Login detected
        Form form = new Form(request.getEntity());
        Parameter identifier = form.getFirst(getIdentifierFormName());
        Parameter secret = form.getFirst(getSecretFormName());

        // Set credentials
        ChallengeResponse cr = new ChallengeResponse(getScheme(),
                identifier != null ? identifier.getValue() : null,
                secret != null ? secret.getValue() : null);
        request.setChallengeResponse(cr);

        // Attempt to redirect
        attemptRedirect(request, response);
    }

    /**
     * Processes the logout request.
     * 
     * @param request
     *            The current request.
     * @param response
     *            The current response.
     */
    protected int logout(Request request, Response response) {
        // Clears the credentials
        request.setChallengeResponse(null);
        CookieSetting credentialsCookie = getCredentialsCookie(request,
                response);
        credentialsCookie.setMaxAge(0);

        // Attempt to redirect
        attemptRedirect(request, response);

        return STOP;
    }

    /**
     * Decodes the credentials stored in a cookie into a proper
     * {@link ChallengeResponse} object.
     * 
     * @param cookieValue
     *            The credentials to decode from cookie value.
     * @return The credentials as a proper challenge response.
     */
    protected ChallengeResponse parseCredentials(String cookieValue) {
        // 1) Decode Base64 string
        byte[] encrypted = Base64.decode(cookieValue);

        if (encrypted == null) {
            getLogger().warning(
                    "Cannot decode cookie credentials : " + cookieValue);
        }

        // 2) Decrypt the credentials
        try {
            String decrypted = CryptoUtils.decrypt(getEncryptAlgorithm(),
                    getEncryptSecretKey(), encrypted);

            // 3) Parse the decrypted cookie value
            int lastSlash = decrypted.lastIndexOf('/');
            String[] indexes = decrypted.substring(lastSlash + 1).split(",");
            int identifierIndex = Integer.parseInt(indexes[0]);
            int secretIndex = Integer.parseInt(indexes[1]);

            // 4) Create the challenge response
            ChallengeResponse cr = new ChallengeResponse(getScheme());
            cr.setRawValue(cookieValue);
            cr.setTimeIssued(Long.parseLong(decrypted.substring(0,
                    identifierIndex)));
            cr.setIdentifier(decrypted.substring(identifierIndex + 1,
                    secretIndex));
            cr.setSecret(decrypted.substring(secretIndex + 1, lastSlash));
            return cr;
        } catch (Exception e) {
            getLogger().log(Level.INFO, "Unable to decrypt cookie credentials",
                    e);
            return null;
        }
    }

    /**
     * Sets the cookie name to use for the authentication credentials.
     * 
     * @param cookieName
     *            The cookie name to use for the authentication credentials.
     */
    public void setCookieName(String cookieName) {
        this.cookieName = cookieName;
    }

    /**
     * Sets the name of the algorithm used to encrypt the log info cookie value.
     * 
     * @param secretAlgorithm
     *            The name of the algorithm used to encrypt the log info cookie
     *            value.
     */
    public void setEncryptAlgorithm(String secretAlgorithm) {
        this.encryptAlgorithm = secretAlgorithm;
    }

    /**
     * Sets the secret key for the algorithm used to encrypt the log info cookie
     * value.
     * 
     * @param secretKey
     *            The secret key for the algorithm used to encrypt the log info
     *            cookie value.
     */
    public void setEncryptSecretKey(byte[] secretKey) {
        this.encryptSecretKey = secretKey;
    }

    /**
     * Sets the name of the HTML login form field containing the identifier.
     * 
     * @param loginInputName
     *            The name of the HTML login form field containing the
     *            identifier.
     */
    public void setIdentifierFormName(String loginInputName) {
        this.identifierFormName = loginInputName;
    }

    /**
     * Indicates if the login requests should be intercepted.
     * 
     * @param intercepting
     *            True if the login requests should be intercepted.
     */
    public void setInterceptingLogin(boolean intercepting) {
        this.interceptingLogin = intercepting;
    }

    /**
     * Indicates if the logout requests should be intercepted.
     * 
     * @param intercepting
     *            True if the logout requests should be intercepted.
     */
    public void setInterceptingLogout(boolean intercepting) {
        this.interceptingLogout = intercepting;
    }

    /**
     * Sets the URI path of the HTML login form to use to challenge the user.
     * 
     * @param loginFormPath
     *            The URI path of the HTML login form to use to challenge the
     *            user.
     */
    public void setLoginFormPath(String loginFormPath) {
        this.loginFormPath = loginFormPath;
    }

    /**
     * Sets the login URI path to intercept.
     * 
     * @param loginPath
     *            The login URI path to intercept.
     */
    public void setLoginPath(String loginPath) {
        this.loginPath = loginPath;
    }

    /**
     * Sets the logout URI path to intercept.
     * 
     * @param logoutPath
     *            The logout URI path to intercept.
     */
    public void setLogoutPath(String logoutPath) {
        this.logoutPath = logoutPath;
    }

    /**
     * Sets the maximum age of the log info cookie.
     * 
     * @param timeout
     *            The maximum age of the log info cookie.
     * @see CookieSetting#setMaxAge(int)
     */
    public void setMaxCookieAge(int timeout) {
        this.maxCookieAge = timeout;
    }

    /**
     * Sets the name of the query parameter containing the URI to redirect the
     * browser to after login or logout.
     * 
     * @param redirectQueryName
     *            The name of the query parameter containing the URI to redirect
     *            the browser to after login or logout.
     */
    public void setRedirectQueryName(String redirectQueryName) {
        this.redirectQueryName = redirectQueryName;
    }

    /**
     * Sets the name of the HTML login form field containing the secret.
     * 
     * @param passwordInputName
     *            The name of the HTML login form field containing the secret.
     */
    public void setSecretFormName(String passwordInputName) {
        this.secretFormName = passwordInputName;
    }

}
