com.janrain.backplane2.server.Backplane2Controller.java Source code

Java tutorial

Introduction

Here is the source code for com.janrain.backplane2.server.Backplane2Controller.java

Source

/*
 * Copyright 2012 Janrain, Inc.
 *
 * Licensed 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 com.janrain.backplane2.server;

import com.janrain.backplane2.server.config.*;
import com.janrain.backplane2.server.dao.DAOFactory;
import com.janrain.backplane.DateTimeUtils;
import com.janrain.commons.supersimpledb.SimpleDBException;
import com.janrain.crypto.ChannelUtil;
import com.janrain.crypto.HmacHashUtils;
import com.janrain.oauth2.*;
import com.janrain.redis.Redis;
import com.janrain.servlet.ServletUtil;
import com.janrain.utils.AnalyticsLogger;
import com.yammer.metrics.core.MetricName;
import com.yammer.metrics.core.TimerContext;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang.StringUtils;
import org.apache.log4j.Logger;
import org.codehaus.jackson.map.ObjectMapper;
import org.springframework.stereotype.Controller;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;

import javax.inject.Inject;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.UnsupportedEncodingException;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;

import static com.janrain.oauth2.OAuth2.*;
import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;

/**
 * Backplane API implementation.
 *
 * @author Johnny Bufu, Tom Raney
 */
@Controller
@RequestMapping(value = "/v2/*")
@SuppressWarnings({ "UnusedDeclaration" })
public class Backplane2Controller {

    // - PUBLIC

    /** both view name and jsp variable */
    public static final String DIRECT_RESPONSE = "direct_response";

    /**
     * Handle dynamic discovery of this server's registration endpoint
     * @return
     */
    @RequestMapping(value = "/.well-known/host-meta", method = { RequestMethod.GET })
    public ModelAndView xrds(HttpServletRequest request, HttpServletResponse response) {

        ModelAndView view = new ModelAndView("xrd");
        view.addObject("host", "http://" + request.getServerName());
        view.addObject("secureHost", "https://" + request.getServerName());
        return view;
    }

    @RequestMapping(value = "/authorize", method = { RequestMethod.GET, RequestMethod.POST })
    public ModelAndView authorize(HttpServletRequest request, HttpServletResponse response,
            @CookieValue(value = AUTH_SESSION_COOKIE, required = false) String authSessionCookie,
            @CookieValue(value = AUTHORIZATION_REQUEST_COOKIE, required = false) String authorizationRequestCookie)
            throws AuthorizationException {

        AuthorizationRequest authzRequest = null;
        String httpMethod = request.getMethod();
        String authZdecisionKey = request.getParameter(AUTHZ_DECISION_KEY);
        if (authZdecisionKey != null) {
            logger.debug("received valid authZdecisionKey:" + authZdecisionKey);
        }

        // not return from /authenticate && not authz decision post
        if (request.getParameterMap().size() > 0 && StringUtils.isEmpty(authZdecisionKey)) {
            // incoming authz request
            authzRequest = parseAuthZrequest(request);
        }

        String authenticatedBusOwner = getAuthenticatedBusOwner(request, authSessionCookie);
        if (null == authenticatedBusOwner) {
            if (null != authzRequest) {
                try {
                    logger.info("Persisting authorization request for client: "
                            + authzRequest.get(AuthorizationRequest.Field.CLIENT_ID) + "["
                            + authzRequest.get(AuthorizationRequest.Field.COOKIE) + "]");
                    daoFactory.getAuthorizationRequestDAO().persist(authzRequest);
                    response.addCookie(new Cookie(AUTHORIZATION_REQUEST_COOKIE,
                            authzRequest.get(AuthorizationRequest.Field.COOKIE)));
                } catch (BackplaneServerException e) {
                    throw new AuthorizationException(OAuth2.OAUTH2_AUTHZ_SERVER_ERROR, e.getMessage(), request, e);
                }
            }
            logger.info("Bus owner not authenticated, redirecting to /authenticate");
            return new ModelAndView("redirect:https://" + request.getServerName() + "/v2/authenticate");
        }

        if (StringUtils.isEmpty(authZdecisionKey)) {
            // authorization request
            if (null == authzRequest) {
                // return from /authenticate
                try {
                    logger.debug("bp2.authorization.request cookie = " + authorizationRequestCookie);
                    authzRequest = daoFactory.getAuthorizationRequestDAO().get(authorizationRequestCookie);
                    logger.info("Retrieved authorization request for client:"
                            + authzRequest.get(AuthorizationRequest.Field.CLIENT_ID) + "["
                            + authzRequest.get(AuthorizationRequest.Field.COOKIE) + "]");
                } catch (BackplaneServerException e) {
                    throw new AuthorizationException(OAuth2.OAUTH2_AUTHZ_SERVER_ERROR, e.getMessage(), request, e);
                }
            }
            return processAuthZrequest(authzRequest, authSessionCookie, authenticatedBusOwner);
        } else {
            // authZ decision from bus owner, accept only on post
            if (!"POST".equals(httpMethod)) {
                throw new InvalidRequestException(
                        "Invalid HTTP method for authorization decision post: " + httpMethod);
            }
            return processAuthZdecision(authZdecisionKey, authSessionCookie, authenticatedBusOwner,
                    authorizationRequestCookie, request);
        }
    }

