/* * Copyright 2000-2014 JetBrains s.r.o. * * 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 jetbrains.buildServer.server.rest; import com.intellij.openapi.diagnostic.Logger; import com.intellij.util.ArrayUtil; import com.intellij.util.Function; import com.sun.jersey.api.core.PackagesResourceConfig; import com.sun.jersey.api.core.ResourceConfig; import com.sun.jersey.api.json.JSONConfiguration; import com.sun.jersey.core.util.FeaturesAndProperties; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.*; import java.util.concurrent.TimeUnit; import javax.servlet.FilterConfig; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; import jetbrains.buildServer.ExtensionHolder; import jetbrains.buildServer.controllers.AuthorizationInterceptor; import jetbrains.buildServer.controllers.BaseController; import jetbrains.buildServer.controllers.interceptors.PathSet; import jetbrains.buildServer.controllers.interceptors.auth.HttpAuthenticationManager; import jetbrains.buildServer.controllers.interceptors.auth.HttpAuthenticationResult; import jetbrains.buildServer.plugins.PluginManager; import jetbrains.buildServer.plugins.bean.PluginInfo; import jetbrains.buildServer.plugins.bean.ServerPluginInfo; import jetbrains.buildServer.server.rest.jersey.ExceptionMapperUtil; import jetbrains.buildServer.server.rest.jersey.JerseyWebComponent; import jetbrains.buildServer.server.rest.jersey.WadlGenerator; import jetbrains.buildServer.server.rest.request.BuildRequest; import jetbrains.buildServer.server.rest.request.Constants; import jetbrains.buildServer.server.rest.request.RootApiRequest; import jetbrains.buildServer.server.rest.request.ServerRequest; import jetbrains.buildServer.serverSide.BuildServerAdapter; import jetbrains.buildServer.serverSide.SBuildServer; import jetbrains.buildServer.serverSide.SecurityContextEx; import jetbrains.buildServer.serverSide.TeamCityProperties; import jetbrains.buildServer.util.FuncThrow; import jetbrains.buildServer.util.StringUtil; import jetbrains.buildServer.web.openapi.PluginDescriptor; import jetbrains.buildServer.web.openapi.WebControllerManager; import jetbrains.buildServer.web.util.SessionUser; import jetbrains.buildServer.web.util.UserAgentUtil; import jetbrains.buildServer.web.util.WebUtil; import org.apache.log4j.Level; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.web.context.ServletContextAware; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.servlet.ModelAndView; /** * @author Yegor.Yarko * Date: 23.03.2009 */ public class APIController extends BaseController implements ServletContextAware { public static final String REST_COMPATIBILITY_ALLOW_EXTERNAL_ID_AS_INTERNAL = "rest.compatibility.allowExternalIdAsInternal"; public static final String INCLUDE_INTERNAL_ID_PROPERTY_NAME = "rest.beans.includeInternalId"; final static Logger LOG = Logger.getInstance(APIController.class.getName()); public static final String REST_CORS_ORIGINS_INTERNAL_PROPERTY_NAME = "rest.cors.origins"; public static final String REST_RESPONSE_PRETTYFORMAT = "rest.response.prettyformat"; public static final String REST_PREFER_OWN_BIND_PATHS = "rest.allow.bind.paths.override"; private final boolean myInternalAuthProcessing = TeamCityProperties.getBoolean("rest.cors.optionsRequest.allowUnauthorized"); private final String[] myPathsWithoutAuth = new String[]{ BuildRequest.BUILDS_ROOT_REQUEST_PATH + "/*/" + BuildRequest.STATUS_ICON_REQUEST_NAME, ServerRequest.SERVER_REQUEST_PATH + "/" + ServerRequest.SERVER_VERSION_RQUEST_PATH, RootApiRequest.VERSION, RootApiRequest.API_VERSION, Constants.EXTERNAL_APPLICATION_WADL_NAME, Constants.EXTERNAL_APPLICATION_WADL_NAME + "/xsd*.xsd"}; private JerseyWebComponent myWebComponent; private final ConfigurableApplicationContext myConfigurableApplicationContext; private final SecurityContextEx mySecurityContext; private final WebControllerManager myWebControllerManager; private final ServerPluginInfo myPluginDescriptor; private final ExtensionHolder myExtensionHolder; private final AuthorizationInterceptor myAuthorizationInterceptor; @NotNull private final HttpAuthenticationManager myAuthManager; @NotNull private final PluginManager myPluginManager; private ClassLoader myClassloader; private String myAuthToken; private final RequestPathTransformInfo myRequestPathTransformInfo; private final PathSet myUnauthenticatedPathSet = new PathSet(); private final CachingValuesFromInternalProperty myAllowedOrigins = new CachingValuesFromInternalProperty(); private final CachingValuesFromInternalProperty myDisabledRequests = new CachingValuesFromInternalProperty(); public static String ourFirstBindPath; public APIController(final SBuildServer server, WebControllerManager webControllerManager, final ConfigurableApplicationContext configurableApplicationContext, final SecurityContextEx securityContext, final RequestPathTransformInfo requestPathTransformInfo, final ServerPluginInfo pluginDescriptor, final ExtensionHolder extensionHolder, final AuthorizationInterceptor authorizationInterceptor, @NotNull final HttpAuthenticationManager authManager, @NotNull final PluginManager pluginManager) throws ServletException { super(server); myWebControllerManager = webControllerManager; myPluginDescriptor = pluginDescriptor; myExtensionHolder = extensionHolder; myAuthorizationInterceptor = authorizationInterceptor; myAuthManager = authManager; myPluginManager = pluginManager; setSupportedMethods(new String[]{METHOD_GET, METHOD_HEAD, METHOD_POST, "PUT", "OPTIONS", "DELETE"}); myConfigurableApplicationContext = configurableApplicationContext; mySecurityContext = securityContext; myRequestPathTransformInfo = requestPathTransformInfo; server.addListener(new BuildServerAdapter() { @Override public void pluginsLoaded() { initializeController(); } }); if (TeamCityProperties.getBoolean("rest.use.authToken")) { try { myAuthToken = URLEncoder.encode(UUID.randomUUID().toString() + (new Date()).toString().hashCode(), "UTF-8"); LOG.info("Authentication token for Super user generated: '" + myAuthToken + "' (" + getPluginIdentifyingText() + ")"); } catch (UnsupportedEncodingException e) { LOG.warn(e); } } } public void initializeController() { final List unfilteredOriginalBindPaths = getBindPaths(myPluginDescriptor); if (unfilteredOriginalBindPaths.isEmpty()) { final String message = "Error while initializing REST API " + getPluginIdentifyingText() + ": No bind paths found. Corrupted plugin?"; LOG.error(message + " Reporting plugin load error."); throw new RuntimeException(message); } ourFirstBindPath = unfilteredOriginalBindPaths.get(0); final List originalBindPaths = filterOtherPlugins(unfilteredOriginalBindPaths); if (originalBindPaths.isEmpty()) { final String message = "Error while initializing REST API " + getPluginIdentifyingText() + ": No unique bind paths found. Conflicting plugins set is installed."; LOG.error(message + " Reporting plugin load error."); throw new RuntimeException(message); } LOG.info("Listening for paths " + originalBindPaths + " in " + getPluginIdentifyingText()); List bindPaths = new ArrayList(originalBindPaths); bindPaths.addAll(addPrefix(originalBindPaths, StringUtil.removeTailingSlash(WebUtil.HTTP_AUTH_PREFIX))); bindPaths.addAll(addPrefix(originalBindPaths, StringUtil.removeTailingSlash(WebUtil.GUEST_AUTH_PREFIX))); Map transformBindPaths = new HashMap(); addEntries(transformBindPaths, bindPaths, Constants.API_URL); addEntries(transformBindPaths, addSuffix(bindPaths, Constants.EXTERNAL_APPLICATION_WADL_NAME), Constants.JERSEY_APPLICATION_WADL_NAME); myRequestPathTransformInfo.setPathMapping(transformBindPaths); LOG.debug("Will use request mapping: " + myRequestPathTransformInfo); registerController(myWebControllerManager, originalBindPaths); myClassloader = getClass().getClassLoader(); } private List filterOtherPlugins(final List bindPaths) { if (!TeamCityProperties.getBooleanOrTrue(REST_PREFER_OWN_BIND_PATHS)) { //todo: support list of plugin names as values, default to "rest-api" return bindPaths; } final ArrayList result = new ArrayList(bindPaths); final Collection allPlugins = myPluginManager.getDetectedPlugins(); //is the plugin actually loaded? Might need to check only the successfully loaded plugins for (PluginInfo plugin : allPlugins) { if (myPluginDescriptor.getPluginName().equals(plugin.getPluginName())) { continue; } if (plugin instanceof PluginDescriptor) { final PluginDescriptor pluginDescriptor = (ServerPluginInfo)plugin; //TeamCity API issue: cast String bindPath = pluginDescriptor.getParameterValue(Constants.BIND_PATH_PROPERTY_NAME); if (!StringUtil.isEmpty(bindPath)) { final List pathToExclude = getBindPaths(pluginDescriptor); if (result.removeAll(pathToExclude)){ LOG.info("Excluding paths from handling by plugin '" + myPluginDescriptor.getPluginName() + "' as they are handled by plugin '" + plugin.getPluginName() + "': " + pathToExclude + ". Set " + REST_PREFER_OWN_BIND_PATHS + " to 'false' to turn this logic off."); } } } else { LOG.warn("Cannot get plugin info for plugin '" + plugin.getPluginName() + "', ignoring it while filtering out REST bind paths. " + "This is a bug in the REST plugin, please report it to JetBrains."); } } return result; } private String getPluginIdentifyingText() { return "plugin '" + myPluginDescriptor.getPluginName() + "'"; } private static void addEntries(final Map map, final List keys, final String value) { for (String key : keys) { map.put(key, value); } } private List addPrefix(final List paths, final String prefix) { List result = new ArrayList(paths.size()); for (String path : paths) { result.add(prefix + path); } return result; } private List addSuffix(final List paths, final String suffix) { List result = new ArrayList(paths.size()); for (String path : paths) { result.add(path + suffix); } return result; } private void registerController(final WebControllerManager webControllerManager, final List bindPaths) { try { for (String controllerBindPath : bindPaths) { LOG.debug("Binding REST API " + getPluginIdentifyingText() + " to path '" + controllerBindPath + "'"); webControllerManager.registerController(controllerBindPath + "/**", this); if (myInternalAuthProcessing && !controllerBindPath.equals("/app/rest")) {// this is a special case as it contains paths of other plugins under it. Thus, it cannot be registered as not requiring auth myAuthorizationInterceptor.addPathNotRequiringAuth(controllerBindPath + "/**"); for (String path : myPathsWithoutAuth) { myUnauthenticatedPathSet.addPath(controllerBindPath + path); } } else { for (String path : myPathsWithoutAuth) { myAuthorizationInterceptor.addPathNotRequiringAuth(controllerBindPath + path); } } } } catch (Exception e) { LOG.error("Error registering controller in " + getPluginIdentifyingText(), e); } } private boolean requestForMyPathNotRequiringAuth(final HttpServletRequest request) { return myUnauthenticatedPathSet.matches(WebUtil.getOriginalPathWithoutAuthenticationType(request)); } private List getBindPaths(@NotNull final PluginDescriptor pluginDescriptor) { String bindPath = pluginDescriptor.getParameterValue(Constants.BIND_PATH_PROPERTY_NAME); if (bindPath == null) { LOG.error("No property '" + Constants.BIND_PATH_PROPERTY_NAME + "' found in pugin descriptor file in " + getPluginIdentifyingText() + ". Corrupted plugin?"); return Collections.emptyList(); } final String[] bindPaths = bindPath.split(","); if (bindPath.length() == 0) { LOG.error("Invalid REST API bind path in plugin descriptor: '" + bindPath + "'in " + getPluginIdentifyingText() + ". Corrupted plugin?"); return Collections.emptyList(); } return Arrays.asList(bindPaths); } private void init() throws ServletException { myWebComponent = new JerseyWebComponent(); myWebComponent.setExtensionHolder(myExtensionHolder); myWebComponent.setWebApplicationContext(myConfigurableApplicationContext); myWebComponent.init(createJerseyConfig()); } private FilterConfig createJerseyConfig() { return new FilterConfig() { Map initParameters = new HashMap(); { initParameters.put(ResourceConfig.PROPERTY_WADL_GENERATOR_CONFIG, WadlGenerator.class.getCanonicalName()); initParameters.put(JSONConfiguration.FEATURE_POJO_MAPPING, "true"); initParameters.put(PackagesResourceConfig.PROPERTY_PACKAGES, "org.codehaus.jackson.jaxrs;jetbrains.buildServer.server.rest.request;" + getPackagesFromExtensions()); if (TeamCityProperties.getBoolean(APIController.REST_RESPONSE_PRETTYFORMAT)) { initParameters.put(FeaturesAndProperties.FEATURE_FORMATTED, "true"); } } private String getPackagesFromExtensions() { return StringUtil.join(myServer.getExtensions(RESTControllerExtension.class), new Function() { public String fun(final RESTControllerExtension restControllerExtension) { return restControllerExtension.getPackage(); } }, ";"); } public String getFilterName() { return "jerseyFilter"; } public ServletContext getServletContext() { //return APIController.this.getServletContext(); // workaround for http://jetbrains.net/tracker/issue2/TW-7656 for (ApplicationContext ctx = getApplicationContext(); ctx != null; ctx = ctx.getParent()) { if (ctx instanceof WebApplicationContext) { return ((WebApplicationContext)ctx).getServletContext(); } } throw new RuntimeException("WebApplication context was not found."); } public String getInitParameter(final String s) { return initParameters.get(s); } public Enumeration getInitParameterNames() { return new Vector(initParameters.keySet()).elements(); } }; } protected ModelAndView doHandle(@NotNull final HttpServletRequest request, @NotNull final HttpServletResponse response) throws Exception { if (TeamCityProperties.getBoolean("rest.disable")) { reportRestErrorResponse(response, HttpServletResponse.SC_NOT_IMPLEMENTED, null, "REST API is disabled on this TeamCity server with 'rest.disable' internal property.", Level.INFO, request); return null; } if (matches(WebUtil.getRequestUrl(request), myDisabledRequests.getParsedValues(TeamCityProperties.getProperty("rest.disable.requests")))) { reportRestErrorResponse(response, HttpServletResponse.SC_NOT_IMPLEMENTED, null, "Requests for URL \"" + WebUtil.getRequestUrl(request) + "\" are disabled in REST API on this server with 'rest.disable.requests' internal property.", Level.INFO, request); return null; } final long requestStartProcessing = System.nanoTime(); if (LOG.isDebugEnabled()) { LOG.debug("REST API request received: " + WebUtil.getRequestDump(request) + ", " + getPluginIdentifyingText()); } try { ensureInitialized(); } catch (Throwable throwable) { reportRestErrorResponse(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, throwable, "Error initializing REST API", Level.ERROR, request); return null; } boolean runAsSystem = false; if (TeamCityProperties.getBoolean("rest.use.authToken")) { String authToken = request.getParameter("authToken"); if (StringUtil.isNotEmpty(authToken) && StringUtil.isNotEmpty(getAuthToken())) { if (authToken.equals(getAuthToken())) { runAsSystem = true; } else { synchronized (this) { Thread.sleep(10000); //to prevent brute-forcing } reportRestErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, null, "Wrong authToken specified", Level.INFO, request); return null; } } } final boolean runAsSystemActual = runAsSystem; try { final boolean corsRequest = processCorsRequest(request, response); if (corsRequest && request.getMethod().equalsIgnoreCase("OPTIONS")){ //handling browser pre-flight requests LOG.debug("Pre-flight OPTIONS request detected, replying with status 204"); response.setStatus(204); return null; } if (myInternalAuthProcessing && SessionUser.getUser(request) == null && !requestForMyPathNotRequiringAuth(request)){ if (processRequestAuthentication(request, response, myAuthManager)){ return null; } if (SessionUser.getUser(request) == null){ response.setStatus(401); response.getWriter().write("TeamCity core was unable to handle authentication."); return null; } } // workaround for http://jetbrains.net/tracker/issue2/TW-7656 jetbrains.buildServer.util.Util.doUnderContextClassLoader(getClass().getClassLoader(), new FuncThrow() { public Void apply() throws Throwable { // patching request final HttpServletRequest actualRequest = new RequestWrapper(patchRequest(request, "Accept", "overrideAccept"), myRequestPathTransformInfo); if (runAsSystemActual) { LOG.debug("Executing request with system security level"); mySecurityContext.runAsSystem(new SecurityContextEx.RunAsAction() { public void run() throws Throwable { myWebComponent.doFilter(actualRequest, response, null); } }); } else { myWebComponent.doFilter(actualRequest, response, null); } return null; } }); } catch (Throwable throwable) { // Sometimes Jersey throws IllegalArgumentException and probably other without utilizing ExceptionMappers // forcing plain text error reporting reportRestErrorResponse(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, throwable, null, Level.WARN, request); } finally{ if (LOG.isDebugEnabled()) { final long requestFinishProcessing = System.nanoTime(); LOG.debug("REST API request processing finished in " + TimeUnit.MILLISECONDS.convert(requestFinishProcessing - requestStartProcessing, TimeUnit.NANOSECONDS) + " ms, status code: " + getStatus(response) + ", " + getPluginIdentifyingText()); } } return null; } private static boolean processRequestAuthentication(@NotNull final HttpServletRequest request, @NotNull final HttpServletResponse response, @NotNull final HttpAuthenticationManager authManager) throws IOException { boolean canRedirect = UserAgentUtil.isBrowser(request); final HttpAuthenticationResult authResult = authManager.processAuthenticationRequest(request, response, canRedirect); if (canRedirect) { final String redirectUrl = authResult.getRedirectUrl(); if (redirectUrl != null) { response.sendRedirect(redirectUrl); return true; } } if (authResult.getType() != HttpAuthenticationResult.Type.AUTHENTICATED) { authManager.processUnauthenticatedRequest(request, response, null, canRedirect); return true; } return false; } private boolean matches(final String requestURI, final String[] disabledRequests) { for (String requestPattern : disabledRequests) { if (requestURI.matches(requestPattern)){ return true; } } return false; } private String getStatus(final HttpServletResponse response) { String result = ""; try { result = String.valueOf(response.getStatus()); } catch (NoSuchMethodError e) { //ignore: this occurs for Servlet API < 3.0 } return result; } private boolean processCorsRequest(final HttpServletRequest request, final HttpServletResponse response) { final String origin = request.getHeader("Origin"); if (StringUtil.isNotEmpty(origin)) { final String[] originsArray = myAllowedOrigins.getParsedValues(TeamCityProperties.getProperty(REST_CORS_ORIGINS_INTERNAL_PROPERTY_NAME)); if (ArrayUtil.contains(origin, originsArray)) { addOriginHeaderToResponse(response, origin); addOtherHeadersToResponse(request, response); return true; } else if (ArrayUtil.contains("*", originsArray)) { LOG.debug("Got CORS request from origin '" + origin + "', but this origin is not allowed. However, '*' is. Replying with '*'." + " Add the origin to '" + REST_CORS_ORIGINS_INTERNAL_PROPERTY_NAME + "' internal property (comma-separated) to trust the applications hosted on the domain. Current allowed origins are: " + Arrays.toString(originsArray)); addOriginHeaderToResponse(response, "*"); return true; } else { LOG.debug("Got CORS request from origin '" + origin + "', but this origin is not allowed. Add the origin to '" + REST_CORS_ORIGINS_INTERNAL_PROPERTY_NAME + "' internal property (comma-separated) to trust the applications hosted on the domain. Current allowed origins are: " + Arrays.toString(originsArray)); } } return false; } private void addOriginHeaderToResponse(final HttpServletResponse response, final String origin) { response.addHeader("Access-Control-Allow-Origin", origin); } private void addOtherHeadersToResponse(final HttpServletRequest request, final HttpServletResponse response) { response.addHeader("Access-Control-Allow-Methods", request.getHeader("Access-Control-Request-Method")); response.addHeader("Access-Control-Allow-Credentials", "true"); //this will actually not function for OPTION request until http://youtrack.jetbrains.com/issue/TW-22019 is fixed response.addHeader("Access-Control-Allow-Headers", request.getHeader("Access-Control-Request-Headers")); } class CachingValuesFromInternalProperty{ private String myCachedValue; private String[] myParsedValues; @NotNull private synchronized String[] getParsedValues(@NotNull final String currentValue) { if (myCachedValue == null || !myCachedValue.equals(currentValue)) { myCachedValue = currentValue; myParsedValues = currentValue.split(","); for (int i = 0; i < myParsedValues.length; i++) { myParsedValues[i] = myParsedValues[i].trim(); } } return myParsedValues; } } public static void reportRestErrorResponse(@NotNull final HttpServletResponse response, final int statusCode, @Nullable final Throwable e, @Nullable final String message, final Level level, @NotNull final HttpServletRequest request) { final String responseText = ExceptionMapperUtil.getResponseTextAndLogRestErrorErrorMessage(statusCode, e, message, statusCode == HttpServletResponse.SC_INTERNAL_SERVER_ERROR, level, request); response.setStatus(statusCode); response.setContentType("text/plain"); try { response.getWriter().print(responseText); } catch (Throwable nestedException) { final String message1 = "Error while adding error description into response: " + nestedException.getMessage(); LOG.warn(message1); LOG.debug(message1, nestedException); } } //todo: move to RequestWrapper private HttpServletRequest patchRequest(final HttpServletRequest request, final String headerName, final String parameterName) { final String newValue = request.getParameter(parameterName); if (!StringUtil.isEmpty(newValue)) { return modifyRequestHeader(request, headerName, newValue); } return request; } private HttpServletRequest modifyRequestHeader(final HttpServletRequest request, final String headerName, final String newValue) { return new HttpServletRequestWrapper(request) { @Override public String getHeader(final String name) { if (headerName.equalsIgnoreCase(name)) { return newValue; } return super.getHeader(name); } @Override public Enumeration getHeaders(final String name) { if (headerName.equalsIgnoreCase(name)) { return Collections.enumeration(Collections.singletonList(newValue)); } return super.getHeaders(name); } }; } private void ensureInitialized() throws ServletException { //todo: check synchronization synchronized (this) { // workaround for http://jetbrains.net/tracker/issue2/TW-7656 if (myWebComponent == null) { final ClassLoader cl = Thread.currentThread().getContextClassLoader(); Thread.currentThread().setContextClassLoader(myClassloader); try { init(); } catch (RuntimeException e) { //otherwise exception here is swallowed and logged nowhere LOG.error("Error initializing REST API: ", e); myWebComponent = null; throw e; } catch (Error e) { LOG.error("Error initializing REST API: ", e); myWebComponent = null; throw e; } catch (ServletException e) { LOG.error("Error initializing REST API: ", e); myWebComponent = null; throw e; } finally { Thread.currentThread().setContextClassLoader(cl); } } } } private String getAuthToken() { return myAuthToken; } }