/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.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://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.brooklyn.rest.filter;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;

import javax.annotation.Priority;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.ContainerResponseContext;
import javax.ws.rs.container.ContainerResponseFilter;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.NewCookie;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.Provider;

import org.apache.brooklyn.core.mgmt.entitlement.Entitlements;
import org.apache.brooklyn.rest.api.ServerApi;
import org.apache.brooklyn.rest.domain.ApiError;
import org.apache.brooklyn.rest.resources.LogoutResource;
import org.apache.brooklyn.rest.util.MultiSessionAttributeAdapter;
import org.apache.brooklyn.util.text.Identifiers;
import org.apache.brooklyn.util.text.Strings;
import org.apache.commons.collections.EnumerationUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Predicates;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;

/**
 * Causes the server to return a "Csrf-Token" cookie in certain circumstances,
 * and thereafter to require clients to send that token as an "X-Csrf-Token" header on certain requests.
 * <p>
 * To enable REST requests to work from the <code>br</code> CLI and regular <code>curl</code> without CSRF,
 * this CSRF protection only applies when a session is in effect.
 * Sessions are only established by some REST requests:
 * {@link ServerApi#getUpExtended()} is a good choice (/v1/server/up/extended).
 * It can also be forced by using the {@link #CSRF_TOKEN_REQUIRED_HEADER} header on any REST request.
 * Browser-based UI clients should use one of the above methods early in operation.
 * <p>
 * The standard model is that the token is required for all <i>write</i> (ie non-GET) requests being made
 * with a valid session cookie (ie the request is part of an ongoing session).
 * In such cases, the client must send the X-Csrf-Token as a header.
 * This prevents a third-party site from effecting a mutating cross-site request via the browser. 
 * <p>
 * For transitional reasons, the default behaviour in the current implementation is to warn (not fail)
 * if no token is supplied in the above case. This will likely be changed to enforce the standard model 
 * in a subsequent version, but it avoids breaking backwards compatibility for any existing session-based clients.
 * <p>
 * Clients can force different required behaviour (e.g. "fail") by including the
 * {@link #CSRF_TOKEN_REQUIRED_HEADER} with one of the values in {@link CsrfTokenRequiredForRequests},
 * viz. to require the header on ALL requests, or on NONE, instead of just on WRITE requests (the default).
 * If such a mode is explicitly specified it is enforced (instead of just displaying the transitional warning),
 * so while transitioning to enforce CSRF this header should be supplied.
 * The Brooklyn UI does this.
 * <p>
 * This uses *two* names, <code>Csrf-Token</code> and <code>Xsrf-Token</code>.
 * The former seems the usual convention, but Angular apps use the latter.
 * This strategy should mean that Angular apps should get CSRF protection with no special configuration.
 * <p>
 * The cookies are sent by the client on every request, by virtue of being cookies,
 * although this is not necessary (and rather wasteful). A client may optimise by deleting the cookies 
 * and caching the information in another form. 
 * The cookie names do not have any salt/uid, so in dev on localhost you may get conflicts, e.g. between
 * multiple Brooklyn instances or other apps that use the same token names.
 * (In contrast the session ID token, usually JSESSIONID, has a BROOKLYN<uid> field at runtime to prevent
 * such collisions.)
 * <p>
 * Additional CSRF strategies might be worth considering, in addition to the one described above:
 * <ul>
 * <li> Compare "Referer / Origin" against "Host" / "X-Forwarded-Host" (or app knowing its location)
 *      (disallowing if different is probably a good idea)
 * <li> Look for "X-Requested-With: XMLHttpRequest" on POST, esp if there is cookie and/or user agent is a browser
 *      (but angular drops this one)
 * </ul>
 * More info at:  https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet .
 * (These have not been implemented because the cookie pattern is sufficient, and one of the strongest.)
 * 
 */
@Provider
@Priority(400)
public class CsrfTokenFilter implements ContainerRequestFilter, ContainerResponseFilter {
    
    private static final Logger log = LoggerFactory.getLogger(CsrfTokenFilter.class);