    /**
     * Authenticates a bus owner and stores the authenticated session (cookie) to simpleDB.
     *
     * GET: displays authentication form
     * POST: processes authentication and returns to /authorize
     */
    @RequestMapping(value = "/authenticate", method = { RequestMethod.GET, RequestMethod.POST })
    public ModelAndView authenticate(HttpServletRequest request, HttpServletResponse response,
            @RequestParam(required = false) String busOwner, @RequestParam(required = false) String password)
            throws AuthException, BackplaneServerException {

        ServletUtil.checkSecure(request);

        String httpMethod = request.getMethod();
        if ("GET".equals(httpMethod)) {
            logger.debug("returning view for GET");
            return new ModelAndView(BUS_OWNER_AUTH_FORM_JSP);
        } else if ("POST".equals(httpMethod)) {
            checkBusOwnerAuth(busOwner, password);
            persistAuthenticatedSession(response, busOwner);
            return new ModelAndView("redirect:https://" + request.getServerName() + "/v2/authorize");
        } else {
            throw new InvalidRequestException("Unsupported method for /authenticate: " + httpMethod);
        }
    }

    /**
     * The OAuth "Token Endpoint" is used to obtain an access token to be used
     * for retrieving messages from the Get Messages endpoint.
     *
     * @param scope     optional
     * @param callback  required
     * @return
     * @throws AuthException
     * @throws SimpleDBException
     * @throws BackplaneServerException
     */

    @RequestMapping(value = "/token", method = { RequestMethod.GET })
    @ResponseBody
    public Map<String, Object> getToken(final HttpServletRequest request, HttpServletResponse response,
            @RequestParam(value = OAUTH2_SCOPE_PARAM_NAME, required = false) final String scope,
            @RequestParam(required = false) final String bus, @RequestParam(required = false) final String callback,
            @RequestParam(required = false) final String refresh_token,
            @RequestHeader(value = "Authorization", required = false) final String authorizationHeader,
            @RequestHeader(value = "Referer", required = false) String referer) {

        ServletUtil.checkSecure(request);

        final TimerContext context = getRegularTokenTimer.time();

        try {
            Map<String, Object> result = new AnonymousTokenRequest(callback, bus, scope, refresh_token, daoFactory,
                    request, authorizationHeader).tokenResponse();
            // Refresh token requests are not logged to analytics.
            if (StringUtils.isNotEmpty(bus)) {
                aniLogNewChannel(request, referer, bus, (String) result.get(OAUTH2_SCOPE_PARAM_NAME));
            }

            return result;

        } catch (TokenException e) {
            return handleTokenException(e, response);
        } finally {
            context.stop();
        }
    }

    /**
     * The OAuth "Token Endpoint" is used to obtain an access token to be used
     * for retrieving messages from the Get Messages endpoint.
     *
     * @param client_id
     * @param grant_type
     * @param redirect_uri
     * @param code
     * @param client_secret
     * @param scope
     * @return
     * @throws AuthException
     * @throws SimpleDBException
     * @throws BackplaneServerException
     */

    @RequestMapping(value = "/token", method = { RequestMethod.POST })
    @ResponseBody
    public Map<String, Object> token(HttpServletRequest request, HttpServletResponse response,
            @RequestParam(value = "client_id", required = false) String client_id,
            @RequestParam(value = "grant_type", required = false) String grant_type,
            @RequestParam(value = "redirect_uri", required = false) String redirect_uri,
            @RequestParam(value = "code", required = false) String code,
            @RequestParam(value = "client_secret", required = false) String client_secret,
            @RequestParam(value = "scope", required = false) String scope,
            @RequestParam(required = false) String refresh_token,
            @RequestHeader(value = "Authorization", required = false) String authorizationHeader) {

        ServletUtil.checkSecure(request);

        TimerContext context = getPrivilegedTokenTimer.time();

        try {
            checkClientCredentialsBasicAuthOnly(request.getQueryString(), client_id, client_secret);
            Client authenticatedClient = getAuthenticatedClient(authorizationHeader);

            return (new AuthenticatedTokenRequest(grant_type, authenticatedClient, code, redirect_uri,
                    refresh_token, scope, daoFactory, request, authorizationHeader)).tokenResponse();
        } catch (TokenException e) {
            return handleTokenException(e, response);
        } catch (AuthException e) {
            logger.error(e.getMessage());
            return handleTokenException(new TokenException(OAUTH2_TOKEN_INVALID_CLIENT,
                    "Client authentication failed", SC_UNAUTHORIZED, e), response);
        } catch (Exception e) {
            return handleTokenException(new TokenException(OAUTH2_TOKEN_SERVER_ERROR, e.getMessage(),
                    HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e), response);
        } finally {
            context.stop();
        }
    }

    /**
     * Retrieve messages from the server.
     *
     * @param access_token required
     * @param block        optional
     * @param callback     optional
     * @param since        optional
     * @return json object
     * @throws SimpleDBException
     * @throws BackplaneServerException
     */

