diff --git a/docs/source/admin/traffic_router.rst b/docs/source/admin/traffic_router.rst index f03241d099..c0a7ba59d3 100644 --- a/docs/source/admin/traffic_router.rst +++ b/docs/source/admin/traffic_router.rst @@ -175,6 +175,9 @@ For the most part, the configuration files and :term:`Parameters` used by Traffi | web.xml | various parameters | Default settings for all Web Applications running in the Traffic Router instance | N/A | | | | of Tomcat | | +----------------------------+-------------------------------------------+----------------------------------------------------------------------------------+----------------------------------------------------+ + | users.properties | API users credentials | Users allowed to access /crs/consistenthash/patternbased/regex and | N/A | + | | | /crs/consistenthash/patternbased/deliveryservice | | + +----------------------------+-------------------------------------------+----------------------------------------------------------------------------------+----------------------------------------------------+ .. _tr-profile: diff --git a/docs/source/development/traffic_router.rst b/docs/source/development/traffic_router.rst index 49a23bd9f3..562d6c6494 100644 --- a/docs/source/development/traffic_router.rst +++ b/docs/source/development/traffic_router.rst @@ -107,6 +107,7 @@ To install the Traffic Router Developer environment: * copy :file:`core/src/main/conf/log4j2.xml` to :file:`core/src/test/conf/` * copy :file:`core/src/main/conf/traffic_monitor.properties` to :file:`core/src/test/conf/` and then edit the ``traffic_monitor.bootstrap.hosts`` property * copy :file:`core/src/main/conf/traffic_ops.properties` to :file:`core/src/test/conf/` and then edit the credentials as appropriate for the Traffic Ops instance you will be using. + * copy :file:`core/src/main/conf/users.properties` to :file:`core/src/test/conf/` and then edit the credentials as appropriate for the users you will be using for relevant TR apis. * Default configuration values now reside in :file:`core/src/main/webapp/WEB-INF/applicationContext.xml` .. note:: These values may be overridden by creating and/or modifying the property files listed in :file:`core/src/main/resources/applicationProperties.xml` @@ -316,4 +317,4 @@ API :hidden: :maxdepth: 1 - traffic_router/traffic_router_api \ No newline at end of file + traffic_router/traffic_router_api diff --git a/traffic_router/core/src/main/conf/users.properties b/traffic_router/core/src/main/conf/users.properties new file mode 100644 index 0000000000..33d24d4e00 --- /dev/null +++ b/traffic_router/core/src/main/conf/users.properties @@ -0,0 +1,2 @@ +admin:admin123 +apj:jpa@3 diff --git a/traffic_router/core/src/main/java/org/apache/traffic_control/traffic_router/core/secure/ApiAuthFilter.java b/traffic_router/core/src/main/java/org/apache/traffic_control/traffic_router/core/secure/ApiAuthFilter.java new file mode 100644 index 0000000000..1414be2824 --- /dev/null +++ b/traffic_router/core/src/main/java/org/apache/traffic_control/traffic_router/core/secure/ApiAuthFilter.java @@ -0,0 +1,75 @@ +package org.apache.traffic_control.traffic_router.core.security; + +import javax.servlet.*; +import javax.servlet.http.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.Base64; + +public class ApiAuthFilter implements Filter { + + private File userFile; + private final Map users = new HashMap<>(); + private long lastModified = 0; + + public void setUserFile(final File userFile) { + this.userFile = userFile; + } + + @SuppressWarnings("PMD.AvoidThrowingRawExceptionTypes") + private synchronized void loadUsersIfModified() { + if (userFile.lastModified() > lastModified) { + final Map tempUsers = new HashMap<>(); + try (BufferedReader reader = new BufferedReader(new FileReader(userFile))) { + String line; + while ((line = reader.readLine()) != null) { + if (!line.trim().isEmpty() && line.contains(":")) { + final String[] parts = line.split(":", 2); + tempUsers.put(parts[0], parts[1]); + } + } + users.clear(); + users.putAll(tempUsers); + lastModified = userFile.lastModified(); + } catch (IOException e) { + throw new RuntimeException("Failed to load users from file: " + userFile.getAbsolutePath(), e); + } + } + } + + @Override + public void doFilter(final ServletRequest req, final ServletResponse res, final FilterChain chain) + throws IOException, ServletException { + + final HttpServletRequest request = (HttpServletRequest) req; + final HttpServletResponse response = (HttpServletResponse) res; + + loadUsersIfModified(); + + final String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Basic ")) { + response.setHeader("WWW-Authenticate", "Basic realm=\"TrafficRouter\""); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + final String base64Credentials = authHeader.substring("Basic ".length()); + final String credentials = new String(Base64.getDecoder().decode(base64Credentials), StandardCharsets.UTF_8); + final String[] values = credentials.split(":", 2); + final String username = values[0]; + final String password = values.length > 1 ? values[1] : ""; + + if (!users.containsKey(username) || !users.get(username).equals(password)) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + chain.doFilter(req, res); + } + + @Override public void init(final FilterConfig filterConfig) {} + @Override public void destroy() {} +} diff --git a/traffic_router/core/src/main/webapp/WEB-INF/applicationContext.xml b/traffic_router/core/src/main/webapp/WEB-INF/applicationContext.xml index f478724088..8276e320b4 100644 --- a/traffic_router/core/src/main/webapp/WEB-INF/applicationContext.xml +++ b/traffic_router/core/src/main/webapp/WEB-INF/applicationContext.xml @@ -141,6 +141,10 @@ + + + + diff --git a/traffic_router/core/src/main/webapp/WEB-INF/web.xml b/traffic_router/core/src/main/webapp/WEB-INF/web.xml index 5586d7b72c..2d534fbf8d 100644 --- a/traffic_router/core/src/main/webapp/WEB-INF/web.xml +++ b/traffic_router/core/src/main/webapp/WEB-INF/web.xml @@ -68,8 +68,19 @@ /* + + authFilter + org.springframework.web.filter.DelegatingFilterProxy + + + + authFilter + /crs/consistenthash/patternbased/regex + + status /crs/* + diff --git a/traffic_router/core/src/test/java/org/apache/traffic_control/traffic_router/core/external/ConsistentHashTest.java b/traffic_router/core/src/test/java/org/apache/traffic_control/traffic_router/core/external/ConsistentHashTest.java index 3757bda213..088ee8178f 100644 --- a/traffic_router/core/src/test/java/org/apache/traffic_control/traffic_router/core/external/ConsistentHashTest.java +++ b/traffic_router/core/src/test/java/org/apache/traffic_control/traffic_router/core/external/ConsistentHashTest.java @@ -46,6 +46,19 @@ import static org.junit.Assert.assertThat; import static org.junit.Assert.fail; + +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.HttpClients; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; + @Category(ExternalTest.class) public class ConsistentHashTest { private CloseableHttpClient closeableHttpClient; @@ -55,6 +68,21 @@ public class ConsistentHashTest { String consistentHashRegex; List steeredDeliveryServices = new ArrayList(); + private String[] readCredentials() throws Exception { + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream("users.properties")) { + if (inputStream == null) { + throw new RuntimeException("test-creds.txt not found in classpath"); + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line = reader.readLine(); + if (line == null || !line.contains(":")) { + throw new RuntimeException("Invalid format in test-creds.txt, expected format 'username:password'"); + } + return line.split(":", 2); + } + } + } + @Before public void before() throws Exception { closeableHttpClient = HttpClientBuilder.create().build(); @@ -78,6 +106,7 @@ public void before() throws Exception { resourcePath = "publish/CrConfig.json"; inputStream = getClass().getClassLoader().getResourceAsStream(resourcePath); + if (inputStream == null) { fail("Could not find file '" + resourcePath + "' needed for test from the current classpath as a resource!"); } @@ -266,7 +295,7 @@ public void itUsesBypassFiltersWithDeliveryServiceSteering() throws Exception { } @Test - public void itUsesRegexToStandardizeRequestPath() throws Exception { + public void itUsesRegexToStandardizeRequestPathWithoutCreds() throws Exception { CloseableHttpResponse response = null; try { @@ -276,22 +305,48 @@ public void itUsesRegexToStandardizeRequestPath() throws Exception { response = closeableHttpClient.execute(httpGet); - assertThat("Expected to get 200 response from /consistenthash/patternbased/regex endpoint", response.getStatusLine().getStatusCode(), equalTo(200)); + assertThat("Expected to get 401 response from /consistenthash/patternbased/regex endpoint", response.getStatusLine().getStatusCode(), equalTo(401)); + } finally { + if (response != null) response.close(); + } + } + + @Test + public void itUsesRegexToStandardizeRequestPathWithCreds() throws Exception { + String[] creds = readCredentials(); + String username = creds[0]; + String password = creds[1]; + + CredentialsProvider credsProvider = new BasicCredentialsProvider(); + credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username, password)); + + try (CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultCredentialsProvider(credsProvider) + .build()) { ObjectMapper objectMapper = new ObjectMapper(new JsonFactory()); - JsonNode resp = objectMapper.readTree(EntityUtils.toString(response.getEntity())); - String resultingPathToConsistentHash = resp.get("resultingPathToConsistentHash").asText(); - requestPath = URLEncoder.encode("/other/path/other_thing.m3u8", "UTF-8"); - httpGet = new HttpGet("http://localhost:3333/crs/consistenthash/patternbased/regex?regex=" + encodedConsistentHashRegex + "&requestPath=" + requestPath); + String requestPath = URLEncoder.encode("/some/path/thing.m3u8", "UTF-8"); + String encodedConsistentHashRegex = URLEncoder.encode(consistentHashRegex, "UTF-8"); - response = closeableHttpClient.execute(httpGet); + String baseUrl = "http://localhost:3333/crs/consistenthash/patternbased/regex"; + String url = baseUrl + "?regex=" + encodedConsistentHashRegex + "&requestPath=" + requestPath; - resp = objectMapper.readTree(EntityUtils.toString(response.getEntity())); + HttpGet httpGet = new HttpGet(url); + try (CloseableHttpResponse response = httpClient.execute(httpGet)) { + assertThat("Expected 200 response", response.getStatusLine().getStatusCode(), equalTo(200)); + JsonNode resp = objectMapper.readTree(EntityUtils.toString(response.getEntity())); + String resultingPathToConsistentHash = resp.get("resultingPathToConsistentHash").asText(); - assertThat(JsonUtils.optString(resp, "resultingPathToConsistentHash"),equalTo(resultingPathToConsistentHash)); - } finally { - if (response != null) response.close(); + requestPath = URLEncoder.encode("/other/path/other_thing.m3u8", "UTF-8"); + url = baseUrl + "?regex=" + encodedConsistentHashRegex + "&requestPath=" + requestPath; + + HttpGet secondRequest = new HttpGet(url); + try (CloseableHttpResponse response2 = httpClient.execute(secondRequest)) { + JsonNode secondResp = objectMapper.readTree(EntityUtils.toString(response2.getEntity())); + assertThat(secondResp.get("resultingPathToConsistentHash").asText(), equalTo(resultingPathToConsistentHash)); + } + } } } diff --git a/traffic_router/core/src/test/resources/users.properties b/traffic_router/core/src/test/resources/users.properties new file mode 100644 index 0000000000..35194933d3 --- /dev/null +++ b/traffic_router/core/src/test/resources/users.properties @@ -0,0 +1 @@ +admin:secret