diff --git a/docs/release-notes.md b/docs/release-notes.md index 2771d1571e..b7dd50df97 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -13,7 +13,7 @@ -----------------------
New Features
-* +* [1172618](https://bugzilla.redhat.com/show_bug.cgi?id=1172618) - Allow anonymous pull from Zanata ---- diff --git a/zanata-war/src/main/java/org/zanata/limits/RateLimitManager.java b/zanata-war/src/main/java/org/zanata/limits/RateLimitManager.java index e6feae77a4..2c546c4be8 100644 --- a/zanata-war/src/main/java/org/zanata/limits/RateLimitManager.java +++ b/zanata-war/src/main/java/org/zanata/limits/RateLimitManager.java @@ -37,7 +37,7 @@ public class RateLimitManager implements Introspectable { public static final String INTROSPECTABLE_FIELD_RATE_LIMITERS = "RateLimiters"; - private final Cache activeCallers = CacheBuilder + private final Cache activeCallers = CacheBuilder .newBuilder().maximumSize(100).build(); @Getter(AccessLevel.PROTECTED) @@ -111,13 +111,13 @@ public String getFieldValueAsString(String fieldName) { } private Iterable peekCurrentBuckets() { - ConcurrentMap map = activeCallers.asMap(); + ConcurrentMap map = activeCallers.asMap(); return Iterables.transform(map.entrySet(), - new Function, String>() { + new Function, String>() { @Override public String - apply(Map.Entry input) { + apply(Map.Entry input) { RestCallLimiter rateLimiter = input.getValue(); return input.getKey() + ":" + rateLimiter; @@ -125,7 +125,10 @@ private Iterable peekCurrentBuckets() { }); } - public RestCallLimiter getLimiter(final String apiKey) { + /** + * @param key - {@link RateLimiterToken.TYPE ) + */ + public RestCallLimiter getLimiter(final RateLimiterToken key) { if (getMaxConcurrent() == 0 && getMaxActive() == 0) { if (activeCallers.size() > 0) { @@ -135,10 +138,10 @@ public RestCallLimiter getLimiter(final String apiKey) { return NoLimitLimiter.INSTANCE; } try { - return activeCallers.get(apiKey, new Callable() { + return activeCallers.get(key, new Callable() { @Override public RestCallLimiter call() throws Exception { - log.debug("creating rate limiter for api key: {}", apiKey); + log.debug("creating rate limiter for key: {}", key); return new RestCallLimiter(getMaxConcurrent(), getMaxActive()); } diff --git a/zanata-war/src/main/java/org/zanata/limits/RateLimiterToken.java b/zanata-war/src/main/java/org/zanata/limits/RateLimiterToken.java new file mode 100644 index 0000000000..001d2060e2 --- /dev/null +++ b/zanata-war/src/main/java/org/zanata/limits/RateLimiterToken.java @@ -0,0 +1,72 @@ +/* + * Copyright 2015, Red Hat, Inc. and individual contributors as indicated by the + * @author tags. See the copyright.txt file in the distribution for a full + * listing of individual contributors. + * + * This is free software; you can redistribute it and/or modify it under the + * terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This software is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this software; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA, or see the FSF + * site: http://www.fsf.org. + */ + +package org.zanata.limits; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +/** + * Token used for rate limiter queue. + * + * @author Alex Eng aeng@redhat.com + */ +@EqualsAndHashCode +@ToString +public class RateLimiterToken { + + @Getter + private final String value; + + @Getter + private final TYPE type; + + public static enum TYPE { + USERNAME, API_KEY, IP_ADDRESS; + } + + public RateLimiterToken(TYPE type, String value) { + this.type = type; + this.value = value; + } + + /** + * Generate key from username + */ + public static RateLimiterToken fromUsername(String username) { + return new RateLimiterToken(TYPE.USERNAME, username); + } + + /** + * Generate key from api key + */ + public static RateLimiterToken fromApiKey(String apiKey) { + return new RateLimiterToken(TYPE.API_KEY, apiKey); + } + + /** + * Generate key from ip address + */ + public static RateLimiterToken fromIPAddress(String ipAddress) { + return new RateLimiterToken(TYPE.IP_ADDRESS, ipAddress); + } +} diff --git a/zanata-war/src/main/java/org/zanata/limits/RateLimitingProcessor.java b/zanata-war/src/main/java/org/zanata/limits/RateLimitingProcessor.java index 88d72ac6a4..f199ff7ac0 100644 --- a/zanata-war/src/main/java/org/zanata/limits/RateLimitingProcessor.java +++ b/zanata-war/src/main/java/org/zanata/limits/RateLimitingProcessor.java @@ -31,13 +31,23 @@ public RateLimitingProcessor() { private final LeakyBucket logLimiter = new LeakyBucket(1, 5, TimeUnit.MINUTES); - public void processApiKey(String apiKey, HttpResponse response, - Runnable taskToRun) throws Exception { - process(apiKey, response, taskToRun); + public void processForApiKey(String apiKey, HttpResponse response, + Runnable taskToRun) throws Exception { + process(RateLimiterToken.fromApiKey(apiKey), response, taskToRun); } - private void process(String key, HttpResponse response, Runnable taskToRun) - throws IOException { + public void processForUser(String username, HttpResponse response, + Runnable taskToRun) throws IOException { + process(RateLimiterToken.fromUsername(username), response, taskToRun); + } + + public void processForAnonymousIP(String ip, HttpResponse response, + Runnable taskToRun) throws IOException { + process(RateLimiterToken.fromIPAddress(ip), response, taskToRun); + } + + private void process(RateLimiterToken key, HttpResponse response, + Runnable taskToRun) throws IOException { RestCallLimiter rateLimiter = rateLimitManager.getLimiter(key); log.debug("check semaphore for {}", this); @@ -48,16 +58,20 @@ private void process(String key, HttpResponse response, Runnable taskToRun) "{} has too many concurrent requests. Returning status 429", key); } - String errorMessage = + + String errorMessage; + if(key.getType().equals(RateLimiterToken.TYPE.API_KEY)) { + errorMessage = String.format( - "Too many concurrent requests for this user (maximum is %d)", - rateLimiter.getMaxConcurrentPermits()); + "Too many concurrent requests for client API key (maximum is %d)", + rateLimiter.getMaxConcurrentPermits()); + } else { + errorMessage = + String.format( + "Too many concurrent requests for client '%s' (maximum is %d)", + key.getValue(), rateLimiter.getMaxConcurrentPermits()); + } response.sendError(TOO_MANY_REQUEST, errorMessage); } } - - public void processUsername(String username, HttpResponse response, - Runnable taskToRun) throws IOException { - process(username, response, taskToRun); - } } diff --git a/zanata-war/src/main/java/org/zanata/rest/HeaderHelper.java b/zanata-war/src/main/java/org/zanata/rest/HeaderHelper.java deleted file mode 100644 index b11dd43489..0000000000 --- a/zanata-war/src/main/java/org/zanata/rest/HeaderHelper.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.zanata.rest; - -import org.jboss.resteasy.spi.HttpRequest; - -/** - * @author Patrick Huang - * pahuang@redhat.com - */ -public final class HeaderHelper { - private HeaderHelper() { - } - - public static final String X_AUTH_TOKEN_HEADER = "X-Auth-Token"; - public static final String X_AUTH_USER_HEADER = "X-Auth-User"; - - protected static String getApiKey(HttpRequest request) { - return request.getHttpHeaders().getRequestHeaders() - .getFirst(X_AUTH_TOKEN_HEADER); - } - - protected static String getUserName(HttpRequest request) { - return request.getHttpHeaders().getRequestHeaders() - .getFirst(X_AUTH_USER_HEADER); - } -} diff --git a/zanata-war/src/main/java/org/zanata/rest/InvalidApiKeyUtil.java b/zanata-war/src/main/java/org/zanata/rest/InvalidApiKeyUtil.java new file mode 100644 index 0000000000..db60fea90d --- /dev/null +++ b/zanata-war/src/main/java/org/zanata/rest/InvalidApiKeyUtil.java @@ -0,0 +1,31 @@ +package org.zanata.rest; + +/** + * Utility for invalid API key exception. + * + * @author Alex Eng aeng@redhat.com + */ +public class InvalidApiKeyUtil { + public static final String message = "Invalid API key"; + + public static String getMessage(String username, String apiKey, + String additionalMessage) { + StringBuilder sb = new StringBuilder(); + sb.append(getMessage(username, apiKey)) + .append(" ").append(additionalMessage); + return sb.toString(); + } + + public static String getMessage(String username, String apiKey) { + StringBuilder sb = new StringBuilder(); + sb.append(message).append(" for user: [").append(username).append("]") + .append(" apiKey: [").append(apiKey).append("]."); + return sb.toString(); + } + + public static String getMessage(String additionalMessage) { + StringBuilder sb = new StringBuilder(); + sb.append(message).append(". ").append(additionalMessage); + return sb.toString(); + } +} diff --git a/zanata-war/src/main/java/org/zanata/rest/RestLimitingSynchronousDispatcher.java b/zanata-war/src/main/java/org/zanata/rest/RestLimitingSynchronousDispatcher.java index ccf515d457..3624f11c9a 100644 --- a/zanata-war/src/main/java/org/zanata/rest/RestLimitingSynchronousDispatcher.java +++ b/zanata-war/src/main/java/org/zanata/rest/RestLimitingSynchronousDispatcher.java @@ -1,8 +1,31 @@ +/* + * Copyright 2015, Red Hat, Inc. and individual contributors as indicated by the + * @author tags. See the copyright.txt file in the distribution for a full + * listing of individual contributors. + * + * This is free software; you can redistribute it and/or modify it under the + * terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This software is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this software; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA, or see the FSF + * site: http://www.fsf.org. + */ package org.zanata.rest; import java.io.IOException; +import javax.annotation.Nonnull; +import javax.servlet.http.HttpServletRequest; import javax.ws.rs.core.Response; +import org.apache.commons.lang.StringUtils; import org.jboss.resteasy.core.SynchronousDispatcher; import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpResponse; @@ -10,12 +33,17 @@ import org.jboss.resteasy.spi.UnhandledException; import org.jboss.seam.resteasy.SeamResteasyProviderFactory; import org.jboss.seam.security.management.JpaIdentityStore; +import org.jboss.seam.web.ServletContexts; +import org.zanata.dao.AccountDAO; import org.zanata.limits.RateLimitingProcessor; import org.zanata.model.HAccount; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.base.Throwables; import lombok.extern.slf4j.Slf4j; + +import org.zanata.security.SecurityFunctions; +import org.zanata.util.HttpUtil; import org.zanata.util.ServiceLocator; /** @@ -45,18 +73,43 @@ public RestLimitingSynchronousDispatcher( this.processor = processor; } + HttpServletRequest getServletRequest() { + return ServletContexts.instance().getRequest(); + } + @Override public void invoke(final HttpRequest request, final HttpResponse response) { + /** + * This is only non-null if request came from same browser which + * user used to logged into Zanata. + */ HAccount authenticatedUser = getAuthenticatedUser(); - String apiKey = HeaderHelper.getApiKey(request); + + /** + * If apiKey is empty, request is from anonymous user, + * If apiKey is not empty, it must be an authenticated + * user from pre-process in ZanataRestSecurityInterceptor. + */ + String apiKey = HttpUtil.getApiKey(request); try { - // we are not validating api key but will rate limit any api key - if (authenticatedUser == null && Strings.isNullOrEmpty(apiKey)) { - response.sendError( - Response.Status.UNAUTHORIZED.getStatusCode(), - API_KEY_ABSENCE_WARNING); + // Get user account with apiKey if request is from client + if(authenticatedUser == null && StringUtils.isNotEmpty(apiKey)) { + authenticatedUser = getUser(apiKey); + } + + if(!SecurityFunctions.canAccessRestPath(authenticatedUser, + request.getHttpMethod(), request.getPreprocessedPath())) { + + /** + * Not using response.sendError because the app server will generate + * an HTML page which includes the message. We want to return + * the message string as is. + */ + response.setStatus(Response.Status.UNAUTHORIZED.getStatusCode()); + response.getOutputStream().write(InvalidApiKeyUtil.getMessage( + API_KEY_ABSENCE_WARNING).getBytes()); return; } @@ -69,16 +122,25 @@ public void run() { } }; - if (authenticatedUser == null) { - processor.processApiKey(apiKey, response, taskToRun); - } else if (!Strings.isNullOrEmpty(authenticatedUser.getApiKey())) { - processor.processApiKey(authenticatedUser.getApiKey(), - response, taskToRun); + //authenticatedUser can be from browser or client request + if(authenticatedUser == null) { + /** + * Process anonymous request for rate limiting + * Note: clientIP might be a proxy server IP address, due to + * different implementation of each proxy server. This will put + * all the requests from same proxy server into a single queue. + */ + String clientIP = HttpUtil.getClientIp(getServletRequest()); + processor.processForAnonymousIP(clientIP, response, taskToRun); } else { - processor.processUsername(authenticatedUser.getUsername(), + if (!Strings.isNullOrEmpty(authenticatedUser.getApiKey())) { + processor.processForApiKey(authenticatedUser.getApiKey(), + response, taskToRun); + } else { + processor.processForUser(authenticatedUser.getUsername(), response, taskToRun); + } } - } catch (UnhandledException e) { Throwable cause = e.getCause(); log.error("Failed to process REST request", cause); @@ -108,4 +170,9 @@ protected HAccount getAuthenticatedUser() { return ServiceLocator.instance().getInstance( JpaIdentityStore.AUTHENTICATED_USER, HAccount.class); } + + protected HAccount getUser(@Nonnull String apiKey) { + return ServiceLocator.instance().getInstance(AccountDAO.class) + .getByApiKey(apiKey); + } } diff --git a/zanata-war/src/main/java/org/zanata/rest/ZanataRestSecurityInterceptor.java b/zanata-war/src/main/java/org/zanata/rest/ZanataRestSecurityInterceptor.java index 99b92bc365..65d7575457 100644 --- a/zanata-war/src/main/java/org/zanata/rest/ZanataRestSecurityInterceptor.java +++ b/zanata-war/src/main/java/org/zanata/rest/ZanataRestSecurityInterceptor.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.StringUtils; import org.jboss.resteasy.annotations.interception.SecurityPrecedence; import org.jboss.resteasy.annotations.interception.ServerInterceptor; import org.jboss.resteasy.core.ResourceMethod; @@ -13,7 +14,9 @@ import org.jboss.resteasy.spi.Failure; import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.interception.PreProcessInterceptor; +import org.zanata.security.SecurityFunctions; import org.zanata.security.ZanataIdentity; +import org.zanata.util.HttpUtil; @SecurityPrecedence @ServerInterceptor @@ -25,21 +28,19 @@ public class ZanataRestSecurityInterceptor implements PreProcessInterceptor { preProcess(HttpRequest request, ResourceMethod method) throws Failure, WebApplicationException { - String username = - HeaderHelper.getUserName(request); - String apiKey = - HeaderHelper.getApiKey(request); - - if (username != null && apiKey != null) { + String username = HttpUtil.getUsername(request); + String apiKey = HttpUtil.getApiKey(request); + if (StringUtils.isNotEmpty(username)|| StringUtils.isNotEmpty(apiKey)) { ZanataIdentity.instance().getCredentials().setUsername(username); ZanataIdentity.instance().setApiKey(apiKey); ZanataIdentity.instance().tryLogin(); - if (!ZanataIdentity.instance().isLoggedIn()) { - log.info( - "Failed attempt to authenticate REST request for user {}", - username); + if (!SecurityFunctions.canAccessRestPath(ZanataIdentity.instance(), + request.getHttpMethod(), request.getPreprocessedPath())) { + log.info(InvalidApiKeyUtil.getMessage(username, apiKey)); return ServerResponse.copyIfNotServerResponse(Response.status( - Status.UNAUTHORIZED).build()); + Status.UNAUTHORIZED).entity( + InvalidApiKeyUtil.getMessage(username, apiKey)) + .build()); } } return null; diff --git a/zanata-war/src/main/java/org/zanata/rest/service/TranslationMemoryResourceService.java b/zanata-war/src/main/java/org/zanata/rest/service/TranslationMemoryResourceService.java index f3352a25c1..f973916d94 100644 --- a/zanata-war/src/main/java/org/zanata/rest/service/TranslationMemoryResourceService.java +++ b/zanata-war/src/main/java/org/zanata/rest/service/TranslationMemoryResourceService.java @@ -100,6 +100,7 @@ public Response getAllTranslationMemory(@Nullable LocaleId locale) { } @Override + @Restrict("#{s:hasPermission('', 'download-tmx')}") public Response getProjectTranslationMemory(@Nonnull String projectSlug, @Nullable LocaleId locale) { log.debug("exporting TMX for project {}, locale {}", projectSlug, @@ -118,6 +119,7 @@ public Response getProjectTranslationMemory(@Nonnull String projectSlug, } @Override + @Restrict("#{s:hasPermission('', 'download-tmx')}") public Response getProjectIterationTranslationMemory( @Nonnull String projectSlug, @Nonnull String iterationSlug, @Nullable LocaleId locale) { diff --git a/zanata-war/src/main/java/org/zanata/security/SecurityFunctions.java b/zanata-war/src/main/java/org/zanata/security/SecurityFunctions.java index e6568cf2fb..1ed7181b42 100644 --- a/zanata-war/src/main/java/org/zanata/security/SecurityFunctions.java +++ b/zanata-war/src/main/java/org/zanata/security/SecurityFunctions.java @@ -29,18 +29,24 @@ import org.zanata.model.HIterationGroup; import org.zanata.model.HLocale; import org.zanata.model.HLocaleMember; -import org.zanata.model.HPerson; import org.zanata.model.HProject; import org.zanata.model.HProjectIteration; import org.zanata.security.permission.GrantsPermission; +import org.zanata.util.HttpUtil; import org.zanata.util.ServiceLocator; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import lombok.extern.slf4j.Slf4j; + /** * Contains static helper functions used inside the rules files. * * @author Carlos Munoz camunoz@redhat.com */ +@Slf4j public class SecurityFunctions { protected SecurityFunctions() { } @@ -389,4 +395,75 @@ private static final T extractTarget(Object[] array, Class type) { } return null; } + + /***************************************************************************************** + * TMX rules + ******************************************************************************************/ + + @GrantsPermission(actions = "download-tmx") + public static boolean canDownloadTMX() { + Optional account = getAuthenticatedAccount(); + return account.isPresent(); + } + + + /***************************************************************************************** + * HTTP request rules + ******************************************************************************************/ + + /** + * Check if user can access to REST URL with httpMethod. + * 1) Check if request can communicate to with rest service path, + * 2) then check if request can perform the specific API action. + * + * If request is from anonymous user(account == null), + * only 'Read' action are allowed. Additionally, role-based check will be + * performed in the REST service class. + * + * This rule apply to all REST endpoint. + * + * @param account - Authenticated account + * @param httpMethod - {@link javax.ws.rs.HttpMethod} + * @param restServicePath - service path of rest request. + * See annotation @Path in REST service class. + */ + public static final boolean canAccessRestPath(@Nullable HAccount account, + String httpMethod, String restServicePath) { + //This is to allow data injection for function-test/rest-test + if(isTestServicePath(restServicePath)) { + log.debug("Allow rest access for Zanata test"); + return true; + } + if (account != null) { + return true; + } + if (HttpUtil.isReadMethod(httpMethod)) { + return true; + } + return false; + } + + public static final boolean canAccessRestPath( + @Nonnull ZanataIdentity identity, + String httpMethod, String restServicePath) { + // This is to allow data injection for function-test/rest-test + if (isTestServicePath(restServicePath)) { + log.debug("Allow rest access for Zanata test"); + return true; + } + if (identity.isLoggedIn()) { + return true; + } + return false; + } + + /** + * Check if request path are from functional test or RestTest + * + * @param servicePath - service path of rest request. + * See annotation @Path in REST service class. + */ + private static boolean isTestServicePath(String servicePath) { + return servicePath != null && servicePath.startsWith("/test"); + } } diff --git a/zanata-war/src/main/java/org/zanata/util/HttpUtil.java b/zanata-war/src/main/java/org/zanata/util/HttpUtil.java new file mode 100644 index 0000000000..63a3608291 --- /dev/null +++ b/zanata-war/src/main/java/org/zanata/util/HttpUtil.java @@ -0,0 +1,122 @@ +/* + * Copyright 2015, Red Hat, Inc. and individual contributors as indicated by the + * @author tags. See the copyright.txt file in the distribution for a full + * listing of individual contributors. + * + * This is free software; you can redistribute it and/or modify it under the + * terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This software is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this software; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA, or see the FSF + * site: http://www.fsf.org. + */ +package org.zanata.util; + +import java.util.List; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.HttpMethod; + +import org.apache.commons.lang.StringUtils; +import org.jboss.resteasy.spi.HttpRequest; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Lists; + +/** + * Utility class for HTTP related methods. + * + * @author Patrick Huang + * pahuang@redhat.com + */ +public final class HttpUtil { + + private final static List HTTP_REQUEST_READ_METHODS = Lists.newArrayList( + HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); + + public static final String X_AUTH_TOKEN_HEADER = "X-Auth-Token"; + public static final String X_AUTH_USER_HEADER = "X-Auth-User"; + + /** + * This should be set by admin. + * Example header names might be "X-Forwarded-For", "Proxy-Client-IP", + * "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR" + */ + public static String PROXY_HEADER = System + .getProperty("ZANATA_PROXY_HEADER"); + + public static String getApiKey(HttpRequest request) { + return request.getHttpHeaders().getRequestHeaders() + .getFirst(X_AUTH_TOKEN_HEADER); + } + + @VisibleForTesting + static void refreshProxyHeader() { + PROXY_HEADER = System.getProperty("ZANATA_PROXY_HEADER"); + } + + public static String getUsername(HttpRequest request) { + return request.getHttpHeaders().getRequestHeaders() + .getFirst(X_AUTH_USER_HEADER); + } + + /** + * Return client ip address according to HttpServletRequest. + * + * This will also check for the possibility of client behind proxy + * before returning default remote address in request. + * + * NOTE: Not all proxy server include client ip information in http header + * and different proxy MIGHT use different http header for such information. + * Default remote address in request will be returned if client information + * is not found in header. + * + * see http://stackoverflow.com/questions/4678797/how-do-i-get-the-remote-address-of-a-client-in-servlet + * @param request + */ + public static String getClientIp(HttpServletRequest request) { + String ip; + + if(StringUtils.isEmpty(PROXY_HEADER)) { + return request.getRemoteAddr(); + } + + // PROXY_HEADER can be list of ip address + String[] ipList = + StringUtils.split(request.getHeader(PROXY_HEADER), ","); + + if(ipList.length == 1) { + return ipList[0]; + } + + //return last ip address from list if found + ip = ipList[ipList.length-1]; + if(!isIpUnknown(ip)) { + return ip; + } + + return request.getRemoteAddr(); + } + + private static boolean isIpUnknown(String ip) { + return StringUtils.isEmpty(ip) || StringUtils.equalsIgnoreCase(ip, + "unknown") || StringUtils.equalsIgnoreCase(ip, "localhost") || + StringUtils.equals(ip, "127.0.0.1"); + } + + public static boolean isReadMethod(String httpMethod) { + for(String readMethod: HTTP_REQUEST_READ_METHODS) { + if(readMethod.equalsIgnoreCase(httpMethod)) { + return true; + } + } + return false; + } +} diff --git a/zanata-war/src/test/java/org/zanata/RestTest.java b/zanata-war/src/test/java/org/zanata/RestTest.java index f680f8714e..788deff06e 100644 --- a/zanata-war/src/test/java/org/zanata/RestTest.java +++ b/zanata-war/src/test/java/org/zanata/RestTest.java @@ -35,7 +35,6 @@ import org.jboss.arquillian.junit.Arquillian; import org.jboss.arquillian.test.api.ArquillianResource; import org.jboss.resteasy.client.ClientRequest; -import org.jboss.resteasy.client.ProxyFactory; import org.jboss.seam.util.Naming; import org.junit.After; import org.junit.Before; @@ -46,7 +45,6 @@ import org.zanata.rest.ResourceRequestEnvironment; import org.zanata.rest.client.ZanataProxyFactory; import org.zanata.rest.dto.VersionInfo; -import org.zanata.rest.helper.RemoteTestSignaler; /** * Provides basic test utilities to test raw REST APIs and compatibility. @@ -208,6 +206,18 @@ public static final ResourceRequestEnvironment getAuthorizedEnvironment() { return ENV_AUTHORIZED; } + /** + * Gets an empty header for REST request. + */ + public static final ResourceRequestEnvironment getEmptyHeaderEnvironment() { + return new ResourceRequestEnvironment() { + @Override + public Map getDefaultHeaders() { + return new HashMap(); + } + }; + } + /** * Creates and returns a new instance of a proxy factory for the given * credentials. This method aids with the testing of Rest API classes. diff --git a/zanata-war/src/test/java/org/zanata/limits/RateLimitingProcessorTest.java b/zanata-war/src/test/java/org/zanata/limits/RateLimitingProcessorTest.java index 37bd3ed692..5b1f0adb5d 100644 --- a/zanata-war/src/test/java/org/zanata/limits/RateLimitingProcessorTest.java +++ b/zanata-war/src/test/java/org/zanata/limits/RateLimitingProcessorTest.java @@ -41,9 +41,10 @@ public void beforeMethod() throws IOException { public void restCallLimiterReturnsFalseWillCauseErrorResponse() throws Exception { when(restCallLimiter.tryAcquireAndRun(runnable)).thenReturn(false); - doReturn(restCallLimiter).when(rateLimitManager).getLimiter(API_KEY); + doReturn(restCallLimiter).when(rateLimitManager).getLimiter( + RateLimiterToken.fromApiKey(API_KEY)); - processor.processApiKey(API_KEY, response, runnable); + processor.processForApiKey(API_KEY, response, runnable); verify(restCallLimiter).tryAcquireAndRun(runnable); verify(response).sendError(eq(429), anyString()); @@ -53,9 +54,10 @@ public void restCallLimiterReturnsFalseWillCauseErrorResponse() public void restCallLimiterReturnsTrueWillNotReturnErrorResponse() throws Exception { when(restCallLimiter.tryAcquireAndRun(runnable)).thenReturn(true); - doReturn(restCallLimiter).when(rateLimitManager).getLimiter(API_KEY); + doReturn(restCallLimiter).when(rateLimitManager).getLimiter( + RateLimiterToken.fromApiKey(API_KEY)); - processor.processApiKey(API_KEY, response, runnable); + processor.processForApiKey(API_KEY, response, runnable); verify(restCallLimiter).tryAcquireAndRun(runnable); verifyZeroInteractions(response); diff --git a/zanata-war/src/test/java/org/zanata/rest/ResourceRequestEnvironment.java b/zanata-war/src/test/java/org/zanata/rest/ResourceRequestEnvironment.java index c68c82685d..615c2bd5fa 100644 --- a/zanata-war/src/test/java/org/zanata/rest/ResourceRequestEnvironment.java +++ b/zanata-war/src/test/java/org/zanata/rest/ResourceRequestEnvironment.java @@ -42,7 +42,6 @@ public class ResourceRequestEnvironment { */ public Map getDefaultHeaders() { Map map = Maps.newHashMap(); - map.put("X-Auth-Token", "abc123"); return map; } } diff --git a/zanata-war/src/test/java/org/zanata/rest/RestLimitingSynchronousDispatcherTest.java b/zanata-war/src/test/java/org/zanata/rest/RestLimitingSynchronousDispatcherTest.java index f60a156264..d50f55259b 100644 --- a/zanata-war/src/test/java/org/zanata/rest/RestLimitingSynchronousDispatcherTest.java +++ b/zanata-war/src/test/java/org/zanata/rest/RestLimitingSynchronousDispatcherTest.java @@ -2,6 +2,7 @@ import java.io.IOException; import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; import javax.ws.rs.core.MultivaluedMap; import org.jboss.resteasy.core.ResourceInvoker; @@ -17,8 +18,10 @@ import org.testng.annotations.Test; import org.zanata.limits.RateLimitingProcessor; import org.zanata.model.HAccount; +import org.zanata.util.HttpUtil; import static org.mockito.Mockito.*; +import static org.mockito.Mockito.doReturn; /** * @author Patrick Huang headers; private HAccount authenticatedUser; + @Mock + private HttpServletRequest servletRequest; + + private String clienIP = "255.255.255.0.1"; @BeforeMethod public void beforeMethod() throws ServletException, IOException { MockitoAnnotations.initMocks(this); - - when(request.getHttpHeaders().getRequestHeaders()).thenReturn(headers); + when(request.getHttpHeaders().getRequestHeaders()) + .thenReturn(headers); when(request.getHttpMethod()).thenReturn("GET"); - when(headers.getFirst(HeaderHelper.X_AUTH_TOKEN_HEADER)).thenReturn( - API_KEY); + when(headers.getFirst(HttpUtil.X_AUTH_TOKEN_HEADER)).thenReturn( + API_KEY); dispatcher = spy(new RestLimitingSynchronousDispatcher(providerFactory, processor)); // this way we can verify the task actually called super.invoke() + doReturn(servletRequest).when(dispatcher).getServletRequest(); doReturn(superInvoker).when(dispatcher).getInvoker(request); doNothing().when(dispatcher).invoke(request, response, superInvoker); authenticatedUser = null; @@ -67,56 +75,59 @@ public void beforeMethod() throws ServletException, IOException { } @Test - public void willSkipIfAPIkeyNotPresent() throws IOException, - ServletException { - when(headers.getFirst(HeaderHelper.X_AUTH_TOKEN_HEADER)).thenReturn( - null); - when(request.getUri().getPath()).thenReturn("/rest/in/peace"); - doReturn(null).when(dispatcher).getAuthenticatedUser(); + public void willUseAuthenticatedUserApiKeyIfPresent() throws Exception { + authenticatedUser = new HAccount(); + authenticatedUser.setApiKey("apiKeyInAuth"); + doReturn(authenticatedUser).when(dispatcher).getAuthenticatedUser(); dispatcher.invoke(request, response); - verify(response).sendError(401, - RestLimitingSynchronousDispatcher.API_KEY_ABSENCE_WARNING); - verifyZeroInteractions(processor); + verify(processor).processForApiKey(same("apiKeyInAuth"), same(response), + taskCaptor.capture()); } @Test - public void willCallRateLimitingProcessorIfAllConditionsAreMet() - throws Exception { - dispatcher.invoke(request, response); - - verify(processor).processApiKey(same(API_KEY), same(response), - taskCaptor.capture()); + public void willUseUsernameIfNoApiKeyButAuthenticated() throws Exception { + authenticatedUser = new HAccount(); + authenticatedUser.setUsername("admin"); + doReturn(authenticatedUser).when(dispatcher).getAuthenticatedUser(); - // verify task is calling super.invoke - Runnable task = taskCaptor.getValue(); - task.run(); - verify(dispatcher).getInvoker(request); + dispatcher.invoke(request, response); + verify(processor).processForUser(same("admin"), same(response), + taskCaptor.capture()); } @Test - public void willUseAuthenticatedUserApiKeyIfPresent() throws Exception { - authenticatedUser = new HAccount(); - authenticatedUser.setApiKey("apiKeyInAuth"); - doReturn(authenticatedUser).when(dispatcher).getAuthenticatedUser(); + public void willThrowErrorWithPOSTAndNoApiKey() throws Exception { + when(request.getHttpMethod()).thenReturn("POST"); + when(headers.getFirst(HttpUtil.X_AUTH_TOKEN_HEADER)).thenReturn( + null); + when(request.getUri().getPath()).thenReturn("/rest/in/peace"); + doReturn(null).when(dispatcher).getAuthenticatedUser(); dispatcher.invoke(request, response); - verify(processor).processApiKey(same("apiKeyInAuth"), same(response), - taskCaptor.capture()); + verify(response).setStatus(401); + verify(response).getOutputStream(); + verifyZeroInteractions(processor); } @Test - public void willUserUsernameIfNoApiKeyButAuthenticated() throws Exception { - authenticatedUser = new HAccount(); - authenticatedUser.setUsername("admin"); - doReturn(authenticatedUser).when(dispatcher).getAuthenticatedUser(); + public void willProcessAnonymousWithGETAndNoApiKey() throws Exception { + when(headers.getFirst(HttpUtil.X_AUTH_TOKEN_HEADER)).thenReturn(null); + when(request.getUri().getPath()).thenReturn("/rest/in/peace"); + when(servletRequest.getRemoteAddr()).thenReturn(clienIP); + doReturn(null).when(dispatcher).getAuthenticatedUser(); dispatcher.invoke(request, response); - verify(processor).processUsername(same("admin"), same(response), - taskCaptor.capture()); + verify(processor).processForAnonymousIP(same(clienIP), same(response), + taskCaptor.capture()); + + // verify task is calling super.invoke + Runnable task = taskCaptor.getValue(); + task.run(); + verify(dispatcher).getInvoker(request); } } diff --git a/zanata-war/src/test/java/org/zanata/rest/compat/ProjectIterationCompatibilityITCase.java b/zanata-war/src/test/java/org/zanata/rest/compat/ProjectIterationCompatibilityITCase.java index 866e42ad24..2dd8569cd1 100644 --- a/zanata-war/src/test/java/org/zanata/rest/compat/ProjectIterationCompatibilityITCase.java +++ b/zanata-war/src/test/java/org/zanata/rest/compat/ProjectIterationCompatibilityITCase.java @@ -38,6 +38,9 @@ public class ProjectIterationCompatibilityITCase extends RestTest { @Override protected void prepareDBUnitOperations() { + addBeforeTestOperation(new DataSetOperation( + "org/zanata/test/model/AccountData.dbunit.xml", + DatabaseOperation.CLEAN_INSERT)); addBeforeTestOperation(new DataSetOperation( "org/zanata/test/model/ProjectsData.dbunit.xml", DatabaseOperation.CLEAN_INSERT)); diff --git a/zanata-war/src/test/java/org/zanata/rest/compat/ProjectIterationRawCompatibilityITCase.java b/zanata-war/src/test/java/org/zanata/rest/compat/ProjectIterationRawCompatibilityITCase.java index 211c848426..d3dad379ac 100644 --- a/zanata-war/src/test/java/org/zanata/rest/compat/ProjectIterationRawCompatibilityITCase.java +++ b/zanata-war/src/test/java/org/zanata/rest/compat/ProjectIterationRawCompatibilityITCase.java @@ -45,6 +45,9 @@ public class ProjectIterationRawCompatibilityITCase extends RestTest { @Override protected void prepareDBUnitOperations() { + addBeforeTestOperation(new DataSetOperation( + "org/zanata/test/model/AccountData.dbunit.xml", + DatabaseOperation.CLEAN_INSERT)); addBeforeTestOperation(new DataSetOperation( "org/zanata/test/model/ProjectsData.dbunit.xml", DatabaseOperation.CLEAN_INSERT)); diff --git a/zanata-war/src/test/java/org/zanata/rest/compat/ProjectRawCompatibilityITCase.java b/zanata-war/src/test/java/org/zanata/rest/compat/ProjectRawCompatibilityITCase.java index 424a758c21..dd21396a97 100644 --- a/zanata-war/src/test/java/org/zanata/rest/compat/ProjectRawCompatibilityITCase.java +++ b/zanata-war/src/test/java/org/zanata/rest/compat/ProjectRawCompatibilityITCase.java @@ -66,6 +66,9 @@ public class ProjectRawCompatibilityITCase extends RestTest { @Override protected void prepareDBUnitOperations() { + addBeforeTestOperation(new DataSetOperation( + "org/zanata/test/model/AccountData.dbunit.xml", + DatabaseOperation.CLEAN_INSERT)); addBeforeTestOperation(new DataSetOperation( "org/zanata/test/model/ProjectsData.dbunit.xml", DatabaseOperation.CLEAN_INSERT)); diff --git a/zanata-war/src/test/java/org/zanata/rest/service/raw/AnonymousUserRawRestITCase.java b/zanata-war/src/test/java/org/zanata/rest/service/raw/AnonymousUserRawRestITCase.java new file mode 100644 index 0000000000..7f24569fdb --- /dev/null +++ b/zanata-war/src/test/java/org/zanata/rest/service/raw/AnonymousUserRawRestITCase.java @@ -0,0 +1,160 @@ +/* + * Copyright 2015, Red Hat, Inc. and individual contributors as indicated by the + * @author tags. See the copyright.txt file in the distribution for a full + * listing of individual contributors. + * + * This is free software; you can redistribute it and/or modify it under the + * terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This software is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this software; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA, or see the FSF + * site: http://www.fsf.org. + */ + +package org.zanata.rest.service.raw; + +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.zanata.provider.DBUnitProvider.DataSetOperation; + +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response.Status; + +import org.dbunit.operation.DatabaseOperation; +import org.jboss.arquillian.container.test.api.RunAsClient; +import org.jboss.resteasy.client.ClientRequest; +import org.jboss.resteasy.client.ClientResponse; +import org.junit.Test; +import org.zanata.RestTest; +import org.zanata.rest.InvalidApiKeyUtil; +import org.zanata.rest.MediaTypes; +import org.zanata.rest.ResourceRequest; +import org.zanata.rest.ResourceRequestEnvironment; + +public class AnonymousUserRawRestITCase extends RestTest { + + private final String invalidAPI = "InvalidAPIKEY"; + + //NOTE: keep in sync with RestLimitingSynchronousDispatcher.API_KEY_ABSENCE_WARNING + private final String API_KEY_ABSENCE_WARNING = + "You must have a valid API key. You can create one by logging in to Zanata and visiting the settings page."; + + @Override + protected void prepareDBUnitOperations() { + addBeforeTestOperation(new DataSetOperation( + "org/zanata/test/model/ClearAllTables.dbunit.xml", + DatabaseOperation.DELETE_ALL)); + addBeforeTestOperation(new DataSetOperation( + "org/zanata/test/model/AccountData.dbunit.xml", + DatabaseOperation.CLEAN_INSERT)); + addBeforeTestOperation(new DataSetOperation( + "org/zanata/test/model/ProjectsData.dbunit.xml", + DatabaseOperation.CLEAN_INSERT)); + } + + @Test + @RunAsClient + public void doGETProjectsWithWrongAPI() throws Exception { + new ResourceRequest(getRestEndpointUrl("/projects"), "GET", + getUnAuthorizedEnvironment()) { + @Override + protected void prepareRequest(ClientRequest request) { + request.header(HttpHeaders.ACCEPT, + MediaTypes.APPLICATION_ZANATA_PROJECTS_XML); + } + + @Override + protected void onResponse(ClientResponse response) { + assertThat(response.getEntity(String.class).toString(), + is(InvalidApiKeyUtil.getMessage(ADMIN, invalidAPI))); + + assertThat(response.getStatus(), + is(Status.UNAUTHORIZED.getStatusCode())); + } + }.run(); + } + + @Test + @RunAsClient + public void doGETProjectsWithCorrectAPI() throws Exception { + new ResourceRequest(getRestEndpointUrl("/projects"), "GET", + getAuthorizedEnvironment()) { + @Override + protected void prepareRequest(ClientRequest request) { + request.header(HttpHeaders.ACCEPT, + MediaTypes.APPLICATION_ZANATA_PROJECTS_XML); + } + + @Override + protected void onResponse(ClientResponse response) { + assertThat(response.getStatus(), + is(Status.OK.getStatusCode())); + } + }.run(); + } + + @Test + @RunAsClient + public void doGETProjectsWithAnonymous() throws Exception { + new ResourceRequest(getRestEndpointUrl("/projects"), "GET") { + @Override + protected void prepareRequest(ClientRequest request) { + request.header(HttpHeaders.ACCEPT, + MediaTypes.APPLICATION_ZANATA_PROJECTS_XML); + } + + @Override + protected void onResponse(ClientResponse response) { + assertThat(response.getStatus(), + is(Status.OK.getStatusCode())); + } + }.run(); + } + + @Test + @RunAsClient + public void doPOSTProjectsWithAnonymous() throws Exception { + new ResourceRequest(getRestEndpointUrl("/projects"), "POST") { + @Override + protected void prepareRequest(ClientRequest request) { + request.header(HttpHeaders.ACCEPT, + MediaTypes.APPLICATION_ZANATA_PROJECTS_XML); + } + + @Override + protected void onResponse(ClientResponse response) { + assertThat(response.getEntity(String.class).toString(), + is(InvalidApiKeyUtil.getMessage( + API_KEY_ABSENCE_WARNING))); + + assertThat(response.getStatus(), + is(Status.UNAUTHORIZED.getStatusCode())); + } + }.run(); + } + + private ResourceRequestEnvironment getUnAuthorizedEnvironment() { + return new ResourceRequestEnvironment() { + @Override + public Map getDefaultHeaders() { + return new HashMap() { + { + put("X-Auth-User", ADMIN); + put("X-Auth-Token", invalidAPI); + } + }; + } + }; + } +} diff --git a/zanata-war/src/test/java/org/zanata/util/HttpUtilTest.java b/zanata-war/src/test/java/org/zanata/util/HttpUtilTest.java new file mode 100644 index 0000000000..601553bbc8 --- /dev/null +++ b/zanata-war/src/test/java/org/zanata/util/HttpUtilTest.java @@ -0,0 +1,77 @@ +package org.zanata.util; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.HttpMethod; + +import org.mockito.Mockito; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Alex Eng aeng@redhat.com + */ +@Test(groups = { "unit-tests" }) +public class HttpUtilTest { + + @BeforeMethod + public void init() { + setHeader(""); + } + + @Test + public void getClientIdWithNoHeaderTest() { + String expectedIP = "255.255.255.1"; + HttpServletRequest mockRequest = Mockito.mock(HttpServletRequest.class); + when(mockRequest.getRemoteAddr()).thenReturn(expectedIP); + + String ip = HttpUtil.getClientIp(mockRequest); + assertThat(ip).isEqualTo(expectedIP); + verify(mockRequest).getRemoteAddr(); + } + + @Test + public void getClientIdWithWithHeaderTest() { + String proxyHeader = "random-header-from-proxy-server"; + setHeader(proxyHeader); + String expectedIP = "255.255.255.1"; + HttpServletRequest mockRequest = Mockito.mock(HttpServletRequest.class); + when(mockRequest.getHeader(proxyHeader)).thenReturn(expectedIP); + + String ip = HttpUtil.getClientIp(mockRequest); + assertThat(ip).isEqualTo(expectedIP); + verify(mockRequest).getHeader(proxyHeader); + } + + @Test + public void getClientIdWithWithHeaderListTest() { + String proxyHeader = "random-header-from-proxy-server"; + setHeader(proxyHeader); + String expectedIP = "255.255.255.1,255.255.255.2,255.255.255.3"; + HttpServletRequest mockRequest = Mockito.mock(HttpServletRequest.class); + when(mockRequest.getHeader(proxyHeader)).thenReturn(expectedIP); + + String ip = HttpUtil.getClientIp(mockRequest); + assertThat(ip).isEqualTo("255.255.255.3"); + verify(mockRequest).getHeader(proxyHeader); + } + + private void setHeader(String header) { + System.setProperty("ZANATA_PROXY_HEADER", header); + HttpUtil.refreshProxyHeader(); + } + + @Test + public void isReadMethodTest() { + assertThat(HttpUtil.isReadMethod(HttpMethod.DELETE)).isFalse(); + assertThat(HttpUtil.isReadMethod(HttpMethod.POST)).isFalse(); + assertThat(HttpUtil.isReadMethod(HttpMethod.PUT)).isFalse(); + + assertThat(HttpUtil.isReadMethod(HttpMethod.GET)).isTrue(); + assertThat(HttpUtil.isReadMethod(HttpMethod.HEAD)).isTrue(); + assertThat(HttpUtil.isReadMethod(HttpMethod.OPTIONS)).isTrue(); + } +}