    @RequestMapping(value = "/messages", method = { RequestMethod.GET })
    public @ResponseBody Map<String, Object> messages(final HttpServletRequest request,
            HttpServletResponse response,
            @RequestParam(value = OAUTH2_ACCESS_TOKEN_PARAM_NAME, required = false) final String access_token,
            @RequestParam(value = "block", defaultValue = "0", required = false) String block,
            @RequestParam(required = false) String callback,
            @RequestParam(value = "since", required = false) String since,
            @RequestHeader(value = "Referer", required = false) String referer,
            @RequestHeader(value = "Authorization", required = false) final String authorizationHeader)
            throws SimpleDBException, BackplaneServerException {

        ServletUtil.checkSecure(request);

        TimerContext context = null;
        // only time the event if it is not blocking
        if ("0".equals(block)) {
            context = v2GetsTimer.time();
        }

        try {
            MessageRequest messageRequest = new MessageRequest(callback, since, block);

            Token token = Token.fromRequest(daoFactory, request, access_token, authorizationHeader);
            if (token.getType().isRefresh()) {
                return returnMessage(OAuth2.OAUTH2_TOKEN_INVALID_REQUEST, "Invalid token type: " + token.getType(),
                        HttpServletResponse.SC_FORBIDDEN, response);
            }

            MessagesResponse bpResponse = new MessagesResponse(messageRequest.getSince());
            boolean exit = false;
            do {
                daoFactory.getBackplaneMessageDAO().retrieveMessagesPerScope(bpResponse, token);
                if (!bpResponse.hasMessages() && new Date().before(messageRequest.getReturnBefore())) {
                    try {
                        Thread.sleep(MESSAGES_POLL_SLEEP_MILLIS);
                    } catch (InterruptedException e) {
                        //ignore
                    }
                } else {
                    exit = true;
                }
            } while (!exit);

            Map<String, Object> result = bpResponse.asResponseFields(request.getServerName(),
                    token.getType().isPrivileged());
            aniLogPollMessages(request, referer, bpResponse.getMessages());
            return result;

        } catch (TokenException te) {
            return handleTokenException(te, response);
        } catch (BackplaneServerException bse) {
            throw bse;
        } catch (InvalidRequestException ire) {
            throw ire;
        } catch (Exception e) {
            throw new BackplaneServerException("Error processing messages request: " + e.getMessage(), e);
        } finally {
            if (context != null) {
                context.stop();
            }
        }
    }

    /**
     * Retrieve a single message from the server.
     *
     * @param request
     * @param response
     * @return
     */
    @RequestMapping(value = "/message/{msg_id:.*}", method = { RequestMethod.GET })
    public @ResponseBody Map<String, Object> message(HttpServletRequest request, HttpServletResponse response,
            @PathVariable final String msg_id,
            @RequestParam(value = OAUTH2_ACCESS_TOKEN_PARAM_NAME, required = false) String access_token,
            @RequestParam(required = false) String callback,
            @RequestHeader(value = "Authorization", required = false) String authorizationHeader)
            throws BackplaneServerException, SimpleDBException {

        ServletUtil.checkSecure(request);

        TimerContext context = v2GetSingleMessageTimer.time();

        try {
            new MessageRequest(callback, null, "0"); // validate callback only, if present
        } catch (InvalidRequestException e) {
            return handleInvalidRequest(e, response);
        }

        try {
            Token token = Token.fromRequest(daoFactory, request, access_token, authorizationHeader);
            if (token.getType().isRefresh()) {
                throw new TokenException("Invalid token type: " + token.getType(),
                        HttpServletResponse.SC_FORBIDDEN);
            }

            BackplaneMessage message = daoFactory.getBackplaneMessageDAO().retrieveBackplaneMessage(msg_id, token);

            if (message != null) {
                Map<String, Object> result = message.asFrame(request.getServerName(),
                        token.getType().isPrivileged());
                aniLogGetMessage(request, message, token);
                return result;
            } else {
                return returnMessage(OAuth2.OAUTH2_TOKEN_INVALID_REQUEST, "Message id '" + msg_id + "' not found",
                        HttpServletResponse.SC_NOT_FOUND, response);
            }
        } catch (TokenException te) {
            return handleTokenException(te, response);
        } finally {
            context.stop();
        }
    }

    /**
     * Publish message to Backplane.
     * @param request
     * @param response
     * @return
     */
    @RequestMapping(value = "/message", method = { RequestMethod.POST })
    public @ResponseBody Map<String, Object> postMessages(HttpServletRequest request, HttpServletResponse response,
            @RequestBody Map<String, Map<String, Object>> messagePostBody,
            @RequestParam(value = OAUTH2_ACCESS_TOKEN_PARAM_NAME, required = false) String access_token,
            @RequestHeader(value = "Authorization", required = false) String authorizationHeader)
            throws SimpleDBException, BackplaneServerException {

        ServletUtil.checkSecure(request);

        final TimerContext context = v2PostTimer.time();

        try {
            Token token = Token.fromRequest(daoFactory, request, access_token, authorizationHeader);
            if (token.getType().isRefresh() || !token.getType().isPrivileged()) {
                throw new TokenException("Invalid token type: " + token.getType(),
                        HttpServletResponse.SC_FORBIDDEN);
            }

            BackplaneMessage message = parsePostedMessage(messagePostBody, token);
            daoFactory.getBackplaneMessageDAO().persist(message);

            aniLogNewMessage(request, message, token);

            response.setStatus(HttpServletResponse.SC_CREATED);
            return null;

        } catch (TokenException te) {
            return handleTokenException(te, response);
        } catch (InvalidRequestException ire) {
            throw ire;
        } catch (Exception e) {
            throw new BackplaneServerException("Error processing post request: " + e.getMessage(), e);
        } finally {
            context.stop();
        }
    }

    public Map<String, Object> returnMessage(final String errorCode, final String errorMessage, int responseCode,
            HttpServletResponse response) {
        response.setStatus(responseCode);
        return new HashMap<String, Object>() {
            {
                put(ERR_MSG_FIELD, errorCode);
                put(ERR_MSG_DESCRIPTION, errorMessage);
            }
        };
    }

