NIFI-7870 Resolved access to extension resources when using JWT

- Added SameSite Session Cookie __Host-Authorization-Bearer for sending JWT
- Configured Spring Security CSRF Filter comparing Authorization header and Cookie JWT
- Implemented BearerTokenResolver for retrieving JWT

This closes #4988

Signed-off-by: David Handermann <exceptionfactory@apache.org>
This commit is contained in:
Nathan Gough 2021-02-22 11:28:01 -05:00 committed by exceptionfactory
parent 3963f66dff
commit 1090a9748a
No known key found for this signature in database
GPG Key ID: 29B6A52D2AAE8DBA
16 changed files with 457 additions and 338 deletions

View File

@ -40,7 +40,7 @@ import org.apache.nifi.reporting.Severity;
import org.apache.nifi.util.ComponentIdGenerator;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.web.security.ProxiedEntitiesUtils;
import org.apache.nifi.web.security.jwt.JwtAuthenticationFilter;
import org.apache.nifi.web.security.jwt.NiFiBearerTokenResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -244,25 +244,13 @@ public class ThreadPoolRequestReplicator implements RequestReplicator {
// remove the access token if present, since the user is already authenticated... authorization
// will happen when the request is replicated using the proxy chain above
headers.remove(JwtAuthenticationFilter.AUTHORIZATION);
headers.remove(NiFiBearerTokenResolver.AUTHORIZATION);
// if knox sso cookie name is set, remove any authentication cookie since this user is already authenticated
// and will be included in the proxied entities chain above... authorization will happen when the
// request is replicated
final String knoxCookieName = nifiProperties.getKnoxCookieName();
if (headers.containsKey("Cookie") && StringUtils.isNotBlank(knoxCookieName)) {
final String rawCookies = headers.get("Cookie");
final String[] rawCookieParts = rawCookies.split(";");
final Set<String> filteredCookieParts = Stream.of(rawCookieParts).map(String::trim).filter(cookie -> !cookie.startsWith(knoxCookieName + "=")).collect(Collectors.toSet());
// if that was the only cookie, remove it
if (filteredCookieParts.isEmpty()) {
headers.remove("Cookie");
} else {
// otherwise rebuild the cookies without the knox token
headers.put("Cookie", StringUtils.join(filteredCookieParts, "; "));
}
}
removeCookie(headers, nifiProperties.getKnoxCookieName());
removeCookie(headers, NiFiBearerTokenResolver.JWT_COOKIE_NAME);
// remove the host header
headers.remove("Host");
@ -869,4 +857,20 @@ public class ThreadPoolRequestReplicator implements RequestReplicator {
expiredRequestIds.forEach(id -> onResponseConsumed(id));
return responseMap.size();
}
private void removeCookie(Map<String, String> headers, final String cookieName) {
if (headers.containsKey("Cookie") && StringUtils.isNotBlank(cookieName)) {
final String rawCookies = headers.get("Cookie");
final String[] rawCookieParts = rawCookies.split(";");
final Set<String> filteredCookieParts = Stream.of(rawCookieParts).map(String::trim).filter(cookie -> !cookie.startsWith(cookieName + "=")).collect(Collectors.toSet());
// if that was the only cookie, remove it
if (filteredCookieParts.isEmpty()) {
headers.remove("Cookie");
} else {
// otherwise rebuild the cookies without the knox token
headers.put("Cookie", StringUtils.join(filteredCookieParts, "; "));
}
}
}
}

View File