    private static final String CSRF_TOKEN_VALUE_BASE = "Csrf-Token";
    public static final String CSRF_TOKEN_VALUE_COOKIE = CSRF_TOKEN_VALUE_BASE.toUpperCase();
    public static final String CSRF_TOKEN_VALUE_HEADER = HEADER_OF_COOKIE(CSRF_TOKEN_VALUE_BASE);
    // also send this so angular works as expected
    public static final String CSRF_TOKEN_VALUE_BASE_ANGULAR_NAME = "Xsrf-Token";
    public static final String CSRF_TOKEN_VALUE_COOKIE_ANGULAR_NAME = CSRF_TOKEN_VALUE_BASE_ANGULAR_NAME.toUpperCase();
    public static final String CSRF_TOKEN_VALUE_HEADER_ANGULAR_NAME = HEADER_OF_COOKIE(CSRF_TOKEN_VALUE_BASE_ANGULAR_NAME);
    
    public static final String CSRF_TOKEN_VALUE_ATTR = CsrfTokenFilter.class.getName()+"."+CSRF_TOKEN_VALUE_COOKIE;
    
    public static String HEADER_OF_COOKIE(String cookieName) {
        return "X-"+cookieName;
    }
    
    public static final String CSRF_TOKEN_REQUIRED_HEADER = "X-Csrf-Token-Required-For-Requests";
    public static final String CSRF_TOKEN_REQUIRED_ATTR = CsrfTokenFilter.class.getName()+"."+CSRF_TOKEN_REQUIRED_HEADER;
    public static enum CsrfTokenRequiredForRequests { ALL, WRITE, NONE, }
    public static CsrfTokenRequiredForRequests DEFAULT_REQUIRED_FOR_REQUESTS = CsrfTokenRequiredForRequests.WRITE;

    @Context
    private HttpServletRequest request;

    @Override
    public void filter(ContainerRequestContext requestContext) throws IOException {
        // if header supplied, check it is valid
        String requiredWhenS = request.getHeader(CSRF_TOKEN_REQUIRED_HEADER);
        if (Strings.isNonBlank(requiredWhenS) && getRequiredForRequests(requiredWhenS, null)==null) {
            fail(requestContext, ApiError.builder().errorCode(Response.Status.BAD_REQUEST)
                .message(HEADER_OF_COOKIE(CSRF_TOKEN_REQUIRED_HEADER)+" header if supplied must be one of "
                    + Arrays.asList(CsrfTokenRequiredForRequests.values()))
                .build());
            return;
    }
        
        if (!request.isRequestedSessionIdValid()) {
            // no session; just return
            return;
        }
        
        @SuppressWarnings("unchecked")
        List<String> suppliedTokensDefault = EnumerationUtils.toList(request.getHeaders(CSRF_TOKEN_VALUE_HEADER));
        @SuppressWarnings("unchecked")
        List<String> suppliedTokensAngular = EnumerationUtils.toList(request.getHeaders(CSRF_TOKEN_VALUE_HEADER_ANGULAR_NAME));
        List<String> suppliedTokens = Lists.newArrayList(suppliedTokensDefault);
        suppliedTokens.addAll(suppliedTokensAngular);

        MultiSessionAttributeAdapter session = MultiSessionAttributeAdapter.of(request);
        Object requiredToken = session.getAttribute(CSRF_TOKEN_VALUE_ATTR);
        CsrfTokenRequiredForRequests whenRequired = (CsrfTokenRequiredForRequests) session.getAttribute(CSRF_TOKEN_REQUIRED_ATTR);

        boolean isRequired;
        
        if (whenRequired==null) {
            if (suppliedTokens.isEmpty()) {
                log.warn("No CSRF token expected or supplied but a cookie-session is active: client should be updated"
                    + " (in future CSRF tokens or instructions may be required for session-based clients)"
                    + " - " + Entitlements.getEntitlementContext());
                whenRequired = CsrfTokenRequiredForRequests.NONE;
            } else {
                // default
                whenRequired = DEFAULT_REQUIRED_FOR_REQUESTS;
            }
            // remember it to suppress warnings subsequently
            session.setAttribute(CSRF_TOKEN_REQUIRED_ATTR, whenRequired);
        }
        
        switch (whenRequired) {
        case NONE:
            isRequired = false;
            break;
        case WRITE:
            isRequired = !org.eclipse.jetty.http.HttpMethod.GET.toString().equals(requestContext.getMethod());
            break;
        case ALL:
            isRequired = true;
            break;
        default:
            log.warn("Unexpected "+CSRF_TOKEN_REQUIRED_ATTR+" value "+whenRequired);
            isRequired = true;
        }
        
        if (Iterables.any(suppliedTokens, Predicates.equalTo(requiredToken))) {
            // matching token supplied, or not being used 
            return;
        }
        
        if (!isRequired) {
            // csrf not required, but it doesn't match
            if (requiredToken!=null) {
                log.trace("CSRF optional token mismatch: client did not send valid token, but it isn't required so proceeding");
            }
            return;
        }

        if (suppliedTokens.isEmpty()) {
            fail(requestContext, ApiError.builder().errorCode(Response.Status.FORBIDDEN)
                    .message(HEADER_OF_COOKIE(CSRF_TOKEN_VALUE_COOKIE)+" header is required, containing token previously returned from server in cookie")
                    .build());
        } else {
            fail(requestContext, ApiError.builder().errorCode(Response.Status.FORBIDDEN)
                .message(HEADER_OF_COOKIE(CSRF_TOKEN_VALUE_COOKIE)+" header did not match expected CSRF token")
                .build());
        }
    }