    @ExceptionHandler
    public ModelAndView handleOauthAuthzError(final AuthorizationException e) {
        return authzRequestError(e.getOauthErrorCode(), e.getMessage(), e.getRedirectUri(), e.getState());
    }

    @ExceptionHandler
    @ResponseBody
    public Map<String, Object> handleTokenException(final TokenException e, HttpServletResponse response) {
        logger.warn("Error processing token request: " + e.getMessage(), bpConfig.getDebugException(e));
        response.setStatus(e.getHttpResponseCode());
        return new HashMap<String, Object>() {
            {
                put(ERR_MSG_FIELD, e.getOauthErrorCode());
                put(ERR_MSG_DESCRIPTION, e.getMessage());
            }
        };
    }

    /**
     * Handle auth errors as part of normal application flow
     */
    @ExceptionHandler
    @ResponseBody
    public Map<String, String> handle(final AuthException e, HttpServletResponse response) {
        logger.warn("Backplane authentication error: " + e.getMessage(), bpConfig.getDebugException(e));
        response.setStatus(SC_UNAUTHORIZED);
        return new HashMap<String, String>() {
            {
                put(ERR_MSG_FIELD, e.getMessage());
            }
        };
    }

    /**
     * Handle invalid requests as a normal part of application flow
     */
    @ExceptionHandler
    @ResponseBody
    public Map<String, Object> handleInvalidRequest(final InvalidRequestException e, HttpServletResponse response) {
        logger.error("Error handling backplane request: " + e.getMessage(), bpConfig.getDebugException(e));
        response.setStatus(e.getHttpResponseCode());
        return new HashMap<String, Object>() {
            {
                put(ERR_MSG_FIELD, e.getMessage());
                String errorDescription = e.getErrorDescription();
                if (StringUtils.isNotEmpty(errorDescription)) {
                    put(ERR_MSG_DESCRIPTION, errorDescription);
                }
            }
        };
    }

    /**
     * Handle invalid HTTP request method exceptions
     */
    @ExceptionHandler
    @ResponseBody
    public Map<String, Object> handleInvalidRequest(final HttpRequestMethodNotSupportedException e,
            HttpServletResponse response) {
        logger.warn("Error handling backplane request: " + e.getMessage(), bpConfig.getDebugException(e));
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        return new HashMap<String, Object>() {
            {
                put(ERR_MSG_FIELD, e.getMessage());
            }
        };
    }

    /**
     * Handle all other errors not normally a part of application flow.
     */
    @ExceptionHandler
    @ResponseBody
    public Map<String, String> handle(final Exception e, HttpServletResponse response) {
        logger.error("Error handling backplane request: " + e.getMessage(), bpConfig.getDebugException(e));
        response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        return new HashMap<String, String>() {
            {
                put(ERR_MSG_FIELD, bpConfig.isDebugMode() ? e.getMessage() : "Error processing request.");
            }
        };
    }

    /*
    public static String randomString(int length) {
    byte[] randomBytes = new byte[length];
    // the base64 character set per RFC 4648 with last two members '-' and '_' removed due to possible
    // compatibility issues.
    byte[] digits = {'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
                     'U','V','W','X','Y','Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n',
                     'o','p','q','r','s','t','u','v','w','x','y','z','0','1','2','3','4','5','6','7',
                     '8','9'};
    random.nextBytes(randomBytes);
    for (int i = 0; i < length; i++) {
        byte b = randomBytes[i];
        int c = Math.abs(b % digits.length);
        randomBytes[i] = digits[c];
    }
    try {
        return new String(randomBytes, "US-ASCII");
    }
    catch (UnsupportedEncodingException e) {
        logger.error("US-ASCII character encoding not supported", e); // shouldn't happen
        return null;
    }
    }
    */

    // - PRIVATE

    private static final Logger logger = Logger.getLogger(Backplane2Controller.class);

    private static final String ERR_MSG_FIELD = "error";
    private static final String ERR_MSG_DESCRIPTION = "error_description";

    private static final String BUS_OWNER_AUTH_FORM_JSP = "bus_owner_auth";
    private static final String CLIENT_AUTHORIZATION_FORM_JSP = "client_authorization";

    private static final int AUTH_SESSION_COOKIE_LENGTH = 30;
    private static final String AUTH_SESSION_COOKIE = "bp2.bus.owner.auth";
    private static final int AUTHORIZATION_REQUEST_COOKIE_LENGTH = 30;
    private static final String AUTHORIZATION_REQUEST_COOKIE = "bp2.authorization.request";

    public static final String AUTHZ_DECISION_KEY = "auth_key";

    private static final int MESSAGES_POLL_SLEEP_MILLIS = 3000;

    @Inject
    private DAOFactory daoFactory;

    @Inject
    private Backplane2Config bpConfig;

    @Inject
    private AnalyticsLogger anilogger;

    //private static final Random random = new SecureRandom();

    private void checkBusOwnerAuth(String busOwner, String password) throws AuthException {
        User busOwnerEntry = null;
        try {
            busOwnerEntry = daoFactory.getBusOwnerDAO().get(busOwner);
        } catch (BackplaneServerException e) {
            logger.error("Error looking up bus owner user: " + busOwner, e);
            authError("Error looking up bus owner user: " + busOwner);
        }

        if (busOwnerEntry == null) {
            authError("Bus owner user not found: " + busOwner);
        } else if (!HmacHashUtils.checkHmacHash(password, busOwnerEntry.get(User.Field.PWDHASH))) {
            authError("Incorrect password for bus owner user " + busOwner);
        }
        logger.info("Authenticated bus owner: " + busOwner);
    }