@ -436,5 +436,11 @@
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-http</artifactId>
<version>${jetty.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,91 @@
/*
* 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;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.DefaultCsrfToken;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* A {@link CsrfTokenRepository} implementation for NiFi that matches the NiFi Cookie JWT against the
* Authorization header JWT to protect against CSRF. If the request is an idempotent method type, then only the Cookie
* is required to be present - this allows authenticating access to static resources using a Cookie. If the request is a non-idempotent
* method, NiFi requires the Authorization header (eg. for POST requests).
*/
public final class NiFiCsrfTokenRepository implements CsrfTokenRepository {
private static String EMPTY = "empty";
private CookieCsrfTokenRepository cookieRepository;
public NiFiCsrfTokenRepository() {
cookieRepository = new CookieCsrfTokenRepository();
}
@Override
public CsrfToken generateToken(HttpServletRequest request) {
// Return an empty value CsrfToken - it will not be saved to the response as our CSRF token is added elsewhere
return new DefaultCsrfToken(EMPTY, EMPTY, EMPTY);
}
@Override
public void saveToken(CsrfToken token, HttpServletRequest request,
HttpServletResponse response) {
// Do nothing - we don't need to add new CSRF tokens to the response
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
CsrfToken cookie = cookieRepository.loadToken(request);
// We add the Bearer string here in order to match the Authorization header on comparison in CsrfFilter
return cookie != null ? new DefaultCsrfToken(cookie.getHeaderName(), cookie.getParameterName(), String.format("Bearer %s", cookie.getToken())) : null;
}
/**
* Sets the name of the HTTP request parameter that should be used to provide a token.
*
* @param parameterName the name of the HTTP request parameter that should be used to
* provide a token
*/
public void setParameterName(String parameterName) {
cookieRepository.setParameterName(parameterName);
}
/**
* Sets the name of the HTTP header that should be used to provide the token.
*
* @param headerName the name of the HTTP header that should be used to provide the
* token
*/
public void setHeaderName(String headerName) {
cookieRepository.setHeaderName(headerName);
}
/**
* Sets the name of the cookie that the expected CSRF token is saved to and read from.
*
* @param cookieName the name of the cookie that the expected CSRF token is saved to
* and read from
*/
public void setCookieName(String cookieName) {
cookieRepository.setCookieName(cookieName);
}
}

View File

@ -16,12 +16,12 @@
*/
package org.apache.nifi.web;
import java.util.Arrays;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.web.security.anonymous.NiFiAnonymousAuthenticationFilter;
import org.apache.nifi.web.security.anonymous.NiFiAnonymousAuthenticationProvider;
import org.apache.nifi.web.security.jwt.JwtAuthenticationFilter;
import org.apache.nifi.web.security.jwt.JwtAuthenticationProvider;
import org.apache.nifi.web.security.jwt.NiFiBearerTokenResolver;
import org.apache.nifi.web.security.knox.KnoxAuthenticationFilter;
import org.apache.nifi.web.security.knox.KnoxAuthenticationProvider;
import org.apache.nifi.web.security.otp.OtpAuthenticationFilter;
@ -46,10 +46,15 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* NiFi Web Api Spring security. Applies the various NiFiAuthenticationFilter servlet filters which will extract authentication
* credentials from API requests.
@ -116,14 +121,16 @@ public class NiFiWebApiSecurityConfiguration extends WebSecurityConfigurerAdapte
@Override
protected void configure(HttpSecurity http) throws Exception {
NiFiCsrfTokenRepository csrfRepository = new NiFiCsrfTokenRepository();
csrfRepository.setHeaderName(NiFiBearerTokenResolver.AUTHORIZATION);
csrfRepository.setCookieName(NiFiBearerTokenResolver.JWT_COOKIE_NAME);
http
.cors().and()
.rememberMe().disable()
.authorizeRequests()
.anyRequest().fullyAuthenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
.authorizeRequests().anyRequest().fullyAuthenticated().and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.csrf().requireCsrfProtectionMatcher(new AndRequestMatcher(CsrfFilter.DEFAULT_CSRF_MATCHER, new RequestHeaderRequestMatcher("Cookie"))).csrfTokenRepository(csrfRepository);
// x509
http.addFilterBefore(x509FilterBean(), AnonymousAuthenticationFilter.class);

View File

@ -60,12 +60,13 @@ import org.apache.nifi.web.api.dto.AccessStatusDTO;
import org.apache.nifi.web.api.entity.AccessConfigurationEntity;
import org.apache.nifi.web.api.entity.AccessStatusEntity;
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.jwt.JwtAuthenticationFilter;
import org.apache.nifi.web.security.jwt.JwtAuthenticationProvider;
import org.apache.nifi.web.security.jwt.JwtAuthenticationRequestToken;
import org.apache.nifi.web.security.jwt.JwtService;
import org.apache.nifi.web.security.jwt.NiFiBearerTokenResolver;
import org.apache.nifi.web.security.kerberos.KerberosService;
import org.apache.nifi.web.security.knox.KnoxService;
import org.apache.nifi.web.security.logout.LogoutRequest;
@ -82,6 +83,8 @@ import org.apache.nifi.web.security.token.OtpAuthenticationToken;
import org.apache.nifi.web.security.x509.X509AuthenticationProvider;
import org.apache.nifi.web.security.x509.X509AuthenticationRequestToken;
import org.apache.nifi.web.security.x509.X509CertificateExtractor;
import org.eclipse.jetty.http.HttpCookie;
import org.eclipse.jetty.http.HttpHeader;
import org.opensaml.saml2.metadata.provider.MetadataProviderException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -90,6 +93,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.saml.SAMLCredential;
import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor;
import org.springframework.web.util.WebUtils;
import javax.servlet.ServletContext;
import javax.servlet.http.Cookie;
@ -106,6 +110,7 @@ import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.ResponseBuilder;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import java.io.IOException;
@ -143,6 +148,7 @@ public class AccessResource extends ApplicationResource {
private static final Pattern REVOKE_ACCESS_TOKEN_LOGOUT_FORMAT = Pattern.compile("(\\.google\\.com)");
private static final Pattern ID_TOKEN_LOGOUT_FORMAT = Pattern.compile("(\\.okta)");
private static final int msTimeout = 30_000;
private static final int VALID_FOR_SESSION_ONLY = -1;
private static final String SAML_REQUEST_IDENTIFIER = "saml-request-identifier";
private static final String SAML_METADATA_MEDIA_TYPE = "application/samlmetadata+xml";
@ -342,7 +348,7 @@ public class AccessResource extends ApplicationResource {
initializeSamlServiceProvider();
// ensure the request has the cookie with the request id
final String samlRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), SAML_REQUEST_IDENTIFIER);
final String samlRequestIdentifier = WebUtils.getCookie(httpServletRequest, SAML_REQUEST_IDENTIFIER).getValue();
if (samlRequestIdentifier == null) {
forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was not found in the request. Unable to continue.");
return;
@ -435,7 +441,7 @@ public class AccessResource extends ApplicationResource {
initializeSamlServiceProvider();
// ensure the request has the cookie with the request identifier
final String samlRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), SAML_REQUEST_IDENTIFIER);
final String samlRequestIdentifier = WebUtils.getCookie(httpServletRequest, SAML_REQUEST_IDENTIFIER).getValue();
if (samlRequestIdentifier == null) {
final String message = "The login request identifier was not found in the request. Unable to continue.";
logger.warn(message);
@ -479,7 +485,7 @@ public class AccessResource extends ApplicationResource {
}
// ensure the logout request identifier is present
final String logoutRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), LOGOUT_REQUEST_IDENTIFIER);
final String logoutRequestIdentifier = WebUtils.getCookie(httpServletRequest, LOGOUT_REQUEST_IDENTIFIER).getValue();
if (StringUtils.isBlank(logoutRequestIdentifier)) {
forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_IDENTIFIER_NOT_FOUND);
return;
@ -585,7 +591,7 @@ public class AccessResource extends ApplicationResource {
initializeSamlServiceProvider();
// ensure the logout request identifier is present
final String logoutRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), LOGOUT_REQUEST_IDENTIFIER);
final String logoutRequestIdentifier = WebUtils.getCookie(httpServletRequest, LOGOUT_REQUEST_IDENTIFIER).getValue();
if (StringUtils.isBlank(logoutRequestIdentifier)) {
forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_IDENTIFIER_NOT_FOUND);
return;
@ -732,7 +738,7 @@ public class AccessResource extends ApplicationResource {
return;
}
final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue();
if (oidcRequestIdentifier == null) {
forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was " +
"not found in the request. Unable to continue.");
@ -785,7 +791,6 @@ public class AccessResource extends ApplicationResource {
// store the NiFi token
oidcService.storeJwt(oidcRequestIdentifier, nifiJwt);
} catch (final Exception e) {
logger.error(OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage(), e);
@ -831,7 +836,7 @@ public class AccessResource extends ApplicationResource {
return Response.status(Response.Status.CONFLICT).entity(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG).build();
}
final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue();
if (oidcRequestIdentifier == null) {
final String message = "The login request identifier was not found in the request. Unable to continue.";
logger.warn(message);
@ -847,8 +852,7 @@ public class AccessResource extends ApplicationResource {
throw new IllegalArgumentException("A JWT for this login request identifier could not be found. Unable to continue.");
}
// generate the response
return generateOkResponse(jwt).build();
return generateTokenResponse(generateOkResponse(jwt), jwt);
}
@GET
@ -868,6 +872,10 @@ public class AccessResource extends ApplicationResource {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
}
final String mappedUserIdentity = NiFiUserUtils.getNiFiUserIdentity();
removeCookie(httpServletResponse, NiFiBearerTokenResolver.JWT_COOKIE_NAME);
logger.debug("Invalidated JWT for user [{}]", mappedUserIdentity);
// Get the oidc discovery url
String oidcDiscoveryUrl = properties.getOidcDiscoveryUrl();
@ -920,7 +928,7 @@ public class AccessResource extends ApplicationResource {
return;
}
final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue();
if (oidcRequestIdentifier == null) {
forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was " +
"not found in the request. Unable to continue.");
@ -1164,8 +1172,8 @@ public class AccessResource extends ApplicationResource {
// if there is not certificate, consider a token
if (certificates == null) {
// look for an authorization token
final String authorization = httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION);
// look for an authorization token in header or cookie
final String authorization = new NiFiBearerTokenResolver().resolve(httpServletRequest);
// if there is no authorization header, we don't know the user
if (authorization == null) {
@ -1173,10 +1181,8 @@ public class AccessResource extends ApplicationResource {
accessStatus.setMessage("No credentials supplied, unknown user.");
} else {
try {
// Extract the Base64 encoded token from the Authorization header
final String token = StringUtils.substringAfterLast(authorization, " ");
final JwtAuthenticationRequestToken jwtRequest = new JwtAuthenticationRequestToken(token, httpServletRequest.getRemoteAddr());
// authenticate the token
final JwtAuthenticationRequestToken jwtRequest = new JwtAuthenticationRequestToken(authorization, httpServletRequest.getRemoteAddr());
final NiFiAuthenticationToken authenticationResponse = (NiFiAuthenticationToken) jwtAuthenticationProvider.authenticate(jwtRequest);
final NiFiUser nifiUser = ((NiFiUserDetails) authenticationResponse.getDetails()).getNiFiUser();
@ -1328,7 +1334,7 @@ public class AccessResource extends ApplicationResource {
value = "Creates a token for accessing the REST API via Kerberos ticket exchange / SPNEGO negotiation",
notes = "The token returned is formatted as a JSON Web Token (JWT). The token is base64 encoded and comprised of three parts. The header, " +
"the body, and the signature. The expiration of the token is a contained within the body. The token can be used in the Authorization header " +
"in the format 'Authorization: Bearer <token>'.",
"in the format 'Authorization: Bearer <token>'. It is also stored in the browser as a cookie.",
response = String.class
)
@ApiResponses(
@ -1383,7 +1389,7 @@ public class AccessResource extends ApplicationResource {
// build the response
final URI uri = URI.create(generateResourceUri("access", "kerberos"));
return generateCreatedResponse(uri, token).build();
return generateTokenResponse(generateCreatedResponse(uri, token), token);
} catch (final AuthenticationException e) {
throw new AccessDeniedException(e.getMessage(), e);
}
@ -1391,12 +1397,12 @@ public class AccessResource extends ApplicationResource {
}
/**
* Creates a token for accessing the REST API via username/password.
* Creates a token for accessing the REST API via username/password stored as a cookie in the browser.
*
* @param httpServletRequest the servlet request
* @param username the username
* @param password the password
* @return A JWT (string)
* @return A JWT (string) in a cookie and as the body
*/
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@ -1405,8 +1411,8 @@ public class AccessResource extends ApplicationResource {
@ApiOperation(
value = "Creates a token for accessing the REST API via username/password",
notes = "The token returned is formatted as a JSON Web Token (JWT). The token is base64 encoded and comprised of three parts. The header, " +
"the body, and the signature. The expiration of the token is a contained within the body. The token can be used in the Authorization header " +
"in the format 'Authorization: Bearer <token>'.",
"the body, and the signature. The expiration of the token is a contained within the body. It is stored in the browser as a cookie, but also returned in" +
"the response body to be stored/used by third party client scripts.",
response = String.class
)
@ApiResponses(
@ -1459,7 +1465,7 @@ public class AccessResource extends ApplicationResource {
// build the response
final URI uri = URI.create(generateResourceUri("access", "token"));
return generateCreatedResponse(uri, token).build();
return generateTokenResponse(generateCreatedResponse(uri, token), token);
}
@DELETE
@ -1490,8 +1496,9 @@ public class AccessResource extends ApplicationResource {
try {
logger.info("Logging out " + mappedUserIdentity);
jwtService.logOutUsingAuthHeader(httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION));
logger.info("Successfully invalidated JWT for " + mappedUserIdentity);
logOutUser(httpServletRequest);
removeCookie(httpServletResponse, NiFiBearerTokenResolver.JWT_COOKIE_NAME);
logger.debug("Invalidated JWT for user [{}]", mappedUserIdentity);
// create a LogoutRequest and tell the LogoutRequestManager about it for later retrieval
final LogoutRequest logoutRequest = new LogoutRequest(UUID.randomUUID().toString(), mappedUserIdentity);
@ -1507,7 +1514,10 @@ public class AccessResource extends ApplicationResource {
return generateOkResponse().build();
} catch (final JwtException e) {
logger.error("Logout of user " + mappedUserIdentity + " failed due to: " + e.getMessage(), e);
logger.error("JWT processing failed for [{}], due to: ", mappedUserIdentity, e.getMessage(), e);
return Response.serverError().build();
} catch (final LogoutException e) {
logger.error("Logout failed for user [{}] due to: ", mappedUserIdentity, e.getMessage(), e);
return Response.serverError().build();
}
}
@ -1543,7 +1553,7 @@ public class AccessResource extends ApplicationResource {
LogoutRequest logoutRequest = null;
// check if a logout request identifier is present and if so complete the request
final String logoutRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), LOGOUT_REQUEST_IDENTIFIER);
final String logoutRequestIdentifier = WebUtils.getCookie(httpServletRequest, LOGOUT_REQUEST_IDENTIFIER).getValue();
if (logoutRequestIdentifier != null) {
logoutRequest = logoutRequestManager.complete(logoutRequestIdentifier);
}
@ -1577,25 +1587,6 @@ public class AccessResource extends ApplicationResource {
return proposedTokenExpiration;
}
/**
* Gets the value of a cookie matching the specified name. If no cookie with that name exists, null is returned.
*
* @param cookies the cookies
* @param name the name of the cookie
* @return the value of the corresponding cookie, or null if the cookie does not exist
*/
private String getCookieValue(final Cookie[] cookies, final String name) {
if (cookies != null) {
for (final Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
private String getOidcCallback() {
return generateResourceUri("access", "oidc", "callback");
}
@ -1812,4 +1803,14 @@ public class AccessResource extends ApplicationResource {
this.logoutRequestManager = logoutRequestManager;
}
private void logOutUser(HttpServletRequest httpServletRequest) {
final String jwt = new NiFiBearerTokenResolver().resolve(httpServletRequest);
jwtService.logOut(jwt);
}
private 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(NiFiBearerTokenResolver.JWT_COOKIE_NAME, token, null, "/", VALID_FOR_SESSION_ONLY, true, true, null, 0, HttpCookie.SameSite.STRICT);
return builder.header(HttpHeader.SET_COOKIE.asString(), jwtCookie.getRFC6265SetCookie()).build();
}
}

View File

@ -0,0 +1,35 @@
/*
* 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.security;
import org.springframework.security.core.AuthenticationException;
/**
* Thrown if the authentication of a given request is invalid. For instance,
* an expired certificate or token.
*/
public class LogoutException extends AuthenticationException {
public LogoutException(String msg) {
super(msg);
}
public LogoutException(String msg, Throwable t) {
super(msg, t);
}
}

View File

@ -0,0 +1,30 @@
/*
* 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.security.jwt;
import javax.servlet.http.HttpServletRequest;
public interface BearerTokenResolver {
/**
* Resolve any
* <a href="https://tools.ietf.org/html/rfc6750#section-1.2" target="_blank">Bearer
* Token</a> value from the request.
* @param request the request
* @return the Bearer Token value or {@code null} if none found
*/
String resolve(HttpServletRequest request);
}

View File

@ -16,60 +16,35 @@
*/
package org.apache.nifi.web.security.jwt;
import org.apache.nifi.web.security.InvalidAuthenticationException;
import org.apache.nifi.util.StringUtils;
import org.apache.nifi.web.security.NiFiAuthenticationFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import javax.servlet.http.HttpServletRequest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
*/
public class JwtAuthenticationFilter extends NiFiAuthenticationFilter {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
// The Authorization header contains authentication credentials
public static final String AUTHORIZATION = "Authorization";
private static final Pattern tokenPattern = Pattern.compile("^Bearer (\\S*\\.\\S*\\.\\S*)$");
private static NiFiBearerTokenResolver bearerTokenResolver = new NiFiBearerTokenResolver();
@Override
public Authentication attemptAuthentication(final HttpServletRequest request) {
// only support jwt login when running securely
// Only support JWT login when running securely
if (!request.isSecure()) {
return null;
}
// TODO: Refactor request header extraction logic to shared utility as it is duplicated in AccessResource
// Get JWT from Authorization header or cookie value
final String headerToken = bearerTokenResolver.resolve(request);
// get the principal out of the user token
final String authorizationHeader = request.getHeader(AUTHORIZATION);
// if there is no authorization header, we don't know the user
if (authorizationHeader == null || !validJwtFormat(authorizationHeader)) {
if (StringUtils.isNotBlank(headerToken)) {
return new JwtAuthenticationRequestToken(headerToken, request.getRemoteAddr());
} else {
return null;
} else {
// Extract the Base64 encoded token from the Authorization header
final String token = getTokenFromHeader(authorizationHeader);
return new JwtAuthenticationRequestToken(token, request.getRemoteAddr());
}
}
private boolean validJwtFormat(String authenticationHeader) {
Matcher matcher = tokenPattern.matcher(authenticationHeader);
return matcher.matches();
}
public static String getTokenFromHeader(String authenticationHeader) {
Matcher matcher = tokenPattern.matcher(authenticationHeader);
if(matcher.matches()) {
return matcher.group(1);
} else {
throw new InvalidAuthenticationException("JWT did not match expected pattern.");
}
}
}

View File

@ -31,6 +31,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.admin.service.AdministrationException;
import org.apache.nifi.admin.service.KeyService;
import org.apache.nifi.key.Key;
import org.apache.nifi.web.security.LogoutException;
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
import org.slf4j.LoggerFactory;
@ -186,7 +187,7 @@ public class JwtService {
* @throws JwtException if there is a problem with the token input
* @throws Exception if there is an issue logging the user out
*/
public void logOut(String token) {
public void logOut(String token) throws LogoutException {
Jws<Claims> claims = parseTokenFromBase64EncodedString(token);
// Get the key ID from the claims
@ -199,13 +200,9 @@ public class JwtService {
try {
keyService.deleteKey(keyId);
} catch (Exception e) {
logger.error("The key with key ID: " + keyId + " failed to be removed from the user database.");
throw e;
final String errorMessage = String.format("The key with key ID: %s failed to be removed from the user database.", keyId);
logger.error(errorMessage);
throw new LogoutException(errorMessage);
}
}
public void logOutUsingAuthHeader(String authorizationHeader) {
String base64EncodedToken = JwtAuthenticationFilter.getTokenFromHeader(authorizationHeader);
logOut(base64EncodedToken);
}
}

View File

@ -0,0 +1,70 @@
/*
* 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.security.jwt;
import org.apache.nifi.util.StringUtils;
import org.apache.nifi.web.security.InvalidAuthenticationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.util.WebUtils;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class NiFiBearerTokenResolver implements BearerTokenResolver {
private static final Logger logger = LoggerFactory.getLogger(NiFiBearerTokenResolver.class);
private static final Pattern BEARER_HEADER_PATTERN = Pattern.compile("^Bearer (\\S*\\.\\S*\\.\\S*){1}$");
private static final Pattern JWT_PATTERN = Pattern.compile("^(\\S*\\.\\S*\\.\\S*)$");
public static final String AUTHORIZATION = "Authorization";
public static final String JWT_COOKIE_NAME = "__Host-Authorization-Bearer";
@Override
public String resolve(HttpServletRequest request) {
final String authorizationHeader = request.getHeader(AUTHORIZATION);
final Cookie cookieHeader = WebUtils.getCookie(request, JWT_COOKIE_NAME);
if (StringUtils.isNotBlank(authorizationHeader) && validAuthorizationHeaderFormat(authorizationHeader)) {
return getTokenFromHeader(authorizationHeader);
} else if(cookieHeader != null && validJwtFormat(cookieHeader.getValue())) {
return cookieHeader.getValue();
} else {
logger.debug("Authorization header was not present or not in a valid format.");
return null;
}
}
private boolean validAuthorizationHeaderFormat(String authorizationHeader) {
Matcher matcher = BEARER_HEADER_PATTERN.matcher(authorizationHeader);
return matcher.matches();
}
private boolean validJwtFormat(String jwt) {
Matcher matcher = JWT_PATTERN.matcher(jwt);
return matcher.matches();
}
private String getTokenFromHeader(String authenticationHeader) {
Matcher matcher = BEARER_HEADER_PATTERN.matcher(authenticationHeader);
if (matcher.matches()) {
return matcher.group(1);
} else {
throw new InvalidAuthenticationException("JWT did not match expected pattern.");
}
}
}

View File

@ -1,180 +0,0 @@
/*
* 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.security.jwt
import groovy.json.JsonOutput
import org.apache.nifi.web.security.InvalidAuthenticationException
import org.junit.After
import org.junit.AfterClass
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.rules.ExpectedException
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@RunWith(JUnit4.class)
class JwtAuthenticationFilterTest extends GroovyTestCase {
public static String jwtString
@Rule
public ExpectedException expectedException = ExpectedException.none()
@BeforeClass
static void setUpOnce() throws Exception {
final String ALG_HEADER = "{\"alg\":\"HS256\"}"
final int EXPIRATION_SECONDS = 500
Calendar now = Calendar.getInstance()
final long currentTime = (long) (now.getTimeInMillis() / 1000.0)
final long TOKEN_ISSUED_AT = currentTime
final long TOKEN_EXPIRATION_SECONDS = currentTime + EXPIRATION_SECONDS
// Generate a token that we will add a valid signature from a different token
// Always use LinkedHashMap to enforce order of the keys because the signature depends on order
final String EXPECTED_PAYLOAD =
JsonOutput.toJson(
sub:'unknownuser',
iss:'MockIdentityProvider',
aud:'MockIdentityProvider',
preferred_username:'unknownuser',
kid:1,
exp:TOKEN_EXPIRATION_SECONDS,
iat:TOKEN_ISSUED_AT)
// Set up our JWT string with a test token
jwtString = JwtServiceTest.generateHS256Token(ALG_HEADER, EXPECTED_PAYLOAD, true, true)
}
@AfterClass
static void tearDownOnce() throws Exception {
}
@Before
void setUp() throws Exception {
}
@After
void tearDown() throws Exception {
}
@Test
void testValidAuthenticationHeaderString() {
// Arrange
String authenticationHeader = "Bearer " + jwtString
// Act
boolean isValidHeader = new JwtAuthenticationFilter().validJwtFormat(authenticationHeader)
// Assert
assertTrue(isValidHeader)
}
@Test
void testMissingBearer() {
// Arrange
// Act
boolean isValidHeader = new JwtAuthenticationFilter().validJwtFormat(jwtString)
// Assert
assertFalse(isValidHeader)
}
@Test
void testExtraCharactersAtBeginningOfToken() {
// Arrange
String authenticationHeader = "xBearer " + jwtString
// Act
boolean isValidToken = new JwtAuthenticationFilter().validJwtFormat(authenticationHeader)
// Assert
assertFalse(isValidToken)
}
@Test
void testBadTokenFormat() {
// Arrange
String[] tokenStrings = jwtString.split("\\.")
String badToken = "Bearer " + tokenStrings[1] + tokenStrings[2]
// Act
boolean isValidToken = new JwtAuthenticationFilter().validJwtFormat(badToken)
// Assert
assertFalse(isValidToken)
}
@Test
void testMultipleTokenInvalid() {
// Arrange
String authenticationHeader = "Bearer " + jwtString
authenticationHeader = authenticationHeader + " " + authenticationHeader
// Act
boolean isValidToken = new JwtAuthenticationFilter().validJwtFormat(authenticationHeader)
// Assert
assertFalse(isValidToken)
}
@Test
void testExtractToken() {
// Arrange
String authenticationHeader = "Bearer " + jwtString
// Act
String extractedToken = new JwtAuthenticationFilter().getTokenFromHeader(authenticationHeader)
// Assert
assertEquals(jwtString, extractedToken)
}
@Test
void testMultipleTokenDottedInvalid() {
// Arrange
String authenticationHeader = "Bearer " + jwtString
authenticationHeader = authenticationHeader + "." + authenticationHeader
// Act
boolean isValidToken = new JwtAuthenticationFilter().validJwtFormat(authenticationHeader)
// Assert
assertFalse(isValidToken)
}
@Test
void testMultipleTokenNotExtracted() {
// Arrange
expectedException.expect(InvalidAuthenticationException.class)
expectedException.expectMessage("JWT did not match expected pattern.")
String authenticationHeader = "Bearer " + jwtString
authenticationHeader = authenticationHeader + " " + authenticationHeader
// Act
String token = new JwtAuthenticationFilter().getTokenFromHeader(authenticationHeader)
// Assert
// Expect InvalidAuthenticationException
}
}

View File

@ -598,38 +598,6 @@ public class JwtServiceTest {
// Should throw exception when user is not found
}
@Test
public void testShouldLogOutUserUsingAuthHeader() throws Exception {
// Arrange
expectedException.expect(JwtException.class);
expectedException.expectMessage("Unable to validate the access token.");
// Token expires in 60 seconds
final int EXPIRATION_MILLIS = 60000;
LoginAuthenticationToken loginAuthenticationToken = new LoginAuthenticationToken(DEFAULT_IDENTITY,
EXPIRATION_MILLIS,
"MockIdentityProvider");
logger.info("Generating token for " + loginAuthenticationToken);
// Act
String token = jwtService.generateSignedToken(loginAuthenticationToken);
logger.info("Generated JWT: " + token);
logger.info("Validating token...");
String authID = jwtService.getAuthenticationFromToken(token);
assertEquals(DEFAULT_IDENTITY, authID);
logger.info("Token was valid");
logger.info("Logging out user: " + authID);
String header = "Bearer " + token;
jwtService.logOutUsingAuthHeader(header);
logger.info("Logged out user: " + authID);
logger.info("Checking that token is now invalid...");
jwtService.getAuthenticationFromToken(token);
// Assert
// Should throw exception when user is not found
}
@Test
public void testLogoutWhenAuthTokenIsEmptyShouldThrowError() throws Exception {
// Arrange

View File

@ -0,0 +1,124 @@
/*
* 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.security.jwt;
import groovy.json.JsonOutput;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.Mock;
import javax.servlet.http.HttpServletRequest;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class NiFiBearerTokenResolverTest {
public static String jwtString;
@Mock
private static HttpServletRequest request;
@BeforeClass
public static void setUpOnce() throws Exception {
final String ALG_HEADER = "{\"alg\":\"HS256\"}";
final int EXPIRATION_SECONDS = 500;
Calendar now = Calendar.getInstance();
final long currentTime = (long) (now.getTimeInMillis() / 1000.0);
final long TOKEN_ISSUED_AT = currentTime;
final long TOKEN_EXPIRATION_SECONDS = currentTime + EXPIRATION_SECONDS;
Map<String, String> hashMap = new HashMap<String, String>() {{
put("sub", "unknownuser");
put("iss", "MockIdentityProvider");
put("aud", "MockIdentityProvider");
put("preferred_username", "unknownuser");
put("kid", String.valueOf(1));
put("exp", String.valueOf(TOKEN_EXPIRATION_SECONDS));
put("iat", String.valueOf(TOKEN_ISSUED_AT));
}};
// Generate a token that we will add a valid signature from a different token
// Always use LinkedHashMap to enforce order of the keys because the signature depends on order
final String EXPECTED_PAYLOAD = JsonOutput.toJson(hashMap);
// Set up our JWT string with a test token
jwtString = JwtServiceTest.generateHS256Token(ALG_HEADER, EXPECTED_PAYLOAD, true, true);
request = mock(HttpServletRequest.class);
}
@Test
public void testValidAuthenticationHeaderString() {
String authenticationHeader = "Bearer " + jwtString;
when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(authenticationHeader);
String isValidHeader = new NiFiBearerTokenResolver().resolve(request);
assertEquals(jwtString, isValidHeader);
}
@Test
public void testMissingBearer() {
String authenticationHeader = jwtString;
when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(authenticationHeader);
String resolvedToken = new NiFiBearerTokenResolver().resolve(request);
assertNull(resolvedToken);
}
@Test
public void testExtraCharactersAtBeginningOfToken() {
String authenticationHeader = "xBearer " + jwtString;
when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(authenticationHeader);
String resolvedToken = new NiFiBearerTokenResolver().resolve(request);
assertNull(resolvedToken);
}
@Test
public void testBadTokenFormat() {
String[] tokenStrings = jwtString.split("\\.");
when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(String.valueOf("Bearer " + tokenStrings[1] + tokenStrings[2]));
String resolvedToken = new NiFiBearerTokenResolver().resolve(request);
assertNull(resolvedToken);
}
@Test
public void testMultipleTokenInvalid() {
String authenticationHeader = "Bearer " + jwtString;
when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(String.format("%s %s", authenticationHeader, authenticationHeader));
String resolvedToken = new NiFiBearerTokenResolver().resolve(request);
assertNull(resolvedToken);
}
@Test
public void testExtractToken() {
String authenticationHeader = "Bearer " + jwtString;
when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(authenticationHeader);
String extractedToken = new NiFiBearerTokenResolver().resolve(request);
assertEquals(jwtString, extractedToken);
}
}

View File

@ -45,7 +45,7 @@
// In case no further requests will be successful based on the status,
// the canvas is disabled, and the message pane is shown.
if ($('#message-pane').is(':visible')) {
nfCommon.showLogoutLink();
nfCommon.updateLogoutLink();
// hide the splash screen if required
if ($('#splash').is(':visible')) {

View File

@ -99,7 +99,7 @@
'password': $('#password').val()
}
}).done(function (jwt) {
// get the payload and store the token with the appropirate expiration
// Get the payload and store the token with the appropriate expiration. JWT is also stored automatically in a cookie.
var token = nfCommon.getJwtPayload(jwt);
var expiration = parseInt(token['exp'], 10) * nfCommon.MILLIS_PER_SECOND;
nfStorage.setItem('jwt', jwt, expiration);
@ -112,9 +112,6 @@
}).done(function (response) {
var accessStatus = response.accessStatus;
// update the logout link appropriately
showLogoutLink();
// update according to the access status
if (accessStatus.status === 'ACTIVE') {
// reload as appropriate - no need to schedule token refresh as the page is reloading
@ -155,10 +152,6 @@
});
};
var showLogoutLink = function () {
nfCommon.showLogoutLink();
};
var nfLogin = {
/**
* Initializes the login page.
@ -166,9 +159,7 @@
init: function () {
nfStorage.init();
if (nfStorage.getItem('jwt') !== null) {
showLogoutLink();
}
nfCommon.updateLogoutLink();
// supporting logging in via enter press
$('#username, #password').on('keyup', function (e) {

View File

@ -852,11 +852,11 @@
/**
* Shows the logout link if appropriate.
*/
showLogoutLink: function () {
if (nfStorage.getItem('jwt') === null) {
$('#user-logout-container').css('display', 'none');
} else {
updateLogoutLink: function () {
if (nfStorage.getItem('jwt') !== null) {
$('#user-logout-container').css('display', 'block');
} else {
$('#user-logout-container').css('display', 'none');
}
},