diff --git a/nifi-commons/nifi-web-utils/src/main/java/org/apache/nifi/web/util/WebUtils.java b/nifi-commons/nifi-web-utils/src/main/java/org/apache/nifi/web/util/WebUtils.java index 5e508e30d6..32dbb53bbe 100644 --- a/nifi-commons/nifi-web-utils/src/main/java/org/apache/nifi/web/util/WebUtils.java +++ b/nifi-commons/nifi-web-utils/src/main/java/org/apache/nifi/web/util/WebUtils.java @@ -32,8 +32,6 @@ import javax.ws.rs.core.UriBuilderException; import java.net.URI; import java.util.Arrays; import java.util.List; -import java.util.concurrent.locks.ReadWriteLock; -import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Stream; /** @@ -43,8 +41,6 @@ public final class WebUtils { private static final Logger logger = LoggerFactory.getLogger(WebUtils.class); - final static ReadWriteLock lock = new ReentrantReadWriteLock(); - public static final String PROXY_SCHEME_HTTP_HEADER = "X-ProxyScheme"; public static final String PROXY_HOST_HTTP_HEADER = "X-ProxyHost"; public static final String PROXY_PORT_HTTP_HEADER = "X-ProxyPort"; @@ -57,6 +53,8 @@ public final class WebUtils { public static final String FORWARDED_CONTEXT_HTTP_HEADER = "X-Forwarded-Context"; public static final String FORWARDED_PREFIX_HTTP_HEADER = "X-Forwarded-Prefix"; + private static final String EMPTY = ""; + private WebUtils() { } @@ -207,7 +205,7 @@ public final class WebUtils { return contextPath; } catch (UriBuilderException e) { logger.error("Error determining context path on " + jspDisplayName + ": " + e.getMessage()); - return ""; + return EMPTY; } } @@ -218,27 +216,18 @@ public final class WebUtils { * @param request the HTTP request * @return the provided context path or an empty string */ - public static String determineContextPath(HttpServletRequest request) { - String contextPath = request.getContextPath(); - String proxyContextPath = request.getHeader(PROXY_CONTEXT_PATH_HTTP_HEADER); - String forwardedContext = request.getHeader(FORWARDED_CONTEXT_HTTP_HEADER); - String prefix = request.getHeader(FORWARDED_PREFIX_HTTP_HEADER); + public static String determineContextPath(final HttpServletRequest request) { + final String proxyContextPath = request.getHeader(PROXY_CONTEXT_PATH_HTTP_HEADER); + final String forwardedContext = request.getHeader(FORWARDED_CONTEXT_HTTP_HEADER); + final String prefix = request.getHeader(FORWARDED_PREFIX_HTTP_HEADER); - logger.debug("Context path: " + contextPath); - String determinedContextPath = ""; - - // If a context path header is set, log each + String determinedContextPath = EMPTY; if (anyNotBlank(proxyContextPath, forwardedContext, prefix)) { - logger.debug(String.format("On the request, the following context paths were parsed" + - " from headers:\n\t X-ProxyContextPath: %s\n\tX-Forwarded-Context: %s\n\tX-Forwarded-Prefix: %s", - proxyContextPath, forwardedContext, prefix)); - // Implementing preferred order here: PCP, FC, FP determinedContextPath = Stream.of(proxyContextPath, forwardedContext, prefix) - .filter(StringUtils::isNotBlank).findFirst().orElse(""); + .filter(StringUtils::isNotBlank).findFirst().orElse(EMPTY); } - logger.debug("Determined context path: " + determinedContextPath); return determinedContextPath; } @@ -366,7 +355,7 @@ public final class WebUtils { portFromHostHeader = null; } if (StringUtils.isNotBlank(portFromHostHeader) && StringUtils.isNotBlank(portHeaderValue)) { - logger.warn(String.format("The proxied host header contained a port, but was overridden by the proxied port header")); + logger.warn("Forwarded Host Port [{}] replaced with Forwarded Port [{}]", portFromHostHeader, portHeaderValue); } port = StringUtils.isNotBlank(portHeaderValue) ? portHeaderValue : (StringUtils.isNotBlank(portFromHostHeader) ? portFromHostHeader : null); return port; diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml index c06fbdcc3e..f62cedfd8c 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml @@ -432,11 +432,5 @@ org.slf4j jcl-over-slf4j - - org.eclipse.jetty - jetty-http - ${jetty.version} - compile - diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java index 645d67921e..f05dbee70a 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java @@ -34,6 +34,7 @@ import org.apache.nifi.authorization.user.NiFiUserDetails; import org.apache.nifi.authorization.user.NiFiUserUtils; import org.apache.nifi.authorization.util.IdentityMappingUtil; import org.apache.nifi.util.FormatUtils; +import org.apache.nifi.web.api.cookie.ApplicationCookieName; import org.apache.nifi.web.api.dto.AccessConfigurationDTO; import org.apache.nifi.web.api.dto.AccessStatusDTO; import org.apache.nifi.web.api.entity.AccessConfigurationEntity; @@ -42,7 +43,6 @@ import org.apache.nifi.web.security.InvalidAuthenticationException; import org.apache.nifi.web.security.LogoutException; import org.apache.nifi.web.security.ProxiedEntitiesUtils; import org.apache.nifi.web.security.UntrustedProxyException; -import org.apache.nifi.web.security.http.SecurityCookieName; import org.apache.nifi.web.security.jwt.provider.BearerTokenProvider; import org.apache.nifi.web.security.jwt.revocation.JwtLogoutListener; import org.apache.nifi.web.security.kerberos.KerberosService; @@ -62,9 +62,7 @@ import org.springframework.security.oauth2.server.resource.BearerTokenAuthentica import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; -import org.springframework.web.util.WebUtils; -import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.Consumes; @@ -81,6 +79,7 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import java.net.URI; import java.security.cert.X509Certificate; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -96,7 +95,6 @@ public class AccessResource extends ApplicationResource { private static final Logger logger = LoggerFactory.getLogger(AccessResource.class); protected static final String AUTHENTICATION_NOT_ENABLED_MSG = "User authentication/authorization is only supported when running over HTTPS."; - static final String LOGOUT_REQUEST_IDENTIFIER = "nifi-logout-request-identifier"; private X509CertificateExtractor certificateExtractor; private X509AuthenticationProvider x509AuthenticationProvider; @@ -259,7 +257,7 @@ public class AccessResource extends ApplicationResource { accessStatus.setStatus(AccessStatusDTO.Status.UNKNOWN.name()); accessStatus.setMessage("Access Unknown: Authorization Header not found."); // Remove Session Cookie when Authorization Header not found - removeCookie(httpServletResponse, SecurityCookieName.AUTHORIZATION_BEARER.getName()); + applicationCookieService.removeCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.AUTHORIZATION_BEARER); } else { try { // authenticate the token @@ -275,10 +273,7 @@ public class AccessResource extends ApplicationResource { accessStatus.setStatus(AccessStatusDTO.Status.ACTIVE.name()); accessStatus.setMessage("You are already logged in."); } catch (final AuthenticationException iae) { - if (WebUtils.getCookie(httpServletRequest, SecurityCookieName.AUTHORIZATION_BEARER.getName()) != null) { - removeCookie(httpServletResponse, SecurityCookieName.AUTHORIZATION_BEARER.getName()); - } - + applicationCookieService.removeCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.AUTHORIZATION_BEARER); throw iae; } } @@ -343,7 +338,7 @@ public class AccessResource extends ApplicationResource { @ApiResponse(code = 500, message = "Unable to create access token because an unexpected error occurred.") } ) - public Response createAccessTokenFromTicket(@Context HttpServletRequest httpServletRequest) { + public Response createAccessTokenFromTicket(@Context final HttpServletRequest httpServletRequest, @Context final HttpServletResponse httpServletResponse) { // only support access tokens when communicating over HTTPS if (!httpServletRequest.isSecure()) { @@ -379,7 +374,8 @@ public class AccessResource extends ApplicationResource { final LoginAuthenticationToken loginAuthenticationToken = new LoginAuthenticationToken(mappedIdentity, expiration, "KerberosService"); final String token = bearerTokenProvider.getBearerToken(loginAuthenticationToken); final URI uri = URI.create(generateResourceUri("access", "kerberos")); - return generateTokenResponse(generateCreatedResponse(uri, token), token); + setBearerToken(httpServletResponse, token); + return generateCreatedResponse(uri, token).build(); } catch (final AuthenticationException e) { throw new AccessDeniedException(e.getMessage(), e); } @@ -414,9 +410,10 @@ public class AccessResource extends ApplicationResource { } ) public Response createAccessToken( - @Context HttpServletRequest httpServletRequest, - @FormParam("username") String username, - @FormParam("password") String password) { + @Context final HttpServletRequest httpServletRequest, + @Context final HttpServletResponse httpServletResponse, + @FormParam("username") final String username, + @FormParam("password") final String password) { // only support access tokens when communicating over HTTPS if (!httpServletRequest.isSecure()) { @@ -450,9 +447,10 @@ public class AccessResource extends ApplicationResource { throw new AdministrationException(iae.getMessage(), iae); } - final String token = bearerTokenProvider.getBearerToken(loginAuthenticationToken); + final String bearerToken = bearerTokenProvider.getBearerToken(loginAuthenticationToken); final URI uri = URI.create(generateResourceUri("access", "token")); - return generateTokenResponse(generateCreatedResponse(uri, token), token); + setBearerToken(httpServletResponse, bearerToken); + return generateCreatedResponse(uri, bearerToken).build(); } @DELETE @@ -484,7 +482,7 @@ public class AccessResource extends ApplicationResource { try { logger.info("Logout Started [{}]", mappedUserIdentity); logger.debug("Removing Authorization Cookie [{}]", mappedUserIdentity); - removeCookie(httpServletResponse, SecurityCookieName.AUTHORIZATION_BEARER.getName()); + applicationCookieService.removeCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.AUTHORIZATION_BEARER); final String bearerToken = bearerTokenResolver.resolve(httpServletRequest); jwtLogoutListener.logout(bearerToken); @@ -493,14 +491,7 @@ public class AccessResource extends ApplicationResource { final LogoutRequest logoutRequest = new LogoutRequest(UUID.randomUUID().toString(), mappedUserIdentity); logoutRequestManager.start(logoutRequest); - // generate a cookie to store the logout request identifier - final Cookie cookie = new Cookie(LOGOUT_REQUEST_IDENTIFIER, logoutRequest.getRequestIdentifier()); - cookie.setPath("/"); - cookie.setHttpOnly(true); - cookie.setMaxAge(60); - cookie.setSecure(true); - httpServletResponse.addCookie(cookie); - + applicationCookieService.addCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.LOGOUT_REQUEST_IDENTIFIER, logoutRequest.getRequestIdentifier()); return generateOkResponse().build(); } catch (final LogoutException e) { logger.error("Logout Failed Identity [{}]", mappedUserIdentity, e); @@ -538,22 +529,16 @@ public class AccessResource extends ApplicationResource { LogoutRequest completeLogoutRequest(final HttpServletResponse httpServletResponse) { LogoutRequest logoutRequest = null; - // check if a logout request identifier is present and if so complete the request - final Cookie cookie = WebUtils.getCookie(httpServletRequest, LOGOUT_REQUEST_IDENTIFIER); - final String logoutRequestIdentifier = cookie == null ? null : cookie.getValue(); - if (logoutRequestIdentifier != null) { + final Optional cookieValue = getLogoutRequestIdentifier(); + if (cookieValue.isPresent()) { + final String logoutRequestIdentifier = cookieValue.get(); logoutRequest = logoutRequestManager.complete(logoutRequestIdentifier); - } - - if (logoutRequest == null) { - logger.warn("Logout Request [{}] not found", logoutRequestIdentifier); - } else { logger.info("Logout Request [{}] Completed [{}]", logoutRequestIdentifier, logoutRequest.getMappedUserIdentity()); + } else { + logger.warn("Logout Request Cookie [{}] not found", ApplicationCookieName.LOGOUT_REQUEST_IDENTIFIER.getCookieName()); } - // remove the cookie if it existed removeLogoutRequestCookie(httpServletResponse); - return logoutRequest; } @@ -578,8 +563,22 @@ public class AccessResource extends ApplicationResource { return getNiFiUri() + "logout-complete"; } - void removeLogoutRequestCookie(final HttpServletResponse httpServletResponse) { - removeCookie(httpServletResponse, LOGOUT_REQUEST_IDENTIFIER); + /** + * Send Set-Cookie header to remove Logout Request Identifier cookie from client + * + * @param httpServletResponse HTTP Servlet Response + */ + protected void removeLogoutRequestCookie(final HttpServletResponse httpServletResponse) { + applicationCookieService.removeCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.LOGOUT_REQUEST_IDENTIFIER); + } + + /** + * Get Logout Request Identifier from current HTTP Request Cookie header + * + * @return Optional Logout Request Identifier + */ + protected Optional getLogoutRequestIdentifier() { + return applicationCookieService.getCookieValue(httpServletRequest, ApplicationCookieName.LOGOUT_REQUEST_IDENTIFIER); } // setters diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java index f2f6cc3c57..ad95819122 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java @@ -50,21 +50,20 @@ import org.apache.nifi.util.ComponentIdGenerator; import org.apache.nifi.util.NiFiProperties; import org.apache.nifi.web.NiFiServiceFacade; import org.apache.nifi.web.Revision; +import org.apache.nifi.web.api.cookie.ApplicationCookieName; +import org.apache.nifi.web.api.cookie.ApplicationCookieService; +import org.apache.nifi.web.api.cookie.StandardApplicationCookieService; import org.apache.nifi.web.api.dto.RevisionDTO; import org.apache.nifi.web.api.entity.ComponentEntity; import org.apache.nifi.web.api.entity.Entity; import org.apache.nifi.web.api.entity.TransactionResultEntity; import org.apache.nifi.web.security.ProxiedEntitiesUtils; -import org.apache.nifi.web.security.http.SecurityCookieName; import org.apache.nifi.web.security.util.CacheKey; import org.apache.nifi.web.util.WebUtils; -import org.eclipse.jetty.http.HttpCookie; -import org.eclipse.jetty.http.HttpHeader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.servlet.ServletContext; -import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.core.CacheControl; @@ -114,12 +113,13 @@ public abstract class ApplicationResource { public static final String DISCONNECTED_NODE_ACKNOWLEDGED = "disconnectedNodeAcknowledged"; static final String LOGIN_ERROR_TITLE = "Unable to continue login sequence"; static final String LOGOUT_ERROR_TITLE = "Unable to continue logout sequence"; - private static final int VALID_FOR_SESSION_ONLY = -1; protected static final String NON_GUARANTEED_ENDPOINT = "Note: This endpoint is subject to change as NiFi and it's REST API evolve."; private static final Logger logger = LoggerFactory.getLogger(ApplicationResource.class); + private static final String ROOT_PATH = "/"; + public static final String NODEWISE = "false"; @Context @@ -128,6 +128,7 @@ public abstract class ApplicationResource { @Context protected UriInfo uriInfo; + protected ApplicationCookieService applicationCookieService = new StandardApplicationCookieService(); protected NiFiProperties properties; private RequestReplicator requestReplicator; private ClusterCoordinator clusterCoordinator; @@ -164,14 +165,23 @@ public abstract class ApplicationResource { return uri.toString(); } + /** + * Get Resource URI used for Cookie Domain and Path properties + * + * @return Cookie Resource URI + */ + protected URI getCookieResourceUri() { + final UriBuilder uriBuilder = uriInfo.getBaseUriBuilder(); + return buildResourceUri(uriBuilder.replacePath(ROOT_PATH).build()); + } + private URI buildResourceUri(final String... path) { final UriBuilder uriBuilder = uriInfo.getBaseUriBuilder(); - uriBuilder.segment(path); - URI uri = uriBuilder.build(); + return buildResourceUri(uriBuilder.segment(path).build()); + } + + private URI buildResourceUri(final URI uri) { try { - - // check for proxy settings - final String scheme = getFirstHeaderValue(PROXY_SCHEME_HTTP_HEADER, FORWARDED_PROTO_HTTP_HEADER); final String hostHeaderValue = getFirstHeaderValue(PROXY_HOST_HTTP_HEADER, FORWARDED_HOST_HTTP_HEADER); final String portHeaderValue = getFirstHeaderValue(PROXY_PORT_HTTP_HEADER, FORWARDED_PORT_HTTP_HEADER); @@ -180,25 +190,20 @@ public abstract class ApplicationResource { final String port = WebUtils.determineProxiedPort(hostHeaderValue, portHeaderValue); // Catch header poisoning - String allowedContextPaths = properties.getAllowedContextPaths(); - String resourcePath = WebUtils.getResourcePath(uri, httpServletRequest, allowedContextPaths); + final String allowedContextPaths = properties.getAllowedContextPaths(); + final String resourcePath = WebUtils.getResourcePath(uri, httpServletRequest, allowedContextPaths); // determine the port uri int uriPort = uri.getPort(); - if (port != null) { - if (StringUtils.isWhitespace(port)) { - uriPort = -1; - } else { - try { - uriPort = Integer.parseInt(port); - } catch (final NumberFormatException nfe) { - logger.warn(String.format("Unable to parse proxy port HTTP header '%s'. Using port from request URI '%s'.", port, uriPort)); - } + if (StringUtils.isNumeric(port)) { + try { + uriPort = Integer.parseInt(port); + } catch (final NumberFormatException nfe) { + logger.warn("Parsing Proxy Port [{}] Failed: Using URI Port [{}]", port, uriPort); } } - // construct the URI - uri = new URI( + return new URI( (StringUtils.isBlank(scheme)) ? uri.getScheme() : scheme, uri.getUserInfo(), (StringUtils.isBlank(host)) ? uri.getHost() : host, @@ -206,11 +211,9 @@ public abstract class ApplicationResource { resourcePath, uri.getQuery(), uri.getFragment()); - } catch (final URISyntaxException use) { throw new UriBuilderException(use); } - return uri; } /** @@ -314,7 +317,7 @@ public abstract class ApplicationResource { } protected MultivaluedMap getRequestParameters() { - final MultivaluedMap entity = new MultivaluedHashMap(); + final MultivaluedMap entity = new MultivaluedHashMap<>(); for (final Map.Entry entry : httpServletRequest.getParameterMap().entrySet()) { if (entry.getValue() == null) { @@ -330,7 +333,7 @@ public abstract class ApplicationResource { } protected Map getHeaders() { - return getHeaders(new HashMap()); + return getHeaders(new HashMap<>()); } protected Map getHeaders(final Map overriddenHeaders) { @@ -818,7 +821,7 @@ public abstract class ApplicationResource { } } - private final class Request { + private static final class Request { final String userChain; final String uri; final Revision revision; @@ -1283,19 +1286,14 @@ public abstract class ApplicationResource { } - protected Response generateTokenResponse(ResponseBuilder builder, String token) { - // currently there is no way to use javax.servlet-api to set SameSite=Strict, so we do this using Jetty - HttpCookie jwtCookie = new HttpCookie(SecurityCookieName.AUTHORIZATION_BEARER.getName(), token, null, "/", VALID_FOR_SESSION_ONLY, true, true, null, 0, HttpCookie.SameSite.STRICT); - return builder.header(HttpHeader.SET_COOKIE.asString(), jwtCookie.getRFC6265SetCookie()).build(); - } - - protected void removeCookie(final HttpServletResponse httpServletResponse, final String cookieName) { - final Cookie cookie = new Cookie(cookieName, null); - cookie.setPath("/"); - cookie.setHttpOnly(true); - cookie.setMaxAge(0); - cookie.setSecure(true); - httpServletResponse.addCookie(cookie); + /** + * Set Bearer Token as HTTP Session Cookie using standard Cookie Name + * + * @param response HTTP Servlet Response + * @param bearerToken JSON Web Token + */ + protected void setBearerToken(final HttpServletResponse response, final String bearerToken) { + applicationCookieService.addSessionCookie(getCookieResourceUri(), response, ApplicationCookieName.AUTHORIZATION_BEARER, bearerToken); } protected String getNiFiUri() { diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OIDCAccessResource.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OIDCAccessResource.java index 475ee2081c..673e52982a 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OIDCAccessResource.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OIDCAccessResource.java @@ -39,17 +39,15 @@ import org.apache.http.message.BasicNameValuePair; import org.apache.nifi.authentication.exception.AuthenticationNotSupportedException; import org.apache.nifi.authorization.user.NiFiUserUtils; import org.apache.nifi.util.NiFiProperties; -import org.apache.nifi.web.security.http.SecurityCookieName; +import org.apache.nifi.web.api.cookie.ApplicationCookieName; import org.apache.nifi.web.security.jwt.provider.BearerTokenProvider; import org.apache.nifi.web.security.oidc.OIDCEndpoints; import org.apache.nifi.web.security.oidc.OidcService; import org.apache.nifi.web.security.token.LoginAuthenticationToken; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.web.util.WebUtils; import javax.annotation.PreDestroy; -import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.Consumes; @@ -65,6 +63,7 @@ import java.io.IOException; import java.net.URI; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -78,7 +77,6 @@ import java.util.regex.Pattern; public class OIDCAccessResource extends AccessResource { private static final Logger logger = LoggerFactory.getLogger(OIDCAccessResource.class); - private static final String OIDC_REQUEST_IDENTIFIER = "oidc-request-identifier"; private static final String OIDC_ID_TOKEN_AUTHN_ERROR = "Unable to exchange authorization for ID token: "; private static final String OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG = "OpenId Connect support is not configured"; private static final String REVOKE_ACCESS_TOKEN_LOGOUT = "oidc_access_token_logout"; @@ -144,12 +142,12 @@ public class OIDCAccessResource extends AccessResource { ) public void oidcCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { final AuthenticationResponse oidcResponse = parseOidcResponse(httpServletRequest, httpServletResponse, LOGGING_IN); + final Optional requestIdentifier = getOidcRequestIdentifier(); - final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue(); - - if (oidcResponse != null && oidcResponse.indicatesSuccess()) { + if (requestIdentifier.isPresent() && oidcResponse != null && oidcResponse.indicatesSuccess()) { final AuthenticationSuccessResponse successfulOidcResponse = (AuthenticationSuccessResponse) oidcResponse; + final String oidcRequestIdentifier = requestIdentifier.get(); checkOidcState(httpServletResponse, oidcRequestIdentifier, successfulOidcResponse, LOGGING_IN); try { @@ -210,8 +208,8 @@ public class OIDCAccessResource extends AccessResource { return Response.status(Response.Status.CONFLICT).entity(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG).build(); } - final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue(); - if (oidcRequestIdentifier == null) { + final Optional requestIdentifier = getOidcRequestIdentifier(); + if (!requestIdentifier.isPresent()) { final String message = "The login request identifier was not found in the request. Unable to continue."; logger.warn(message); return Response.status(Response.Status.BAD_REQUEST).entity(message).build(); @@ -221,12 +219,13 @@ public class OIDCAccessResource extends AccessResource { removeOidcRequestCookie(httpServletResponse); // get the jwt - final String jwt = oidcService.getJwt(oidcRequestIdentifier); + final String jwt = oidcService.getJwt(requestIdentifier.get()); if (jwt == null) { throw new IllegalArgumentException("A JWT for this login request identifier could not be found. Unable to continue."); } - return generateTokenResponse(generateOkResponse(jwt), jwt); + setBearerToken(httpServletResponse, jwt); + return generateOkResponse(jwt).build(); } @GET @@ -247,7 +246,7 @@ public class OIDCAccessResource extends AccessResource { } final String mappedUserIdentity = NiFiUserUtils.getNiFiUserIdentity(); - removeCookie(httpServletResponse, SecurityCookieName.AUTHORIZATION_BEARER.getName()); + applicationCookieService.removeCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.AUTHORIZATION_BEARER); logger.debug("Invalidated JWT for user [{}]", mappedUserIdentity); // Get the oidc discovery url @@ -291,13 +290,13 @@ public class OIDCAccessResource extends AccessResource { ) public void oidcLogoutCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { final AuthenticationResponse oidcResponse = parseOidcResponse(httpServletRequest, httpServletResponse, !LOGGING_IN); + final Optional requestIdentifier = getOidcRequestIdentifier(); - final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue(); - - if (oidcResponse != null && oidcResponse.indicatesSuccess()) { + if (requestIdentifier.isPresent() && oidcResponse != null && oidcResponse.indicatesSuccess()) { final AuthenticationSuccessResponse successfulOidcResponse = (AuthenticationSuccessResponse) oidcResponse; // confirm state + final String oidcRequestIdentifier = requestIdentifier.get(); checkOidcState(httpServletResponse, oidcRequestIdentifier, successfulOidcResponse, false); // Get the oidc discovery url @@ -404,31 +403,16 @@ public class OIDCAccessResource extends AccessResource { * @return the authorization URI */ private URI oidcRequestAuthorizationCode(@Context HttpServletResponse httpServletResponse, String callback) { - final String oidcRequestIdentifier = UUID.randomUUID().toString(); - - // generate a cookie to associate this login sequence - final Cookie cookie = new Cookie(OIDC_REQUEST_IDENTIFIER, oidcRequestIdentifier); - cookie.setPath("/"); - cookie.setHttpOnly(true); - cookie.setMaxAge(60); - cookie.setSecure(true); - httpServletResponse.addCookie(cookie); - - // get the state for this request + applicationCookieService.addCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER, oidcRequestIdentifier); final State state = oidcService.createState(oidcRequestIdentifier); - - // build the authorization uri - final URI authorizationUri = UriBuilder.fromUri(oidcService.getAuthorizationEndpoint()) + return UriBuilder.fromUri(oidcService.getAuthorizationEndpoint()) .queryParam("client_id", oidcService.getClientId()) .queryParam("response_type", "code") .queryParam("scope", oidcService.getScope().toString()) .queryParam("state", state.getValue()) .queryParam("redirect_uri", callback) .build(); - - // return Authorization URI - return authorizationUri; } private String determineLogoutMethod(String oidcDiscoveryUrl) { @@ -475,7 +459,7 @@ public class OIDCAccessResource extends AccessResource { } @PreDestroy - private final void closeClient() throws IOException { + public void closeClient() throws IOException { httpClient.close(); } @@ -494,8 +478,8 @@ public class OIDCAccessResource extends AccessResource { return null; } - final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue(); - if (oidcRequestIdentifier == null) { + final Optional requestIdentifier = getOidcRequestIdentifier(); + if (!requestIdentifier.isPresent()) { forwardToMessagePage(httpServletRequest, httpServletResponse, pageTitle,"The request identifier was " + "not found in the request. Unable to continue."); return null; @@ -522,16 +506,12 @@ public class OIDCAccessResource extends AccessResource { // confirm state final State state = successfulOidcResponse.getState(); if (state == null || !oidcService.isStateValid(oidcRequestIdentifier, state)) { - logger.error("The state value returned by the OpenId Connect Provider does not match the stored " + - "state. Unable to continue login/logout process."); + logger.error("OIDC Request [{}] State [{}] not valid", oidcRequestIdentifier, state); - // remove the oidc request cookie removeOidcRequestCookie(httpServletResponse); - // forward to the error page forwardToMessagePage(httpServletRequest, httpServletResponse, getForwardPageTitle(isLogin), "Purposed state does not match " + "the stored state. Unable to continue login/logout process."); - return; } } @@ -552,7 +532,11 @@ public class OIDCAccessResource extends AccessResource { } private void removeOidcRequestCookie(final HttpServletResponse httpServletResponse) { - removeCookie(httpServletResponse, OIDC_REQUEST_IDENTIFIER); + applicationCookieService.removeCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER); + } + + private Optional getOidcRequestIdentifier() { + return applicationCookieService.getCookieValue(httpServletRequest, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER); } public void setOidcService(OidcService oidcService) { diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/SAMLAccessResource.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/SAMLAccessResource.java index 8d2439d23e..a37442882e 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/SAMLAccessResource.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/SAMLAccessResource.java @@ -25,19 +25,17 @@ import org.apache.nifi.authorization.util.IdentityMapping; import org.apache.nifi.authorization.util.IdentityMappingUtil; import org.apache.nifi.idp.IdpType; import org.apache.nifi.util.NiFiProperties; +import org.apache.nifi.web.api.cookie.ApplicationCookieName; import org.apache.nifi.web.security.logout.LogoutRequest; import org.apache.nifi.web.security.saml.SAMLCredentialStore; import org.apache.nifi.web.security.saml.SAMLEndpoints; import org.apache.nifi.web.security.saml.SAMLService; import org.apache.nifi.web.security.saml.SAMLStateManager; import org.apache.nifi.web.security.token.LoginAuthenticationToken; -import org.opensaml.saml2.metadata.provider.MetadataProviderException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.saml.SAMLCredential; -import org.springframework.web.util.WebUtils; -import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.Consumes; @@ -53,6 +51,7 @@ import javax.ws.rs.core.UriInfo; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -65,7 +64,6 @@ import java.util.stream.Collectors; public class SAMLAccessResource extends AccessResource { private static final Logger logger = LoggerFactory.getLogger(SAMLAccessResource.class); - private static final String SAML_REQUEST_IDENTIFIER = "saml-request-identifier"; private static final String SAML_METADATA_MEDIA_TYPE = "application/samlmetadata+xml"; private static final String LOGOUT_REQUEST_IDENTIFIER_NOT_FOUND = "The logout request identifier was not found in the request. Unable to continue."; private static final String LOGOUT_REQUEST_NOT_FOUND_FOR_GIVEN_IDENTIFIER = "No logout request was found for the given identifier. Unable to continue."; @@ -84,7 +82,7 @@ public class SAMLAccessResource extends AccessResource { value = "Retrieves the service provider metadata.", notes = NON_GUARANTEED_ENDPOINT ) - public Response samlMetadata(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { + public Response samlMetadata(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) { // only consider user specific access over https if (!httpServletRequest.isSecure()) { throw new AuthenticationNotSupportedException(AUTHENTICATION_NOT_ENABLED_MSG); @@ -122,12 +120,7 @@ public class SAMLAccessResource extends AccessResource { final String samlRequestIdentifier = UUID.randomUUID().toString(); // generate a cookie to associate this login sequence - final Cookie cookie = new Cookie(SAML_REQUEST_IDENTIFIER, samlRequestIdentifier); - cookie.setPath("/"); - cookie.setHttpOnly(true); - cookie.setMaxAge(60); - cookie.setSecure(true); - httpServletResponse.addCookie(cookie); + applicationCookieService.addCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.SAML_REQUEST_IDENTIFIER, samlRequestIdentifier); // get the state for this request final String relayState = samlStateManager.createState(samlRequestIdentifier); @@ -137,7 +130,6 @@ public class SAMLAccessResource extends AccessResource { samlService.initiateLogin(httpServletRequest, httpServletResponse, relayState); } catch (Exception e) { forwardToLoginMessagePage(httpServletRequest, httpServletResponse, e.getMessage()); - return; } } @@ -184,8 +176,8 @@ public class SAMLAccessResource extends AccessResource { initializeSamlServiceProvider(); // ensure the request has the cookie with the request id - final String samlRequestIdentifier = WebUtils.getCookie(httpServletRequest, SAML_REQUEST_IDENTIFIER).getValue(); - if (samlRequestIdentifier == null) { + final Optional requestIdentifier = getSamlRequestIdentifier(); + if (!requestIdentifier.isPresent()) { forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was not found in the request. Unable to continue."); return; } @@ -199,6 +191,7 @@ public class SAMLAccessResource extends AccessResource { } // ensure the RelayState value in the request matches the store state + final String samlRequestIdentifier = requestIdentifier.get(); if (!samlStateManager.isStateValid(samlRequestIdentifier, requestState)) { logger.error("The RelayState value returned by the SAML IDP does not match the stored state. Unable to continue login process."); removeSamlRequestCookie(httpServletResponse); @@ -258,7 +251,7 @@ public class SAMLAccessResource extends AccessResource { notes = NON_GUARANTEED_ENDPOINT ) public Response samlLoginExchange(@Context HttpServletRequest httpServletRequest, - @Context HttpServletResponse httpServletResponse) throws Exception { + @Context HttpServletResponse httpServletResponse) { // only consider user specific access over https if (!httpServletRequest.isSecure()) { @@ -271,30 +264,26 @@ public class SAMLAccessResource extends AccessResource { return Response.status(Response.Status.CONFLICT).entity(SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED).build(); } - logger.info("Attempting to exchange SAML login request for a NiFi JWT..."); - // ensure saml service provider is initialized initializeSamlServiceProvider(); // ensure the request has the cookie with the request identifier - final String samlRequestIdentifier = WebUtils.getCookie(httpServletRequest, SAML_REQUEST_IDENTIFIER).getValue(); - if (samlRequestIdentifier == null) { + final Optional requestIdentifier = getSamlRequestIdentifier(); + if (!requestIdentifier.isPresent()) { final String message = "The login request identifier was not found in the request. Unable to continue."; logger.warn(message); return Response.status(Response.Status.BAD_REQUEST).entity(message).build(); } - // remove the saml request cookie removeSamlRequestCookie(httpServletResponse); - - // get the jwt + final String samlRequestIdentifier = requestIdentifier.get(); final String jwt = samlStateManager.getJwt(samlRequestIdentifier); if (jwt == null) { throw new IllegalArgumentException("A JWT for this login request identifier could not be found. Unable to continue."); } - // generate the response - logger.info("SAML login exchange complete"); + logger.info("SAML Login Request [{}] Completed", samlRequestIdentifier); + setBearerToken(httpServletResponse, jwt); return generateOkResponse(jwt).build(); } @@ -312,13 +301,14 @@ public class SAMLAccessResource extends AccessResource { assert(isSamlEnabled(httpServletRequest, httpServletResponse, !LOGGING_IN)); // ensure the logout request identifier is present - final String logoutRequestIdentifier = WebUtils.getCookie(httpServletRequest, LOGOUT_REQUEST_IDENTIFIER).getValue(); - if (StringUtils.isBlank(logoutRequestIdentifier)) { + final Optional cookieValue = getLogoutRequestIdentifier(); + if (!cookieValue.isPresent()) { forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_IDENTIFIER_NOT_FOUND); return; } // ensure there is a logout request in progress for the given identifier + final String logoutRequestIdentifier = cookieValue.get(); final LogoutRequest logoutRequest = logoutRequestManager.get(logoutRequestIdentifier); if (logoutRequest == null) { forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_NOT_FOUND_FOR_GIVEN_IDENTIFIER); @@ -341,9 +331,8 @@ public class SAMLAccessResource extends AccessResource { try { logger.info("Initiating SAML Single Logout with IDP..."); samlService.initiateLogout(httpServletRequest, httpServletResponse, samlCredential); - } catch (Exception e) { + } catch (final Exception e) { forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, e.getMessage()); - return; } } @@ -400,13 +389,14 @@ public class SAMLAccessResource extends AccessResource { initializeSamlServiceProvider(); // ensure the logout request identifier is present - final String logoutRequestIdentifier = WebUtils.getCookie(httpServletRequest, LOGOUT_REQUEST_IDENTIFIER).getValue(); - if (StringUtils.isBlank(logoutRequestIdentifier)) { + final Optional requestIdentifier = getLogoutRequestIdentifier(); + if (!requestIdentifier.isPresent()) { forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_IDENTIFIER_NOT_FOUND); return; } // ensure there is a logout request in progress for the given identifier + final String logoutRequestIdentifier = requestIdentifier.get(); final LogoutRequest logoutRequest = logoutRequestManager.get(logoutRequestIdentifier); if (logoutRequest == null) { forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_NOT_FOUND_FOR_GIVEN_IDENTIFIER); @@ -473,7 +463,7 @@ public class SAMLAccessResource extends AccessResource { httpServletResponse.sendRedirect(getNiFiLogoutCompleteUri()); } - private void initializeSamlServiceProvider() throws MetadataProviderException { + private void initializeSamlServiceProvider() { if (!samlService.isServiceProviderInitialized()) { final String samlMetadataUri = generateResourceUri("saml", "metadata"); final String baseUri = samlMetadataUri.replace("/saml/metadata", ""); @@ -490,7 +480,7 @@ public class SAMLAccessResource extends AccessResource { } private void removeSamlRequestCookie(final HttpServletResponse httpServletResponse) { - removeCookie(httpServletResponse, SAML_REQUEST_IDENTIFIER); + applicationCookieService.removeCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.SAML_REQUEST_IDENTIFIER); } private boolean isSamlEnabled(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse, boolean isLogin) throws Exception { @@ -514,6 +504,10 @@ public class SAMLAccessResource extends AccessResource { return isLogin ? ApplicationResource.LOGIN_ERROR_TITLE : ApplicationResource.LOGOUT_ERROR_TITLE; } + private Optional getSamlRequestIdentifier() { + return applicationCookieService.getCookieValue(httpServletRequest, ApplicationCookieName.SAML_REQUEST_IDENTIFIER); + } + public void setSamlService(SAMLService samlService) { this.samlService = samlService; } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/cookie/ApplicationCookieName.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/cookie/ApplicationCookieName.java new file mode 100644 index 0000000000..cb665ebcd3 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/cookie/ApplicationCookieName.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.web.api.cookie; + +import org.apache.nifi.web.security.http.SecurityCookieName; + +/** + * Application Cookie Names + */ +public enum ApplicationCookieName { + AUTHORIZATION_BEARER(SecurityCookieName.AUTHORIZATION_BEARER.getName()), + + LOGOUT_REQUEST_IDENTIFIER("nifi-logout-request-identifier"), + + OIDC_REQUEST_IDENTIFIER("nifi-oidc-request-identifier"), + + SAML_REQUEST_IDENTIFIER("nifi-saml-request-identifier"); + + private final String cookieName; + + ApplicationCookieName(final String cookieName) { + this.cookieName = cookieName; + } + + public String getCookieName() { + return cookieName; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/cookie/ApplicationCookieService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/cookie/ApplicationCookieService.java new file mode 100644 index 0000000000..fabcc29d50 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/cookie/ApplicationCookieService.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.web.api.cookie; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.net.URI; +import java.util.Optional; + +/** + * Application Cookie Service capable of generating and retrieving HTTP Cookies using standard properties + */ +public interface ApplicationCookieService { + /** + * Generate cookie with specified value + * + * @param resourceUri Resource URI containing path and domain + * @param response HTTP Servlet Response + * @param applicationCookieName Application Cookie Name to be added + * @param value Cookie value to be added + */ + void addCookie(URI resourceUri, HttpServletResponse response, ApplicationCookieName applicationCookieName, String value); + + /** + * Generate cookie with session-based expiration and specified value + * + * @param resourceUri Resource URI containing path and domain + * @param response HTTP Servlet Response + * @param applicationCookieName Application Cookie Name + * @param value Cookie value to be added + */ + void addSessionCookie(URI resourceUri, HttpServletResponse response, ApplicationCookieName applicationCookieName, String value); + + /** + * Get cookie value using specified name + * + * @param request HTTP Servlet Response + * @param applicationCookieName Application Cookie Name to be retrieved + * @return Optional Cookie Value + */ + Optional getCookieValue(HttpServletRequest request, ApplicationCookieName applicationCookieName); + + /** + * Generate cookie with an empty value instructing the client to remove the cookie + * + * @param resourceUri Resource URI containing path and domain + * @param response HTTP Servlet Response + * @param applicationCookieName Application Cookie Name to be removed + */ + void removeCookie(URI resourceUri, HttpServletResponse response, ApplicationCookieName applicationCookieName); +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/cookie/StandardApplicationCookieService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/cookie/StandardApplicationCookieService.java new file mode 100644 index 0000000000..41267d3c82 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/cookie/StandardApplicationCookieService.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.web.api.cookie; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseCookie; +import org.springframework.web.util.WebUtils; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.HttpHeaders; +import java.net.URI; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * Standard implementation of Application Cookie Service using Spring Framework utilities + */ +public class StandardApplicationCookieService implements ApplicationCookieService { + private static final Duration MAX_AGE_SESSION = Duration.ofSeconds(-1); + + private static final Duration MAX_AGE_REMOVE = Duration.ZERO; + + private static final Duration MAX_AGE_STANDARD = Duration.ofSeconds(60); + + private static final String DEFAULT_PATH = "/"; + + private static final String SAME_SITE_STRICT = "Strict"; + + private static final boolean SECURE_ENABLED = true; + + private static final boolean HTTP_ONLY_ENABLED = true; + + private static final Logger logger = LoggerFactory.getLogger(StandardApplicationCookieService.class); + + /** + * Generate cookie with specified value + * + * @param resourceUri Resource URI containing path and domain + * @param response HTTP Servlet Response + * @param applicationCookieName Application Cookie Name to be added + * @param value Cookie value to be added + */ + @Override + public void addCookie(final URI resourceUri, final HttpServletResponse response, final ApplicationCookieName applicationCookieName, final String value) { + final ResponseCookie.ResponseCookieBuilder responseCookieBuilder = getCookieBuilder(resourceUri, applicationCookieName, value, MAX_AGE_STANDARD); + setResponseCookie(response, responseCookieBuilder.build()); + logger.debug("Added Cookie [{}] URI [{}]", applicationCookieName.getCookieName(), resourceUri); + } + + /** + * Generate cookie with session-based expiration and specified value as well as SameSite Strict property + * + * @param resourceUri Resource URI containing path and domain + * @param response HTTP Servlet Response + * @param applicationCookieName Application Cookie Name + * @param value Cookie value to be added + */ + @Override + public void addSessionCookie(final URI resourceUri, final HttpServletResponse response, final ApplicationCookieName applicationCookieName, final String value) { + final ResponseCookie.ResponseCookieBuilder responseCookieBuilder = getCookieBuilder(resourceUri, applicationCookieName, value, MAX_AGE_SESSION); + responseCookieBuilder.sameSite(SAME_SITE_STRICT); + setResponseCookie(response, responseCookieBuilder.build()); + logger.debug("Added Session Cookie [{}] URI [{}]", applicationCookieName.getCookieName(), resourceUri); + } + + /** + * Get cookie value using specified name + * + * @param request HTTP Servlet Response + * @param applicationCookieName Application Cookie Name to be retrieved + * @return Optional Cookie Value + */ + @Override + public Optional getCookieValue(final HttpServletRequest request, final ApplicationCookieName applicationCookieName) { + final Cookie cookie = WebUtils.getCookie(request, applicationCookieName.getCookieName()); + return cookie == null ? Optional.empty() : Optional.of(cookie.getValue()); + } + + /** + * Generate cookie with an empty value instructing the client to remove the cookie with a maximum age of 60 seconds + * + * @param resourceUri Resource URI containing path and domain + * @param response HTTP Servlet Response + * @param applicationCookieName Application Cookie Name to be removed + */ + @Override + public void removeCookie(final URI resourceUri, final HttpServletResponse response, final ApplicationCookieName applicationCookieName) { + Objects.requireNonNull(response, "Response required"); + final ResponseCookie.ResponseCookieBuilder responseCookieBuilder = getCookieBuilder(resourceUri, applicationCookieName, StringUtils.EMPTY, MAX_AGE_REMOVE); + setResponseCookie(response, responseCookieBuilder.build()); + logger.debug("Removed Cookie [{}] URI [{}]", applicationCookieName.getCookieName(), resourceUri); + } + + private ResponseCookie.ResponseCookieBuilder getCookieBuilder(final URI resourceUri, + final ApplicationCookieName applicationCookieName, + final String value, + final Duration maxAge) { + Objects.requireNonNull(resourceUri, "Resource URI required"); + Objects.requireNonNull(applicationCookieName, "Response Cookie Name required"); + return ResponseCookie.from(applicationCookieName.getCookieName(), value) + .path(getCookiePath(resourceUri)) + .domain(resourceUri.getHost()) + .secure(SECURE_ENABLED) + .httpOnly(HTTP_ONLY_ENABLED) + .maxAge(maxAge); + } + + private void setResponseCookie(final HttpServletResponse response, final ResponseCookie responseCookie) { + response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString()); + } + + private String getCookiePath(final URI resourceUri) { + return StringUtils.defaultIfBlank(resourceUri.getPath(), DEFAULT_PATH); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestDataTransferResource.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestDataTransferResource.java index 84573b0bd9..f5033959f6 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestDataTransferResource.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestDataTransferResource.java @@ -147,6 +147,7 @@ public class TestDataTransferResource { final URI locationUri = new URI(locationUriStr); doReturn(uriBuilder).when(uriInfo).getBaseUriBuilder(); doReturn(uriBuilder).when(uriBuilder).path(any(String.class)); + doReturn(uriBuilder).when(uriBuilder).segment(any(String.class)); doReturn(locationUri).when(uriBuilder).build(); return uriInfo; } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/cookie/StandardApplicationCookieServiceTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/cookie/StandardApplicationCookieServiceTest.java new file mode 100644 index 0000000000..0889107279 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/cookie/StandardApplicationCookieServiceTest.java @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.web.api.cookie; + +import org.apache.nifi.util.StringUtils; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockCookie; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.HttpHeaders; +import java.net.URI; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class StandardApplicationCookieServiceTest { + private static final String DOMAIN = "localhost.localdomain"; + + private static final String RESOURCE_URI = String.format("https://%s", DOMAIN); + + private static final String ROOT_PATH = "/"; + + private static final String CONTEXT_PATH = "/context"; + + private static final String CONTEXT_RESOURCE_URI = String.format("https://%s%s", DOMAIN, CONTEXT_PATH); + + private static final int EXPECTED_MAX_AGE = 60; + + private static final int SESSION_MAX_AGE = -1; + + private static final int REMOVE_MAX_AGE = 0; + + private static final String SAME_SITE_STRICT = "SameSite=Strict"; + + private static final String COOKIE_VALUE = UUID.randomUUID().toString(); + + private static final ApplicationCookieName COOKIE_NAME = ApplicationCookieName.LOGOUT_REQUEST_IDENTIFIER; + + private URI resourceUri; + + private URI contextResourceUri; + + private StandardApplicationCookieService service; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Captor + private ArgumentCaptor cookieArgumentCaptor; + + @Before + public void setService() { + service = new StandardApplicationCookieService(); + resourceUri = URI.create(RESOURCE_URI); + contextResourceUri = URI.create(CONTEXT_RESOURCE_URI); + } + + @Test + public void testAddCookie() { + service.addCookie(resourceUri, response, COOKIE_NAME, COOKIE_VALUE); + + verify(response).addHeader(eq(HttpHeaders.SET_COOKIE), cookieArgumentCaptor.capture()); + final String setCookieHeader = cookieArgumentCaptor.getValue(); + assertAddCookieMatches(setCookieHeader, ROOT_PATH, EXPECTED_MAX_AGE); + } + + @Test + public void testAddCookieContextPath() { + service.addCookie(contextResourceUri, response, COOKIE_NAME, COOKIE_VALUE); + + verify(response).addHeader(eq(HttpHeaders.SET_COOKIE), cookieArgumentCaptor.capture()); + final String setCookieHeader = cookieArgumentCaptor.getValue(); + assertAddCookieMatches(setCookieHeader, CONTEXT_PATH, EXPECTED_MAX_AGE); + } + + @Test + public void testAddSessionCookie() { + service.addSessionCookie(resourceUri, response, COOKIE_NAME, COOKIE_VALUE); + + verify(response).addHeader(eq(HttpHeaders.SET_COOKIE), cookieArgumentCaptor.capture()); + + final String setCookieHeader = cookieArgumentCaptor.getValue(); + assertAddCookieMatches(setCookieHeader, ROOT_PATH, SESSION_MAX_AGE); + assertTrue("SameSite not found", setCookieHeader.endsWith(SAME_SITE_STRICT)); + } + + @Test + public void testAddSessionCookieContextPath() { + service.addSessionCookie(contextResourceUri, response, COOKIE_NAME, COOKIE_VALUE); + + verify(response).addHeader(eq(HttpHeaders.SET_COOKIE), cookieArgumentCaptor.capture()); + + final String setCookieHeader = cookieArgumentCaptor.getValue(); + assertAddCookieMatches(setCookieHeader, CONTEXT_PATH, SESSION_MAX_AGE); + assertTrue("SameSite not found", setCookieHeader.endsWith(SAME_SITE_STRICT)); + } + + @Test + public void testGetCookieValue() { + final Cookie cookie = new Cookie(COOKIE_NAME.getCookieName(), COOKIE_VALUE); + when(request.getCookies()).thenReturn(new Cookie[]{cookie}); + final Optional cookieValue = service.getCookieValue(request, COOKIE_NAME); + assertTrue(cookieValue.isPresent()); + assertEquals(COOKIE_VALUE, cookieValue.get()); + } + + @Test + public void testGetCookieValueEmpty() { + final Optional cookieValue = service.getCookieValue(request, COOKIE_NAME); + assertFalse(cookieValue.isPresent()); + } + + @Test + public void testRemoveCookie() { + service.removeCookie(resourceUri, response, COOKIE_NAME); + + verify(response).addHeader(eq(HttpHeaders.SET_COOKIE), cookieArgumentCaptor.capture()); + final String setCookieHeader = cookieArgumentCaptor.getValue(); + assertRemoveCookieMatches(setCookieHeader, ROOT_PATH); + } + + @Test + public void testRemoveCookieContextPath() { + service.removeCookie(contextResourceUri, response, COOKIE_NAME); + + verify(response).addHeader(eq(HttpHeaders.SET_COOKIE), cookieArgumentCaptor.capture()); + final String setCookieHeader = cookieArgumentCaptor.getValue(); + assertRemoveCookieMatches(setCookieHeader, CONTEXT_PATH); + } + + private void assertAddCookieMatches(final String setCookieHeader, final String path, final long maxAge) { + final Cookie cookie = MockCookie.parse(setCookieHeader); + assertCookieMatches(setCookieHeader, cookie, path); + assertEquals(COOKIE_VALUE, cookie.getValue()); + assertEquals(maxAge, cookie.getMaxAge()); + } + + private void assertRemoveCookieMatches(final String setCookieHeader, final String path) { + final Cookie cookie = MockCookie.parse(setCookieHeader); + assertCookieMatches(setCookieHeader, cookie, path); + assertEquals(StringUtils.EMPTY, cookie.getValue()); + assertEquals(REMOVE_MAX_AGE, cookie.getMaxAge()); + } + + private void assertCookieMatches(final String setCookieHeader, final Cookie cookie, final String path) { + assertEquals("Cookie Name not matched", COOKIE_NAME.getCookieName(), cookie.getName()); + assertEquals("Path not matched", path, cookie.getPath()); + assertEquals("Domain not matched", DOMAIN, cookie.getDomain()); + assertTrue("HTTP Only not matched", cookie.isHttpOnly()); + assertTrue("Secure not matched", cookie.getSecure()); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/http/SecurityCookieName.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/http/SecurityCookieName.java index 96689c4501..9e54c29da5 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/http/SecurityCookieName.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/http/SecurityCookieName.java @@ -20,7 +20,8 @@ package org.apache.nifi.web.security.http; * Enumeration of HTTP Cookie Names for Security */ public enum SecurityCookieName { - AUTHORIZATION_BEARER("__Host-Authorization-Bearer"); + /** See IETF Cookie Prefixes Draft Section 3.1 related to Secure prefix handling */ + AUTHORIZATION_BEARER("__Secure-Authorization-Bearer"); private String name; diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/WEB-INF/pages/message-page.jsp b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/WEB-INF/pages/message-page.jsp index 9999ec0bb3..2ba6ae4a2a 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/WEB-INF/pages/message-page.jsp +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/WEB-INF/pages/message-page.jsp @@ -40,7 +40,7 @@ }).on('mouseleave', function () { $(this).removeClass('link-over'); }).on('click', function () { - window.location = '<%= contextPath %>/nifi'; + window.location = '<%= contextPath %>/nifi/'; }); }); diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/login/nf-login.js b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/login/nf-login.js index 22f1a735ff..d1cc08ccb1 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/login/nf-login.js +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/login/nf-login.js @@ -116,9 +116,9 @@ if (accessStatus.status === 'ACTIVE') { // reload as appropriate - no need to schedule token refresh as the page is reloading if (top !== window) { - parent.window.location = '/nifi'; + parent.window.location = '../nifi/'; } else { - window.location = '/nifi'; + window.location = '../nifi/'; } } else { $('#login-message-title').text('Unable to log in'); diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/logout/nf-logout.js b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/logout/nf-logout.js index 0259a2397a..d353c26639 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/logout/nf-logout.js +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/logout/nf-logout.js @@ -38,7 +38,7 @@ }).on('mouseleave', function () { $(this).removeClass('link-over'); }).on('click', function () { - window.location = '../nifi'; + window.location = '../nifi/'; }); }); })); \ No newline at end of file diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js index 41a36e9e3d..a884f556fe 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js @@ -110,9 +110,9 @@ // handle home $('#user-home').on('click', function () { if (top !== window) { - parent.window.location = '../nifi'; + parent.window.location = '../nifi/'; } else { - window.location = '../nifi'; + window.location = '../nifi/'; } }); }); diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-error-handler.js b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-error-handler.js index ff0e56f62e..583b666d46 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-error-handler.js +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-error-handler.js @@ -58,7 +58,7 @@ headerText: 'Session Expired', dialogContent: 'Your session has expired. Please press Ok to log in again.', okHandler: function () { - window.location = '/nifi'; + window.location = '../nifi/'; } }); }