    private void persistAuthenticatedSession(HttpServletResponse response, String busOwner)
            throws BackplaneServerException {
        try {
            String authCookie = ChannelUtil.randomString(AUTH_SESSION_COOKIE_LENGTH);
            daoFactory.getAuthSessionDAO().persist(new AuthSession(busOwner, authCookie));
            response.addCookie(new Cookie(AUTH_SESSION_COOKIE, authCookie));
        } catch (SimpleDBException e) {
            throw new BackplaneServerException(e.getMessage());
        }
    }

    private String getAuthenticatedBusOwner(HttpServletRequest request, String authSessionCookie) {
        if (authSessionCookie == null)
            return null;
        try {
            AuthSession authSession = daoFactory.getAuthSessionDAO().get(authSessionCookie);
            String authenticatedOwner = authSession.get(AuthSession.Field.AUTH_USER);
            logger.info("Session found for previously authenticated bus owner: " + authenticatedOwner);
            return authenticatedOwner;
        } catch (BackplaneServerException e) {
            logger.error("Error looking up session for cookie: " + authSessionCookie, e);
            return null;
        }
    }

    private void authError(String errMsg) throws AuthException {
        logger.error(errMsg);
        try {
            throw new AuthException("Access denied. " + (bpConfig.isDebugMode() ? errMsg : ""));
        } catch (Exception e) {
            throw new AuthException("Access denied.");
        }
    }

    private String paddedResponse(String callback, String s) {
        if (StringUtils.isBlank(callback)) {
            throw new InvalidRequestException("Callback cannot be blank.");
        }
        StringBuilder result = new StringBuilder(callback);
        result.append("(").append(s).append(")");
        return result.toString();
    }

    /** Parse, extract & validate an OAuth2 authorization request from the HTTP request */
    private AuthorizationRequest parseAuthZrequest(HttpServletRequest request) throws AuthorizationException {
        try {
            // parse authz request
            AuthorizationRequest authorizationRequest = new AuthorizationRequest(
                    ChannelUtil.randomString(AUTHORIZATION_REQUEST_COOKIE_LENGTH), request.getParameterMap());
            logger.info("Parsed authorization request: " + authorizationRequest);
            return authorizationRequest;
        } catch (Exception e) {
            throw new AuthorizationException(OAuth2.OAUTH2_AUTHZ_INVALID_REQUEST, e.getMessage(), request, e);
        }
    }

    /** Returns an authenticated Client, never null, or throws AuthException */
    private Client getAuthenticatedClient(String basicAuth) throws AuthException {
        String userPass = null;
        if (basicAuth == null || !basicAuth.startsWith("Basic ") || basicAuth.length() < 7) {
            authError("Invalid client authorization header: " + basicAuth);
        } else {
            try {
                userPass = new String(Base64.decodeBase64(basicAuth.substring(6).getBytes("utf-8")));
            } catch (UnsupportedEncodingException e) {
                authError("Cannot check client authentication, unsupported encoding: utf-8"); // shouldn't happen
            }
        }

        @SuppressWarnings({ "ConstantConditions" })
        int delim = userPass.indexOf(":");
        if (delim == -1) {
            authError("Invalid Basic auth token: " + userPass);
        }
        String client = userPass.substring(0, delim);
        String pass = userPass.substring(delim + 1);

        Client clientEntry = null;
        try {
            clientEntry = daoFactory.getClientDAO().get(client);
        } catch (BackplaneServerException e) {
            logger.error("Error looking up client: " + client, e);
            authError("Error looking up client: " + client);
        }

        if (clientEntry == null) {
            authError("Client not found: " + client);
        } else if (!HmacHashUtils.checkHmacHash(pass, clientEntry.get(Client.Field.PWDHASH))) {
            authError("Incorrect password for client " + client);
        }

        logger.info("Authenticated client: " + client);
        return clientEntry;
    }

    /** Present an authorization form to the bus owner and obtain authorization decision */
    private ModelAndView processAuthZrequest(AuthorizationRequest authzRequest, String authSessionCookie,
            String authenticatedBusOwner) throws AuthorizationException {
        Map<String, String> model = new HashMap<String, String>();

        // generate & persist authZdecisionKey
        logger.debug("generate & persist authZdecisionKey");
        try {
            AuthorizationDecisionKey authorizationDecisionKey = new AuthorizationDecisionKey(authSessionCookie);
            daoFactory.getAuthorizationDecisionKeyDAO().persist(authorizationDecisionKey);

            model.put("auth_key", authorizationDecisionKey.get(AuthorizationDecisionKey.Field.KEY));
            model.put(AuthorizationRequest.Field.CLIENT_ID.getFieldName().toLowerCase(),
                    authzRequest.get(AuthorizationRequest.Field.CLIENT_ID));
            model.put(AuthorizationRequest.Field.REDIRECT_URI.getFieldName().toLowerCase(),
                    authzRequest.getRedirectUri(daoFactory.getClientDAO()));

            String scope = authzRequest.get(AuthorizationRequest.Field.SCOPE);
            model.put(AuthorizationRequest.Field.SCOPE.getFieldName().toLowerCase(),
                    checkScope(scope, authenticatedBusOwner));

            // return authZ form
            logger.info("Requesting bus owner authorization for :"
                    + authzRequest.get(AuthorizationRequest.Field.CLIENT_ID) + "["
                    + authzRequest.get(AuthorizationRequest.Field.COOKIE) + "]");
            return new ModelAndView(CLIENT_AUTHORIZATION_FORM_JSP, model);

        } catch (Exception e) {
            throw new AuthorizationException(OAuth2.OAUTH2_AUTHZ_SERVER_ERROR, e.getMessage(), authzRequest, e);
        }
    }

