Skip to content

Commit

Permalink
Url handling issue 3382 (#3405)
Browse files Browse the repository at this point in the history
* chore(urls): Unify WAR URL handling in unauthenticated scenario (#3382)

Signed-off-by: Grzegorz Grzybek <gr.grzybek@gmail.com>

* chore(urls): Unify WAR URL handling in authenticated scenario (#3382)

* chore(urls): Unify SpringBoot URL handling (#3382)

* chore(urls): Unify WAR/SpringBoot/Quarkus URL handling (fixes #3382)

* chore(urls): Apply code review discussion (#3382)

Signed-off-by: Grzegorz Grzybek <gr.grzybek@gmail.com>

* chore(urls): Fix test failures (#3382)

Signed-off-by: Grzegorz Grzybek <gr.grzybek@gmail.com>

---------

Signed-off-by: Grzegorz Grzybek <gr.grzybek@gmail.com>
  • Loading branch information
grgrzybek committed Apr 24, 2024
1 parent 67089cf commit 961ebe1
Show file tree
Hide file tree
Showing 27 changed files with 706 additions and 463 deletions.
2 changes: 1 addition & 1 deletion deploy/hawtio-default/src/main/webapp/WEB-INF/web.xml
Expand Up @@ -108,7 +108,7 @@

<filter>
<filter-name>LoginRedirectFilter</filter-name>
<filter-class>io.hawt.web.auth.LoginRedirectFilter</filter-class>
<filter-class>io.hawt.web.auth.ClientRouteRedirectFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>LoginRedirectFilter</filter-name>
Expand Down
Expand Up @@ -34,6 +34,7 @@
* Authenticator supports the following authentication methods:
* <ul>
* <li>a set of user name and password</li>
* <li>oidc (bearer) access token</li>
* <li>client certificates</li>
* </ul>
*/
Expand Down
56 changes: 1 addition & 55 deletions hawtio-system/src/main/java/io/hawt/util/Strings.java
Expand Up @@ -27,66 +27,12 @@ public static List<String> split(String text, String delimiter) {
.filter(Strings::isNotBlank).collect(Collectors.toList());
}

/**
* Normalizes a path. If the path contains a single '/' character it is returned
* unchanged, otherwise the path is:
* <ol>
* <li>stripped from all multiple consecutive occurrences of '/' characters</li>
* <li>stripped from trailing '/' character(s)</li>
* </ol>
*
* @param path
* path to normalize
* @return normalized path
*/
public static String cleanPath(final String path) {
final String result = path.replaceAll("//+", "/");
return result.length() == 1 && result.charAt(0) == '/' ? result
: result.replaceAll("/+$", "");
}

/**
* Creates a web context path from components. Concatenates all path components
* using '/' character as delimiter and the result is then:
* <ol>
* <li>prefixed with '/' character</li>
* <li>stripped from all multiple consecutive occurrences of '/' characters</li>
* <li>stripped from trailing '/' character(s)</li>
* </ol>
*
* @return empty string or string which starts with a "/" character but does not
* end with a "/" character
*/
public static String webContextPath(final String first, final String... more) {
if (more.length == 0 && (first == null || first.isEmpty())) {
return "";
}

final StringBuilder b = new StringBuilder();
if (first != null) {
if (!first.startsWith("/")) {
b.append('/');
}
b.append(first);
}

for (final String s : more) {
if (s != null && !s.isEmpty()) {
b.append('/');
b.append(s);
}
}

final String cleanedPath = cleanPath(b.toString());
return cleanedPath.length() == 1 ? "" : cleanedPath;
}

public static String resolvePlaceholders(String value) {
return resolvePlaceholders(value, System.getProperties());
}

/**
* Simple, recursively-safe property placeholder resolver. Only system properties are used (for now). De-facto
* Simple, recursively-safe property placeholder resolver. De-facto
* standard {@code ${...}} syntax is used. Unresolvable properties are not replaced and separators pass to
* resulting value.
*
Expand Down
88 changes: 88 additions & 0 deletions hawtio-system/src/main/java/io/hawt/web/ServletHelpers.java
Expand Up @@ -7,6 +7,8 @@
import java.net.URL;
import javax.management.AttributeNotFoundException;

import io.hawt.web.auth.SessionExpiryFilter;
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletResponse;

import io.hawt.system.Authenticator;
Expand Down Expand Up @@ -144,4 +146,90 @@ public static String sanitizeHeader(String header) {
}
return header.replaceAll("[\\r\\n]", "");
}

/**
* Normalizes a path. If the path contains a single '/' character it is returned
* unchanged, otherwise the path is:
* <ol>
* <li>stripped from all multiple consecutive occurrences of '/' characters</li>
* <li>stripped from trailing '/' character(s)</li>
* </ol>
*
* @param path
* path to normalize
* @return normalized path
*/
public static String cleanPath(final String path) {
final String result = path.replaceAll("//+", "/");
return result.length() == 1 && result.charAt(0) == '/' ? result
: result.replaceAll("/+$", "");
}

/**
* Creates a web context path from components. Concatenates all path components
* using '/' character as delimiter and the result is then:
* <ol>
* <li>prefixed with '/' character</li>
* <li>stripped from all multiple consecutive occurrences of '/' characters</li>
* <li>stripped from trailing '/' character(s)</li>
* </ol>
*
* @return empty string or string which starts with a "/" character but does not
* end with a "/" character
*/
public static String webContextPath(final String first, final String... more) {
if (more.length == 0 && (first == null || first.isEmpty())) {
return "";
}

final StringBuilder b = new StringBuilder();
if (first != null) {
if (!first.startsWith("/")) {
b.append('/');
}
b.append(first);
}

for (final String s : more) {
if (s != null && !s.isEmpty()) {
b.append('/');
b.append(s);
}
}

final String cleanedPath = cleanPath(b.toString());
return cleanedPath.length() == 1 ? "" : cleanedPath;
}

/**
* Return a number of web path segments that need to be skipped to reach <em>hawtio path</em>. In JakartaEE
* environment (WAR) everything after context path is "hawtio path", so {@code 0} is returned. In Spring Boot
* we may have to skip some segments (like {@code /actuator/hawtio}).
*
* @param servletContext
* @return
*/
public static int hawtioPathIndex(ServletContext servletContext) {
String servletPath = (String) servletContext.getAttribute(SessionExpiryFilter.SERVLET_PATH);
if (servletPath == null) {
// this attribute is set only in non JakartaEE environments, so here we are in standard WAR
// deployment. Just return "0", which means full path without initial context path
return 0;
} else {
// when SessionExpiryFilter.SERVLET_PATH is set, it contains prefix which should be skipped and which
// is not standard JakartaEE path components (context path, servlet path, path info).
// for Spring Boot we have to skip dispatcher servlet path, management endpoints base ("/actuator")
// and management endpoint mapping
String cleanPath = webContextPath(servletPath);
int pathIndex = 0;
// for "/actuator/hawtio", we have to return "2", so count slashes
for (char c : cleanPath.toCharArray()) {
if (c == '/') {
pathIndex++;
}
}
return pathIndex;
}
}

}
Expand Up @@ -97,15 +97,4 @@ public static boolean isAuthenticated(HttpSession session) {
return session != null && session.getAttribute("subject") != null;
}

public static boolean isSpringSecurityEnabled() {
try {
Class.forName("org.springframework.security.core.SpringSecurityCoreVersion");
LOG.debug("Spring Security enabled");
return true;
} catch (ClassNotFoundException e) {
LOG.debug("Spring Security not found");
return false;
}
}

}
Expand Up @@ -2,7 +2,6 @@

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
Expand All @@ -25,12 +24,69 @@ public class AuthenticationConfiguration {
private static final Logger LOG = LoggerFactory.getLogger(AuthenticationConfiguration.class);

public static final String LOGIN_URL = "/login";
public static final String[] UNSECURED_PATHS = {
"/login", "/auth/login", "/auth/logout", "/auth/config", "/auth/config/session-timeout",
"/css", "/fonts", "/img", "/js", "/static", "/hawtconfig.json",
"/jolokia", "/user", "/keycloak", "/plugin", "/favicon.ico"

// these paths shouldn't be redirected to /login, because either they're for static resources we don't have to
// protect (like site building resources - css, js, html) or they're related to authentication itself
// finally, /jolokia and /proxy should not be redirected, because these are accessed via xhr/fetch and should
// simply return 403 if needed
// all these paths are passed to request.getServletPath().startsWith(), so it depends on servlet mapping
// (relative to context path, so ignoring "/hawtio" for WAR or value from "management.server.base-path" property):
// - for prefix mapping, like "/jolokia/*", for request like "/jolokia/read/xxx", servlet path is "/jolokia"
// - for extension mapping, like "*.info", for request like "/x/y/z.info", servlet path is ... "/x/y/z.info"
// - for default mapping, "/", for request like "/css/defaults.css", servlet path is "/css/defaults.css"

/**
* Static resources paths, which should be always reachable.
*/
public static final String[] UNSECURED_RESOURCE_PATHS = {
"/index.html", "/favicon.ico", "/hawtconfig.json",
"/robots.txt", "/json.worker.js", "/editor.worker.js",
"/css", "/fonts", "/img", "/js", "/static"
};

/**
* Paths related to authentication process.
* {@code /login} path is actually a client-side router path, but Hawtio sometimes redirects (thus forcing
* <em>server</em> request) to this path for unified authentication experience.
* {@code /auth/*}, {@code user}, {@code /keycloak} paths are actual servlet mappings.
*/
public static final String[] UNSECURED_AUTHENTICATION_PATHS = {
"/login",
"/auth/login", "/auth/logout", "/auth/config",
"/user", "/keycloak"
};

/**
* Paths for configuration of the client (@hawtio/react) part.
*/
public static final String[] UNSECURED_META_PATHS = {
"/plugin"
};

/**
* API paths. These may be confusing:<ul>
* <li>should NOT be redirected to /login, but</li>
* <li>should be protected otherwise (e.g., AuthenticationFilter)</li>
* </ul>
*/
public static final String[] UNSECURED_SERVLET_PATHS = {
"/jolokia", "/proxy"
};

/**
* Paths that shouldn't be redirected to {@code /login} when user is not authenticated.
*/
public static final String[] UNSECURED_PATHS;

static {
ArrayList<String> l = new ArrayList<>();
l.addAll(Arrays.asList(UNSECURED_RESOURCE_PATHS));
l.addAll(Arrays.asList(UNSECURED_AUTHENTICATION_PATHS));
l.addAll(Arrays.asList(UNSECURED_META_PATHS));
l.addAll(Arrays.asList(UNSECURED_SERVLET_PATHS));
UNSECURED_PATHS = l.toArray(String[]::new);
}

// =========================================================================
// Configuration properties
// =========================================================================
Expand Down Expand Up @@ -205,7 +261,7 @@ private static List<AuthenticationContainerDiscovery> getDiscoveries(String auth
AuthenticationContainerDiscovery discovery = clazz.getDeclaredConstructor().newInstance();
discoveries.add(discovery);
} catch (Exception e) {
LOG.warn("Couldn't instantiate discovery " + discoveryClass, e);
LOG.warn("Couldn't instantiate discovery {}", discoveryClass, e);
}
}
return discoveries;
Expand Down Expand Up @@ -255,6 +311,21 @@ public boolean isOidcEnabled() {
return oidcConfiguration != null && oidcConfiguration.isEnabled();
}

public static boolean isSpringSecurityEnabled() {
try {
Class.forName("org.springframework.security.core.SpringSecurityCoreVersion");
LOG.trace("Spring Security enabled");
return true;
} catch (ClassNotFoundException e) {
LOG.trace("Spring Security not found");
return false;
}
}

public boolean isExternalAuthenticationEnabled() {
return isKeycloakEnabled() || isOidcEnabled() || isSpringSecurityEnabled();
}

/**
* Initialize OIDC configuration, so it is available both in {@link AuthConfigurationServlet} and
* {@link io.hawt.web.filters.ContentSecurityPolicyFilter}.
Expand Down
Expand Up @@ -34,10 +34,13 @@ public class AuthenticationFilter implements Filter {
protected int timeout;
protected AuthenticationConfiguration authConfiguration;

private int pathIndex;

@Override
public void init(FilterConfig filterConfig) throws ServletException {
authConfiguration = AuthenticationConfiguration.getConfiguration(filterConfig.getServletContext());
timeout = AuthSessionHelpers.getSessionTimeout(filterConfig.getServletContext());
this.pathIndex = ServletHelpers.hawtioPathIndex(filterConfig.getServletContext());
}

@Override
Expand All @@ -62,12 +65,21 @@ public void doFilter(final ServletRequest request, final ServletResponse respons
return;
}

boolean proxyMode = false;
RelativeRequestUri uri = new RelativeRequestUri(httpRequest, pathIndex);
if (uri.getComponents().length > 0 && "proxy".equals(uri.getComponents()[0])) {
// https://github.com/hawtio/hawtio/issues/3178
// /proxy/* requests are now authenticated by this filter, but we have to do it differently, because
// "Authorization" header carries credentials for target Jolokia agent
proxyMode = !uri.getUri().equals("proxy/enabled");
}

HttpSession session = httpRequest.getSession(false);
if (session != null) {
Subject subject = (Subject) session.getAttribute("subject");

// For Spring Security
if (AuthSessionHelpers.isSpringSecurityEnabled()) {
if (AuthenticationConfiguration.isSpringSecurityEnabled()) {
if (subject == null && httpRequest.getRemoteUser() != null) {
AuthSessionHelpers.setup(
session, new Subject(), httpRequest.getRemoteUser(), timeout);
Expand Down

0 comments on commit 961ebe1

Please sign in to comment.