    private void fail(ContainerRequestContext requestContext, ApiError apiError) {
        requestContext.abortWith(apiError.asJsonResponse());
    }

    @Override
    public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException {
        MultiSessionAttributeAdapter session = MultiSessionAttributeAdapter.of(request, false);
        String token = (String) (session==null ? null : session.getAttribute(CSRF_TOKEN_VALUE_ATTR));
        String requiredWhenS = request.getHeader(CSRF_TOKEN_REQUIRED_HEADER);

        if (session==null) {
            if (Strings.isBlank(requiredWhenS)) {
                // no session and no requirement specified, bail out 
                return;
            }
            // explicit requirement request forces a session
            if (Boolean.TRUE.equals(request.getAttribute(LogoutResource.DID_LOGOUT))) {
                // user has logged out, don't make a session
                return;
            }
            session = MultiSessionAttributeAdapter.of(request, true);
        }

        if (token==null) {
            // create the token
            token = Identifiers.makeRandomId(16);
            log.trace("Created new token {} for {}", token, session);
        }
        session.setAttribute(CSRF_TOKEN_VALUE_ATTR, token);

        addCookie(responseContext, CSRF_TOKEN_VALUE_COOKIE, token, "Clients should send this value in header "+CSRF_TOKEN_VALUE_HEADER+" for validation");
        addCookie(responseContext, CSRF_TOKEN_VALUE_COOKIE_ANGULAR_NAME, token, "Compatibility cookie for "+CSRF_TOKEN_VALUE_COOKIE+" following AngularJS conventions");

        CsrfTokenRequiredForRequests requiredWhen;
        if (Strings.isNonBlank(requiredWhenS)) {
            requiredWhen = getRequiredForRequests(requiredWhenS, DEFAULT_REQUIRED_FOR_REQUESTS);
            session.setAttribute(CSRF_TOKEN_REQUIRED_ATTR, requiredWhen);
            if (Strings.isNonBlank(token)) {
                // already set csrf token, and the client got it
                // (with the session token if they are in a session;
                // or if client didn't get it it isn't in a session)
                return;
            }
        } else {
            if (Strings.isNonBlank(token)) {
                // already set csrf token, and the client got it
                // (with the session token if they are in a session;
                // or if client didn't get it it isn't in a session)
                return;
            }
            requiredWhen = (CsrfTokenRequiredForRequests) session.getAttribute(CSRF_TOKEN_REQUIRED_ATTR);
            if (requiredWhen==null) {
                requiredWhen = DEFAULT_REQUIRED_FOR_REQUESTS;
                session.setAttribute(CSRF_TOKEN_REQUIRED_ATTR, requiredWhen);
            }
        }

        if (requiredWhen==CsrfTokenRequiredForRequests.NONE) {
            // not required; so don't set
            return;
        }

    }

    protected NewCookie addCookie(ContainerResponseContext responseContext, String cookieName, String token, String comment) {
        NewCookie cookie = new NewCookie(cookieName, token, "/", null, comment, -1, false);
        responseContext.getHeaders().add("Set-Cookie", cookie);
        return cookie;
    }

    protected CsrfTokenRequiredForRequests getRequiredForRequests(String requiredWhenS, CsrfTokenRequiredForRequests defaultMode) {
        CsrfTokenRequiredForRequests result = null;
        if (requiredWhenS!=null) {
            result = CsrfTokenRequiredForRequests.valueOf(requiredWhenS.toUpperCase());
        }
        if (result!=null) return result;
        return defaultMode;
    }

}