    private String checkScope(String scope, String authenticatedBusOwner) throws BackplaneServerException {
        StringBuilder result = new StringBuilder();
        List<BusConfig2> ownedBuses = daoFactory.getBusDao().retrieveByOwner(authenticatedBusOwner);
        if (StringUtils.isEmpty(scope)) {
            // request scope empty, ask/offer permission to all owned buses
            for (BusConfig2 bus : ownedBuses) {
                result.append("bus:").append(bus.get(BusConfig2.Field.BUS_NAME)).append(" ");
            }
            if (result.length() > 0) {
                result.deleteCharAt(result.length() - 1);
            }
        } else {
            List<String> ownedBusNames = new ArrayList<String>();
            for (BusConfig2 bus : ownedBuses) {
                ownedBusNames.add(bus.get(BusConfig2.Field.BUS_NAME));
            }
            for (String scopeToken : scope.split(" ")) {
                if (scopeToken.startsWith("bus:")) {
                    String bus = scopeToken.substring(4);
                    if (!ownedBusNames.contains(bus))
                        continue;
                }
                result.append(scopeToken).append(" ");
            }
            if (result.length() > 0) {
                result.deleteCharAt(result.length() - 1);
            }
        }

        String resultString = result.toString();
        if (!resultString.equals(scope)) {
            logger.info("Authenticated bus owner " + authenticatedBusOwner
                    + " is authoritative for requested scope: " + resultString);
        }
        return resultString;
    }

    private ModelAndView processAuthZdecision(String authZdecisionKey, String authSessionCookie,
            String authenticatedBusOwner, String authorizationRequestCookie, HttpServletRequest request)
            throws AuthorizationException {
        AuthorizationRequest authorizationRequest = null;

        logger.debug("processAuthZdecision()");

        try {
            // retrieve authorization request
            authorizationRequest = daoFactory.getAuthorizationRequestDAO().get(authorizationRequestCookie);

            // check authZdecisionKey
            AuthorizationDecisionKey authZdecisionKeyEntry = daoFactory.getAuthorizationDecisionKeyDAO()
                    .get(authZdecisionKey);
            if (null == authZdecisionKeyEntry || !authSessionCookie
                    .equals(authZdecisionKeyEntry.get(AuthorizationDecisionKey.Field.AUTH_COOKIE))) {
                throw new AuthorizationException(OAuth2.OAUTH2_AUTHZ_ACCESS_DENIED,
                        "Presented authorization key was issued to a different authenticated bus owner.",
                        authorizationRequest);
            }

            if (!"Authorize".equals(request.getParameter("authorize"))) {
                throw new AuthorizationException(OAuth2.OAUTH2_AUTHZ_ACCESS_DENIED,
                        "Bus owner denied authorization.", authorizationRequest);
            } else {
                // todo: use (and check) scope posted back by bus owner
                String scopeString = checkScope(authorizationRequest.get(AuthorizationRequest.Field.SCOPE),
                        authenticatedBusOwner);
                // create grant/code
                Grant grant = new Grant.Builder(GrantType.AUTHORIZATION_CODE, GrantState.INACTIVE,
                        authenticatedBusOwner, authorizationRequest.get(AuthorizationRequest.Field.CLIENT_ID),
                        scopeString).buildGrant();
                daoFactory.getGrantDao().persist(grant);

                logger.info("Authorized " + authorizationRequest.get(AuthorizationRequest.Field.CLIENT_ID) + "["
                        + authorizationRequest.get(AuthorizationRequest.Field.COOKIE) + "]" + "grant ID: "
                        + grant.getIdValue());

                // return OAuth2 authz response
                final String code = grant.getIdValue();
                final String state = authorizationRequest.get(AuthorizationRequest.Field.STATE);

                try {
                    return new ModelAndView("redirect:" + UrlResponseFormat.QUERY.encode(
                            authorizationRequest.getRedirectUri(daoFactory.getClientDAO()),
                            new HashMap<String, String>() {
                                {
                                    put(OAuth2.OAUTH2_AUTHZ_RESPONSE_CODE, code);
                                    if (StringUtils.isNotEmpty(state)) {
                                        put(OAuth2.OAUTH2_AUTHZ_RESPONSE_STATE, state);
                                    }
                                }
                            }));
                } catch (ValidationException ve) {
                    String errMsg = "Error building (positive) authorization response: " + ve.getMessage();
                    logger.error(errMsg, ve);
                    return authzRequestError(OAuth2.OAUTH2_AUTHZ_DIRECT_ERROR, errMsg,
                            authorizationRequest.getRedirectUri(daoFactory.getClientDAO()),
                            authorizationRequest.get(AuthorizationRequest.Field.STATE));
                }
            }
        } catch (Exception e) {
            throw new AuthorizationException(OAuth2.OAUTH2_AUTHZ_SERVER_ERROR, e.getMessage(), authorizationRequest,
                    e);
        }
    }

    private static ModelAndView authzRequestError(final String oauthErrCode, final String errMsg,
            final String redirectUri, final String state) {
        // direct or in/redirect
        if (OAuth2.OAUTH2_AUTHZ_DIRECT_ERROR.equals(oauthErrCode)) {
            logger.error("Authorization error: " + errMsg);
            return new ModelAndView(DIRECT_RESPONSE, new HashMap<String, Object>() {
                {
                    put(DIRECT_RESPONSE, errMsg);
                }
            });
        } else {
            try {
                return new ModelAndView(
                        "redirect:" + UrlResponseFormat.QUERY.encode(redirectUri, new HashMap<String, String>() {
                            {
                                put(OAuth2.OAUTH2_AUTHZ_ERROR_FIELD_NAME, oauthErrCode);
                                put(OAuth2.OAUTH2_AUTHZ_ERROR_DESC_FIELD_NAME, errMsg);
                                if (StringUtils.isNotEmpty(state)) {
                                    put(AuthorizationRequest.Field.STATE.getFieldName(), state);
                                }
                            }
                        }));

            } catch (ValidationException e) {
                logger.error("Error building redirect_uri: " + e.getMessage());
                return new ModelAndView(DIRECT_RESPONSE, new HashMap<String, Object>() {
                    {
                        put(DIRECT_RESPONSE, errMsg);
                    }
                });
            }
        }
    }

    /**
     * Throws AuthException if either of the following fail:
     *   Client credentials MUST NOT be included in the request URI (OAuth2 2.3.1)
     *   Client credentials in request body are NOT RECOMMENDED (OAuth2 2.3.1)
     *
     * @param queryString request query string
     * @param client_id from request parameters (may be from POST/request body)
     * @param client_secret from request parameters (may be from POST/request body)
     */
    private void checkClientCredentialsBasicAuthOnly(String queryString, String client_id, String client_secret)
            throws AuthException {
        if (StringUtils.isNotEmpty(queryString)) {
            Map<String, String> queryParamsMap = new HashMap<String, String>();
            for (String queryParamPair : Arrays.asList(queryString.split("&"))) {
                String[] nameVal = queryParamPair.split("=", 2);
                queryParamsMap.put(nameVal[0], nameVal.length > 0 ? nameVal[1] : null);
            }
            if (queryParamsMap.containsKey("client_id") || queryParamsMap.containsKey("client_secret")) {
                authError("Client credentials MUST NOT be included in the request URI (OAuth2 2.3.1)");
            }
        }
        if (StringUtils.isNotEmpty(client_id) || StringUtils.isNotEmpty(client_secret)) {
            authError("Client credentials in request body are NOT RECOMMENDED (OAuth2 2.3.1)");
        }
    }

    private BackplaneMessage parsePostedMessage(Map<String, Map<String, Object>> messagePostBody, Token token)
            throws BackplaneServerException {
        List<BackplaneMessage> result = new ArrayList<BackplaneMessage>();

        Map<String, Object> msg = messagePostBody.get("message");
        if (msg == null) { // no message body?
            throw new InvalidRequestException("Missing message payload", HttpServletResponse.SC_BAD_REQUEST);
        }

        if (messagePostBody.keySet().size() != 1) { // other garbage in the payload
            throw new InvalidRequestException("Invalid data in payload", HttpServletResponse.SC_BAD_REQUEST);
        }

        String channelId = msg.get(BackplaneMessage.Field.CHANNEL.getFieldName()) != null
                ? msg.get(BackplaneMessage.Field.CHANNEL.getFieldName()).toString()
                : null;
        String bus = msg.get(BackplaneMessage.Field.BUS.getFieldName()) != null
                ? msg.get(BackplaneMessage.Field.BUS.getFieldName()).toString()
                : null;
        Channel channel = getChannel(channelId);
        String boundBus = channel == null ? null : channel.get(Channel.ChannelField.BUS);
        if (channel == null || !StringUtils.equals(bus, boundBus)) {
            throw new InvalidRequestException("Invalid bus - channel binding ", HttpServletResponse.SC_FORBIDDEN);
        }

        // check to see if channel is already full
        if (daoFactory.getBackplaneMessageDAO().getMessageCount(channelId) >= bpConfig
                .getDefaultMaxMessageLimit()) {
            throw new InvalidRequestException("Message limit of " + bpConfig.getDefaultMaxMessageLimit()
                    + " has been reached for channel '" + channel + "'", HttpServletResponse.SC_FORBIDDEN);
        }

        BackplaneMessage message;
        try {
            message = new BackplaneMessage(token.get(Token.TokenField.CLIENT_SOURCE_URL),
                    Integer.parseInt(channel.get(Channel.ChannelField.MESSAGE_EXPIRE_DEFAULT_SECONDS)),
                    Integer.parseInt(channel.get(Channel.ChannelField.MESSAGE_EXPIRE_MAX_SECONDS)), msg);
        } catch (Exception e) {
            throw new InvalidRequestException("Invalid message data: " + e.getMessage(),
                    HttpServletResponse.SC_BAD_REQUEST);
        }
        if (!token.getScope().isMessageInScope(message)) {
            throw new InvalidRequestException("Invalid bus in message", HttpServletResponse.SC_FORBIDDEN);
        }
        return message;
    }

    private Channel getChannel(String channelId) throws BackplaneServerException {
        Channel channel = daoFactory.getChannelDao().get(channelId);
        if (channel == null) {
            // legacy channel-bus binding support
            // todo: remove after all old channels have expired
            BusConfig2 busConfig = daoFactory.getBusDao()
                    .get(Redis.getInstance().get("v2_channel_bus_" + channelId));
            if (busConfig != null) {
                try {
                    channel = new Channel(channelId, busConfig, 0);
                } catch (SimpleDBException e) {
                    // shouldn't happen
                    throw new BackplaneServerException("", e);
                }
            }
        }
        return channel;
    }

    private void aniLogNewChannel(HttpServletRequest request, String referer, String bus, String scope) {
        if (!anilogger.isEnabled()) {
            return;
        }

        int delim = scope.indexOf("channel:");
        String channel = scope.substring(delim + 8);
        String channelId = "https://" + request.getServerName() + "/v2/bus/" + bus + "/channel/" + channel;
        String siteHost = (referer != null) ? ServletUtil.getHostFromUrl(referer) : null;

        Map<String, Object> aniEvent = new HashMap<String, Object>();
        aniEvent.put("channel_id", channelId);
        aniEvent.put("bus", bus);
        aniEvent.put("site_host", siteHost);

        aniLog("new_channel", aniEvent);
    }

    private void aniLogPollMessages(HttpServletRequest request, String referer, List<BackplaneMessage> messages) {
        if (!anilogger.isEnabled()) {
            return;
        }

        String siteHost = (referer != null) ? ServletUtil.getHostFromUrl(referer) : null;
        String serverName = request.getServerName();
        Map<String, Object> aniEvent = new HashMap<String, Object>();

        List<Map<String, String>> messagesMeta = new ArrayList<Map<String, String>>();
        for (BackplaneMessage message : messages) {
            String bus = message.getBus();
            String channelId = "https://" + serverName + "/v2/bus/" + bus + "/channel/" + message.getChannel();
            Map<String, String> anotherMeta = new HashMap<String, String>();
            anotherMeta.put("id", message.getIdValue());
            anotherMeta.put("bus", bus);
            anotherMeta.put("channel_id", channelId);
            messagesMeta.add(anotherMeta);
        }
        aniEvent.put("messages", messagesMeta);
        aniEvent.put("site_host", siteHost);

        aniLog("poll_messages", aniEvent);
    }

    private void aniLogGetMessage(HttpServletRequest request, BackplaneMessage message, Token token) {
        if (!anilogger.isEnabled()) {
            return;
        }

        String bus = message.getBus();
        String channelId = "https://" + request.getServerName() + "/v2/bus/" + bus + "/channel/"
                + message.getChannel();
        Map<String, Object> aniEvent = new HashMap<String, Object>();
        aniEvent.put("message_id", message.getIdValue());
        aniEvent.put("bus", bus);
        aniEvent.put("channel_id", channelId);
        aniEvent.put("client_id", token.get(Token.TokenField.ISSUED_TO_CLIENT_ID));

        aniLog("get_message", aniEvent);
    }

    private void aniLogNewMessage(HttpServletRequest request, BackplaneMessage message, Token token) {
        if (!anilogger.isEnabled()) {
            return;
        }

        String bus = message.getBus();
        String channel = message.getChannel();
        String channelId = "https://" + request.getServerName() + "/v2/bus/" + bus + "/channel/" + channel;
        String clientId = token.get(Token.TokenField.ISSUED_TO_CLIENT_ID);
        Map<String, Object> aniEvent = new HashMap<String, Object>();
        aniEvent.put("channel_id", channelId);
        aniEvent.put("bus", bus);
        aniEvent.put("client_id", clientId);

        aniLog("new_message", aniEvent);
    }

    private void aniLog(String eventName, Map<String, Object> eventData) {
        ObjectMapper mapper = new ObjectMapper();
        String time = DateTimeUtils.ISO8601.get().format(new Date(System.currentTimeMillis()));
        eventData.put("time", time);
        eventData.put("version", "v2");
        try {
            anilogger.log(eventName, mapper.writeValueAsString(eventData));
        } catch (Exception e) {
            String errMsg = "Error sending analytics event: " + e.getMessage();
            logger.error(errMsg, bpConfig.getDebugException(e));
        }
    }

    private final com.yammer.metrics.core.Timer v2GetsTimer = com.yammer.metrics.Metrics.newTimer(
            new MetricName("v2", this.getClass().getName().replace(".", "_"), "v2_gets_time"),
            TimeUnit.MILLISECONDS, TimeUnit.MINUTES);
    private final com.yammer.metrics.core.Timer v2GetSingleMessageTimer = com.yammer.metrics.Metrics.newTimer(
            new MetricName("v2", this.getClass().getName().replace(".", "_"), "v2_get_single_message_time"),
            TimeUnit.MILLISECONDS, TimeUnit.MINUTES);
    private final com.yammer.metrics.core.Timer v2PostTimer = com.yammer.metrics.Metrics.newTimer(
            new MetricName("v2", this.getClass().getName().replace(".", "_"), "v2_posts_time"),
            TimeUnit.MILLISECONDS, TimeUnit.SECONDS);
    private final com.yammer.metrics.core.Timer getRegularTokenTimer = com.yammer.metrics.Metrics.newTimer(
            new MetricName("v2", this.getClass().getName().replace(".", "_"), "v2_get_reg_tokens_time"),
            TimeUnit.MILLISECONDS, TimeUnit.SECONDS);
    private final com.yammer.metrics.core.Timer getPrivilegedTokenTimer = com.yammer.metrics.Metrics.newTimer(
            new MetricName("v2", this.getClass().getName().replace(".", "_"), "v2_get_privileged_tokens_time"),
            TimeUnit.MILLISECONDS, TimeUnit.SECONDS);

}