NIFI-10177: Implemented ID token logout and revoke access token logout for NiFi Registry when using OIDC/OAuth 2.0 providers

NIFI-10177: Addressed latest PR reviews. Reworded comments in the logout endpoint, use nifi registry properties to configure HTTP client timeouts for OIDC logout request, used NiFiUserUtils.getNiFiUserIdentity to retrieve identity used to delete the key

Signed-off-by: Nathan Gough <thenatog@gmail.com>

This closes #6637.
This commit is contained in:
Emilio Setiadarma 2022-11-03 19:51:08 -07:00 committed by Nathan Gough
parent 282c56b5ce
commit 844751cec0
13 changed files with 536 additions and 109 deletions

View File

@ -260,6 +260,13 @@ The following binary components are provided under the Apache Software License v
Guava
Copyright 2015 The Guava Authors
(ASLv2) Apache HttpComponents Client
The following NOTICE information applies:
Copyright 1999-2022 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (https://www.apache.org/).
************************
Common Development and Distribution License 1.1
************************

View File

@ -480,5 +480,9 @@
<version>2.5.18</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -20,6 +20,7 @@ import com.nimbusds.oauth2.sdk.AuthorizationCode;
import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
import com.nimbusds.oauth2.sdk.AuthorizationGrant;
import com.nimbusds.oauth2.sdk.ParseException;
import com.nimbusds.oauth2.sdk.http.HTTPResponse;
import com.nimbusds.oauth2.sdk.id.State;
import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse;
import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser;
@ -31,6 +32,14 @@ import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import io.swagger.annotations.Authorization;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.NameValuePair;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.apache.nifi.registry.authorization.CurrentUser;
import org.apache.nifi.registry.event.EventService;
import org.apache.nifi.registry.exception.AdministrationException;
@ -44,6 +53,7 @@ import org.apache.nifi.registry.security.authentication.exception.IdentityAccess
import org.apache.nifi.registry.security.authentication.exception.InvalidCredentialsException;
import org.apache.nifi.registry.security.authorization.user.NiFiUser;
import org.apache.nifi.registry.security.authorization.user.NiFiUserUtils;
import org.apache.nifi.registry.util.FormatUtils;
import org.apache.nifi.registry.web.exception.UnauthorizedException;
import org.apache.nifi.registry.web.security.authentication.jwt.JwtService;
import org.apache.nifi.registry.web.security.authentication.kerberos.KerberosSpnegoIdentityProvider;
@ -56,6 +66,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import javax.net.ssl.SSLContext;
import javax.servlet.ServletContext;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
@ -72,11 +83,14 @@ import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import java.io.IOException;
import java.net.URI;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Component
@ -91,6 +105,9 @@ public class AccessResource extends ApplicationResource {
private static final String OIDC_REQUEST_IDENTIFIER = "oidc-request-identifier";
private static final String OIDC_ERROR_TITLE = "Unable to continue login sequence";
private static final String REVOKE_ACCESS_TOKEN_LOGOUT = "oidc_access_token_logout";
private static final String ID_TOKEN_LOGOUT = "oidc_id_token_logout";
private static final String STANDARD_LOGOUT = "oidc_standard_logout";
private NiFiRegistryProperties properties;
private JwtService jwtService;
@ -298,17 +315,17 @@ public class AccessResource extends ApplicationResource {
@ApiResponse(code = 500, message = "Client failed to log out."),
}
)
public Response logOut(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) {
public Response logout(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) {
if (!httpServletRequest.isSecure()) {
throw new IllegalStateException("User authentication/authorization is only supported when running over HTTPS.");
}
String userIdentity = NiFiUserUtils.getNiFiUserIdentity();
final String userIdentity = NiFiUserUtils.getNiFiUserIdentity();
if(userIdentity != null && !userIdentity.isEmpty()) {
try {
logger.info("Logging out user " + userIdentity);
jwtService.logOut(userIdentity);
jwtService.deleteKey(userIdentity);
return generateOkResponse().build();
} catch (final JwtException e) {
logger.error("Logout of user " + userIdentity + " failed due to: " + e.getMessage());
@ -319,6 +336,30 @@ public class AccessResource extends ApplicationResource {
}
}
@GET
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.WILDCARD)
@Path("/logout/complete")
@ApiOperation(
value = "Completes the logout sequence.",
notes = NON_GUARANTEED_ENDPOINT
)
@ApiResponses(
value = {
@ApiResponse(code = 200, message = "User was logged out successfully."),
@ApiResponse(code = 401, message = "Authentication token provided was empty or not in the correct JWT format."),
@ApiResponse(code = 500, message = "Client failed to log out."),
}
)
public void logoutComplete(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws IOException {
if (!httpServletRequest.isSecure()) {
throw new IllegalStateException("User logout is only supported when running over HTTPS.");
}
// redirect to NiFi Registry page after logout completes
httpServletResponse.sendRedirect(getNiFiRegistryUri());
}
@POST
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.TEXT_PLAIN)
@ -542,30 +583,10 @@ public class AccessResource extends ApplicationResource {
throw new IllegalStateException("OpenId Connect is not configured.");
}
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
final State state = oidcService.createState(oidcRequestIdentifier);
// build the authorization uri
final URI authorizationUri = 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", getOidcCallback())
.build();
final URI authorizationURI = oidcRequestAuthorizationCode(httpServletResponse, getOidcCallback());
// generate the response
httpServletResponse.sendRedirect(authorizationUri.toString());
httpServletResponse.sendRedirect(authorizationURI.toString());
}
@GET
@ -589,44 +610,25 @@ public class AccessResource extends ApplicationResource {
throw new IllegalStateException("OpenId Connect is not configured.");
}
final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
final String oidcRequestIdentifier = getOidcRequestIdentifier(httpServletRequest);
if (oidcRequestIdentifier == null) {
throw new IllegalStateException("The login request identifier was not found in the request. Unable to continue.");
}
final com.nimbusds.openid.connect.sdk.AuthenticationResponse oidcResponse;
try {
oidcResponse = AuthenticationResponseParser.parse(getRequestUri());
} catch (final ParseException e) {
logger.error("Unable to parse the redirect URI from the OpenId Connect Provider. Unable to continue login process.");
// remove the oidc request cookie
removeOidcRequestCookie(httpServletResponse);
// forward to the error page
throw new IllegalStateException("Unable to parse the redirect URI from the OpenId Connect Provider. Unable to continue login process.");
}
final com.nimbusds.openid.connect.sdk.AuthenticationResponse oidcResponse =
parseAuthenticationResponse(getRequestUri(), httpServletResponse, true);
if (oidcResponse.indicatesSuccess()) {
final AuthenticationSuccessResponse successfulOidcResponse = (AuthenticationSuccessResponse) oidcResponse;
// 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 process.");
// remove the oidc request cookie
removeOidcRequestCookie(httpServletResponse);
// forward to the error page
throw new IllegalStateException("Purposed state does not match the stored state. Unable to continue login process.");
}
validateOIDCState(oidcRequestIdentifier, successfulOidcResponse, httpServletResponse, true);
try {
// exchange authorization code for id token
final AuthorizationCode authorizationCode = successfulOidcResponse.getAuthorizationCode();
final AuthorizationGrant authorizationGrant = new AuthorizationCodeGrant(authorizationCode, URI.create(getOidcCallback()));
oidcService.exchangeAuthorizationCode(oidcRequestIdentifier, authorizationGrant);
oidcService.exchangeAuthorizationCodeForLoginAuthenticationToken(oidcRequestIdentifier, authorizationGrant);
} catch (final Exception e) {
logger.error("Unable to exchange authorization for ID token: " + e.getMessage(), e);
@ -669,7 +671,7 @@ public class AccessResource extends ApplicationResource {
throw new IllegalStateException("OpenId Connect is not configured.");
}
final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
final String oidcRequestIdentifier = getOidcRequestIdentifier(httpServletRequest);
if (oidcRequestIdentifier == null) {
throw new IllegalArgumentException("The login request identifier was not found in the request. Unable to continue.");
}
@ -687,7 +689,7 @@ public class AccessResource extends ApplicationResource {
return generateOkResponse(jwt).build();
}
@DELETE
@GET
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.WILDCARD)
@Path("/oidc/logout")
@ -704,20 +706,130 @@ public class AccessResource extends ApplicationResource {
throw new IllegalStateException("OpenId Connect is not configured.");
}
final String tokenHeader = httpServletRequest.getHeader(JwtService.AUTHORIZATION);
jwtService.logOutUsingAuthHeader(tokenHeader);
// Checks if OIDC service supports logout using either by invoking the revocation endpoint (for OAuth 2.0 providers)
// or the end session endpoint (for OIDC providers). If either is supported, send a request to get an authorization
// code that can be eventually exchanged for a token that is required as a parameter for the logout request.
final String logoutMethod = determineLogoutMethod();
switch (logoutMethod) {
case REVOKE_ACCESS_TOKEN_LOGOUT:
case ID_TOKEN_LOGOUT:
final URI authorizationURI = oidcRequestAuthorizationCode(httpServletResponse, getOidcLogoutCallback());
httpServletResponse.sendRedirect(authorizationURI.toString());
break;
default:
// If the above logout methods are not supported, last ditch effort to logout by providing the client_id,
// to the end session endpoint if it exists. This is a way to logout defined in the OIDC specs, but the
// id_token_hint logout method is recommended. This option is not available when using the POST request
// to the revocation endpoint (OAuth 2.0 providers).
final URI endSessionEndpoint = oidcService.getEndSessionEndpoint();
if (endSessionEndpoint != null) {
final String postLogoutRedirectUri = getNiFiRegistryUri();
final URI logoutUri = UriBuilder.fromUri(endSessionEndpoint)
.queryParam("post_logout_redirect_uri", postLogoutRedirectUri)
.queryParam("client_id", oidcService.getClientId())
.build();
httpServletResponse.sendRedirect(logoutUri.toString());
} else {
throw new IllegalStateException("Unable to initiate logout: Logout method unrecognized");
}
break;
}
}
URI endSessionEndpoint = oidcService.getEndSessionEndpoint();
String postLogoutRedirectUri = generateResourceUri("..", "nifi-registry");
@GET
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.WILDCARD)
@Path("/oidc/logout/callback")
@ApiOperation(
value = "Redirect/callback URI for processing the result of the OpenId Connect logout sequence.",
notes = NON_GUARANTEED_ENDPOINT
)
public void oidcLogoutCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
if (!httpServletRequest.isSecure()) {
throw new IllegalStateException("User logout is only supported when running over HTTPS.");
}
if (endSessionEndpoint == null) {
// handle the case, where the OpenID Provider does not have an end session endpoint
//httpServletResponse.sendRedirect(postLogoutRedirectUri);
if (!oidcService.isOidcEnabled()) {
throw new IllegalStateException("OpenId Connect is not configured.");
}
final String oidcRequestIdentifier = getOidcRequestIdentifier(httpServletRequest);
if (oidcRequestIdentifier == null) {
throw new IllegalStateException("The OIDC request identifier was not found in the request. Unable to continue.");
}
final com.nimbusds.openid.connect.sdk.AuthenticationResponse oidcResponse =
parseAuthenticationResponse(getRequestUri(), httpServletResponse, false);
if (oidcResponse.indicatesSuccess()) {
final AuthenticationSuccessResponse successfulOidcResponse = (AuthenticationSuccessResponse) oidcResponse;
validateOIDCState(oidcRequestIdentifier, successfulOidcResponse, httpServletResponse, false);
try {
// exchange authorization code for id token
final AuthorizationCode authorizationCode = successfulOidcResponse.getAuthorizationCode();
final AuthorizationGrant authorizationGrant = new AuthorizationCodeGrant(authorizationCode, URI.create(getOidcLogoutCallback()));
final String logoutMethod = determineLogoutMethod();
switch(logoutMethod) {
case REVOKE_ACCESS_TOKEN_LOGOUT:
final String accessToken;
try {
accessToken = oidcService.exchangeAuthorizationCodeForAccessToken(authorizationGrant);
} catch (final Exception e) {
final String errorMsg = "Unable to exchange authorization for the access token: " + e.getMessage();
logger.error(errorMsg, e);
throw new IllegalStateException(errorMsg);
}
final URI revokeEndpoint = oidcService.getRevocationEndpoint();
try {
revokeEndpointRequest(httpServletResponse, accessToken, revokeEndpoint);
} catch (final IOException e) {
final String errorMsg = "There was an error logging out of the OpenID Connect Provider: " + e.getMessage();
logger.error(errorMsg, e);
throw new IllegalStateException(errorMsg);
}
break;
case ID_TOKEN_LOGOUT:
final String idToken;
try {
idToken = oidcService.exchangeAuthorizationCodeForIdToken(authorizationGrant);
} catch (final Exception e) {
final String errorMsg = "Unable to exchange authorization for the ID token: " + e.getMessage();
logger.error(errorMsg, e);
throw new IllegalStateException(errorMsg);
}
final URI endSessionEndpoint = oidcService.getEndSessionEndpoint();
final String postLogoutRedirectUri = getNiFiRegistryUri();
final URI logoutUri = UriBuilder.fromUri(endSessionEndpoint)
.queryParam("id_token_hint", idToken)
.queryParam("post_logout_redirect_uri", postLogoutRedirectUri)
.build();
httpServletResponse.sendRedirect(logoutUri.toString());
break;
default:
// there should be no other way to logout at this point, return error
throw new IllegalStateException("Unable to complete logout: Logout method unrecognized");
}
} catch (final Exception e) {
logger.error(e.getMessage(), e);
removeOidcRequestCookie(httpServletResponse);
throw e;
}
} else {
URI logoutUri = UriBuilder.fromUri(endSessionEndpoint)
.queryParam("post_logout_redirect_uri", postLogoutRedirectUri)
.build();
httpServletResponse.sendRedirect(logoutUri.toString());
removeOidcRequestCookie(httpServletResponse);
// report the unsuccessful logout
final AuthenticationErrorResponse errorOidcResponse = (AuthenticationErrorResponse) oidcResponse;
throw new IllegalStateException("Unsuccessful logout attempt: " + errorOidcResponse.getErrorObject().getDescription());
}
}
@ -748,6 +860,10 @@ public class AccessResource extends ApplicationResource {
return generateResourceUri("access", "oidc", "callback");
}
private String getOidcLogoutCallback() {
return generateResourceUri("access", "oidc", "logout", "callback");
}
private void removeOidcRequestCookie(final HttpServletResponse httpServletResponse) {
final Cookie cookie = new Cookie(OIDC_REQUEST_IDENTIFIER, null);
cookie.setPath("/");
@ -761,12 +877,6 @@ public class AccessResource extends ApplicationResource {
return uriInfo.getRequestUri();
}
private String getNiFiRegistryUri() {
final String nifiRegistryApiUrl = generateResourceUri();
final String baseUrl = StringUtils.substringBeforeLast(nifiRegistryApiUrl, "/nifi-registry-api");
return baseUrl + "/nifi-registry";
}
private void forwardToMessagePage(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse, final String message) throws Exception {
httpServletRequest.setAttribute("title", OIDC_ERROR_TITLE);
httpServletRequest.setAttribute("messages", message);
@ -832,4 +942,137 @@ public class AccessResource extends ApplicationResource {
private boolean isOIDCLoginSupported(HttpServletRequest request) {
return request.isSecure() && oidcService != null && oidcService.isOidcEnabled();
}
private String determineLogoutMethod() {
if (oidcService.getEndSessionEndpoint() != null) {
return ID_TOKEN_LOGOUT;
} else if (oidcService.getRevocationEndpoint() != null) {
return REVOKE_ACCESS_TOKEN_LOGOUT;
} else {
return STANDARD_LOGOUT;
}
}
/**
* Generates the request Authorization URI for the OpenID Connect Provider. Returns an authorization
* URI using the provided callback URI.
*
* @param httpServletResponse the servlet response
* @param callback the OIDC callback URI
* @return the authorization URI
*/
private URI oidcRequestAuthorizationCode(@Context final HttpServletResponse httpServletResponse, final 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
final State state = oidcService.createState(oidcRequestIdentifier);
// build the authorization uri
final URI authorizationUri = 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 authorizationUri;
}
private String getOidcRequestIdentifier(final HttpServletRequest httpServletRequest) {
return getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
}
private com.nimbusds.openid.connect.sdk.AuthenticationResponse parseAuthenticationResponse(final URI requestUri,
final HttpServletResponse httpServletResponse,
final boolean isLogin) {
final com.nimbusds.openid.connect.sdk.AuthenticationResponse oidcResponse;
try {
oidcResponse = AuthenticationResponseParser.parse(requestUri);
} catch (final ParseException e) {
final String loginOrLogoutString = isLogin ? "login" : "logout";
logger.error(String.format("Unable to parse the redirect URI from the OpenId Connect Provider. Unable to continue %s process.", loginOrLogoutString));
// remove the oidc request cookie
removeOidcRequestCookie(httpServletResponse);
throw new IllegalStateException(String.format("Unable to parse the redirect URI from the OpenId Connect Provider. Unable to continue %s process.", loginOrLogoutString));
}
return oidcResponse;
}
private void validateOIDCState(final String oidcRequestIdentifier,
final AuthenticationSuccessResponse successfulOidcResponse,
final HttpServletResponse httpServletResponse,
final boolean isLogin) {
// confirm state
final State state = successfulOidcResponse.getState();
if (state == null || !oidcService.isStateValid(oidcRequestIdentifier, state)) {
final String loginOrLogoutMessage = isLogin ? "login" : "logout";
logger.error(String.format("The state value returned by the OpenId Connect Provider does not match the stored state. Unable to continue %s process.", loginOrLogoutMessage));
// remove the oidc request cookie
removeOidcRequestCookie(httpServletResponse);
throw new IllegalStateException(String.format("Proposed state does not match the stored state. Unable to continue %s process.", loginOrLogoutMessage));
}
}
/**
* Sends a POST request to the revoke endpoint to log out of the ID Provider.
*
* @param httpServletResponse the servlet response
* @param accessToken the OpenID Connect Provider access token
* @param revokeEndpoint the name of the cookie
* @throws IOException exceptional case for communication error with the OpenId Connect Provider
*/
private void revokeEndpointRequest(@Context HttpServletResponse httpServletResponse, String accessToken, URI revokeEndpoint) throws IOException, NoSuchAlgorithmException {
final CloseableHttpClient httpClient = getHttpClient();
HttpPost httpPost = new HttpPost(revokeEndpoint);
List<NameValuePair> params = new ArrayList<>();
// Append a query param with the access token
params.add(new BasicNameValuePair("token", accessToken));
httpPost.setEntity(new UrlEncodedFormEntity(params));
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
if (response.getStatusLine().getStatusCode() == HTTPResponse.SC_OK) {
// redirect to NiFi Registry page after logout completes
logger.debug("You are logged out of the OpenId Connect Provider.");
final String postLogoutRedirectUri = getNiFiRegistryUri();
httpServletResponse.sendRedirect(postLogoutRedirectUri);
} else {
logger.error("There was an error logging out of the OpenId Connect Provider. " +
"Response status: " + response.getStatusLine().getStatusCode());
}
} finally {
httpClient.close();
}
}
private CloseableHttpClient getHttpClient() throws NoSuchAlgorithmException {
final String rawConnectTimeout = properties.getOidcConnectTimeout();
final String rawReadTimeout = properties.getOidcReadTimeout();
final int oidcConnectTimeout = (int) FormatUtils.getPreciseTimeDuration(rawConnectTimeout, TimeUnit.MILLISECONDS);
final int oidcReadTimeout = (int) FormatUtils.getPreciseTimeDuration(rawReadTimeout, TimeUnit.MILLISECONDS);
final RequestConfig config = RequestConfig.custom()
.setConnectTimeout(oidcConnectTimeout)
.setConnectionRequestTimeout(oidcReadTimeout)
.setSocketTimeout(oidcReadTimeout)
.build();
final HttpClientBuilder builder = HttpClientBuilder
.create()
.setDefaultRequestConfig(config)
.setSSLContext(SSLContext.getDefault());
return builder.build();
}
}

View File

@ -230,4 +230,7 @@ public class ApplicationResource {
return revisionInfo;
}
protected String getNiFiRegistryUri() {
return generateResourceUri("..", "nifi-registry");
}
}

View File

@ -69,7 +69,7 @@ public class JwtIdentityProvider extends BearerAuthIdentityProvider implements I
}
try {
final String jwtPrincipal = jwtService.getAuthenticationFromToken(jwtAuthToken);
final String jwtPrincipal = jwtService.getUserIdentityFromToken(jwtAuthToken);
return new AuthenticationResponse(jwtPrincipal, jwtPrincipal, expiration, issuer);
} catch (JwtException e) {
throw new InvalidAuthenticationException(e.getMessage(), e);

View File

@ -31,7 +31,6 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.registry.security.authentication.AuthenticationResponse;
import org.apache.nifi.registry.security.key.Key;
import org.apache.nifi.registry.security.key.KeyService;
import org.apache.nifi.registry.web.security.authentication.exception.InvalidAuthenticationException;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@ -40,7 +39,6 @@ import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
// TODO, look into replacing this JwtService service with Apache Licensed JJWT library
@ -62,7 +60,7 @@ public class JwtService {
this.keyService = keyService;
}
public String getAuthenticationFromToken(final String base64EncodedToken) throws JwtException {
public String getUserIdentityFromToken(final String base64EncodedToken) throws JwtException {
// The library representations of the JWT should be kept internal to this service.
try {
final Jws<Claims> jws = parseTokenFromBase64EncodedString(base64EncodedToken);
@ -175,7 +173,7 @@ public class JwtService {
}
public void logOut(String userIdentity) {
public void deleteKey(final String userIdentity) {
if (userIdentity == null || userIdentity.isEmpty()) {
throw new JwtException("Log out failed: The user identity was not present in the request token to log out user.");
}
@ -184,7 +182,7 @@ public class JwtService {
keyService.deleteKey(userIdentity);
logger.info("Deleted token from database.");
} catch (Exception e) {
logger.error("Unable to log out user: " + userIdentity + ". Failed to remove their token from database.");
logger.error("Unable to delete token for user: [" + userIdentity + "].");
throw e;
}
}
@ -228,18 +226,4 @@ public class JwtService {
.append(" ms remaining]")
.toString();
}
public void logOutUsingAuthHeader(String authorizationHeader) {
String base64EncodedToken = getTokenFromHeader(authorizationHeader);
logOut(getAuthenticationFromToken(base64EncodedToken));
}
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

@ -60,6 +60,13 @@ public interface OidcIdentityProvider {
*/
URI getEndSessionEndpoint();
/**
* Returns the URI for the revocation endpoint.
*
* @return uri for the revocation endpoint
*/
URI getRevocationEndpoint();
/**
* Returns the scopes supported by the OIDC provider.
*
@ -75,5 +82,23 @@ public interface OidcIdentityProvider {
* @return a NiFi JWT
* @throws IOException if there was an exceptional error while communicating with the OIDC provider
*/
String exchangeAuthorizationCode(AuthorizationGrant authorizationGrant) throws IOException;
String exchangeAuthorizationCodeForLoginAuthenticationToken(AuthorizationGrant authorizationGrant) throws IOException;
/**
* Exchanges the supplied authorization grant for an Access Token.
*
* @param authorizationGrant authorization grant for invoking the Token Endpoint
* @return an Access Token String
* @throws Exception if there was an exceptional error while communicating with the OIDC provider
*/
String exchangeAuthorizationCodeForAccessToken(AuthorizationGrant authorizationGrant) throws Exception;
/**
* Exchanges the supplied authorization grant for an ID Token.
*
* @param authorizationGrant authorization grant for invoking the Token Endpoint
* @return an ID Token String
* @throws IOException if there was an exceptional error while communicating with the OIDC provider
*/
String exchangeAuthorizationCodeForIdToken(final AuthorizationGrant authorizationGrant) throws IOException;
}

View File

@ -101,6 +101,15 @@ public class OidcService {
return identityProvider.getEndSessionEndpoint();
}
/**
* Returns the URI for the revocation endpoint.
*
* @return uri for the revocation endpoint
*/
public URI getRevocationEndpoint() {
return identityProvider.getRevocationEndpoint();
}
/**
* Returns the OpenId Connect scope.
*
@ -195,13 +204,13 @@ public class OidcService {
* @param authorizationGrant authorization grant
* @throws IOException exceptional case for communication error with the OpenId Connect provider
*/
public void exchangeAuthorizationCode(final String oidcRequestIdentifier, final AuthorizationGrant authorizationGrant) throws IOException {
public void exchangeAuthorizationCodeForLoginAuthenticationToken(final String oidcRequestIdentifier, final AuthorizationGrant authorizationGrant) throws IOException {
if (!isOidcEnabled()) {
throw new IllegalStateException(OidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
final String nifiJwt = identityProvider.exchangeAuthorizationCode(authorizationGrant);
final String nifiJwt = identityProvider.exchangeAuthorizationCodeForLoginAuthenticationToken(authorizationGrant);
try {
// cache the jwt for later retrieval
@ -216,6 +225,38 @@ public class OidcService {
}
}
/**
* Exchanges the specified authorization grant for an access token.
*
* @param authorizationGrant authorization grant
* @return an Access Token string
* @throws IOException exceptional case for communication error with the OpenId Connect provider
*/
public String exchangeAuthorizationCodeForAccessToken(final AuthorizationGrant authorizationGrant) throws Exception {
if (!isOidcEnabled()) {
throw new IllegalStateException(OidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
// Retrieve access token
return identityProvider.exchangeAuthorizationCodeForAccessToken(authorizationGrant);
}
/**
* Exchanges the specified authorization grant for an ID Token.
*
* @param authorizationGrant authorization grant
* @return an ID Token string
* @throws IOException exceptional case for communication error with the OpenId Connect provider
*/
public String exchangeAuthorizationCodeForIdToken(final AuthorizationGrant authorizationGrant) throws IOException {
if (!isOidcEnabled()) {
throw new IllegalStateException(OidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
// Retrieve ID token
return identityProvider.exchangeAuthorizationCodeForIdToken(authorizationGrant);
}
/**
* Returns the resulting JWT for the given request identifier. Will return null if the request
* identifier is not associated with a JWT or if the login sequence was not completed before

View File

@ -26,6 +26,11 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import com.nimbusds.oauth2.sdk.token.AccessToken;
import com.nimbusds.openid.connect.sdk.claims.AccessTokenHash;
import com.nimbusds.openid.connect.sdk.validators.AccessTokenValidator;
import com.nimbusds.openid.connect.sdk.validators.InvalidHashException;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.registry.properties.NiFiRegistryProperties;
import org.apache.nifi.registry.security.authentication.exception.IdentityAccessException;
@ -276,6 +281,14 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider {
return oidcProviderMetadata.getEndSessionEndpointURI();
}
@Override
public URI getRevocationEndpoint() {
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
return oidcProviderMetadata.getRevocationEndpointURI();
}
@Override
public Scope getScope() {
if (!isOidcEnabled()) {
@ -302,7 +315,7 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider {
}
@Override
public String exchangeAuthorizationCode(final AuthorizationGrant authorizationGrant) throws IOException {
public String exchangeAuthorizationCodeForLoginAuthenticationToken(final AuthorizationGrant authorizationGrant) throws IOException {
// Check if OIDC is enabled
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
@ -314,20 +327,60 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider {
try {
// Build the token request
final HTTPRequest tokenHttpRequest = createTokenHTTPRequest(authorizationGrant, clientAuthentication);
return authorizeClient(tokenHttpRequest);
final TokenResponse response = authorizeClient(tokenHttpRequest);
return convertOIDCTokenToNiFiToken((OIDCTokenResponse) response);
} catch (final ParseException | JOSEException | BadJOSEException | java.text.ParseException e) {
throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage());
}
}
private String authorizeClient(HTTPRequest tokenHttpRequest) throws ParseException, IOException, BadJOSEException, JOSEException, java.text.ParseException {
@Override
public String exchangeAuthorizationCodeForAccessToken(final AuthorizationGrant authorizationGrant) throws Exception {
// Check if OIDC is enabled
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
// Build ClientAuthentication
final ClientAuthentication clientAuthentication = createClientAuthentication();
try {
// Build the token request
final HTTPRequest tokenHttpRequest = createTokenHTTPRequest(authorizationGrant, clientAuthentication);
final TokenResponse response = authorizeClient(tokenHttpRequest);
return getAccessTokenString((OIDCTokenResponse) response);
} catch (final ParseException | JOSEException | BadJOSEException | java.text.ParseException e) {
throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage());
}
}
@Override
public String exchangeAuthorizationCodeForIdToken(final AuthorizationGrant authorizationGrant) {
// Check if OIDC is enabled
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
// Build ClientAuthentication
final ClientAuthentication clientAuthentication = createClientAuthentication();
try {
// Build the token request
final HTTPRequest tokenHttpRequest = createTokenHTTPRequest(authorizationGrant, clientAuthentication);
final TokenResponse response = authorizeClient(tokenHttpRequest);
return getIdTokenString((OIDCTokenResponse) response);
} catch (final RuntimeException | JOSEException | BadJOSEException | ParseException | IOException | java.text.ParseException e) {
throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage(), e);
}
}
private TokenResponse authorizeClient(HTTPRequest tokenHttpRequest) throws ParseException, IOException, BadJOSEException, JOSEException, java.text.ParseException {
// Get the token response
final TokenResponse response = OIDCTokenResponseParser.parse(tokenHttpRequest.send());
// Handle success
if (response.indicatesSuccess()) {
return convertOIDCTokenToNiFiToken((OIDCTokenResponse) response);
return response;
} else {
// If the response was not successful
final TokenErrorResponse errorResponse = (TokenErrorResponse) response;
@ -463,4 +516,72 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider {
}
}
private String getAccessTokenString(final OIDCTokenResponse response) throws Exception {
final OIDCTokens oidcTokens = response.getOIDCTokens();
// Validate the Access Token
validateAccessToken(oidcTokens);
// Return the Access Token String
return oidcTokens.getAccessToken().getValue();
}
private String getIdTokenString(OIDCTokenResponse response) throws BadJOSEException, JOSEException {
final OIDCTokens oidcTokens = response.getOIDCTokens();
// Validate the Token - no nonce required for authorization code flow
validateIdToken(oidcTokens.getIDToken());
// Return the ID Token string
return oidcTokens.getIDTokenString();
}
private void validateAccessToken(OIDCTokens oidcTokens) throws Exception {
// Get the Access Token to validate
final AccessToken accessToken = oidcTokens.getAccessToken();
// Get the preferredJwsAlgorithm for validation
final JWSAlgorithm jwsAlgorithm = extractJwsAlgorithm();
// Get the accessTokenHash for validation
final String atHashString = oidcTokens
.getIDToken()
.getJWTClaimsSet()
.getStringClaim("at_hash");
// Compute the Access Token hash
final AccessTokenHash atHash = new AccessTokenHash(atHashString);
try {
// Validate the Token
AccessTokenValidator.validate(accessToken, jwsAlgorithm, atHash);
} catch (InvalidHashException e) {
throw new Exception("Unable to validate the Access Token: " + e.getMessage());
}
}
private IDTokenClaimsSet validateIdToken(JWT oidcJwt) throws BadJOSEException, JOSEException {
try {
return tokenValidator.validate(oidcJwt, null);
} catch (BadJOSEException e) {
throw new BadJOSEException("Unable to validate the ID Token: " + e.getMessage());
}
}
private JWSAlgorithm extractJwsAlgorithm() {
final String rawPreferredJwsAlgorithm = properties.getOidcPreferredJwsAlgorithm();
final JWSAlgorithm preferredJwsAlgorithm;
if (StringUtils.isBlank(rawPreferredJwsAlgorithm)) {
preferredJwsAlgorithm = JWSAlgorithm.RS256;
} else {
if ("none".equalsIgnoreCase(rawPreferredJwsAlgorithm)) {
preferredJwsAlgorithm = null;
} else {
preferredJwsAlgorithm = JWSAlgorithm.parse(rawPreferredJwsAlgorithm);
}
}
return preferredJwsAlgorithm;
}
}

View File

@ -83,14 +83,14 @@ public class OidcServiceTest {
@Test(expected = IllegalStateException.class)
public void testOidcNotEnabledExchangeCode() throws Exception {
final OidcService service = getServiceWithNoOidcSupport();
service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
service.exchangeAuthorizationCodeForLoginAuthenticationToken(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
}
@Test(expected = IllegalStateException.class)
public void testExchangeCodeMultipleInvocation() throws Exception {
final OidcService service = getServiceWithOidcSupport();
service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
service.exchangeAuthorizationCodeForLoginAuthenticationToken(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
service.exchangeAuthorizationCodeForLoginAuthenticationToken(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
}
@Test(expected = IllegalStateException.class)
@ -102,14 +102,14 @@ public class OidcServiceTest {
@Test
public void testGetJwt() throws Exception {
final OidcService service = getServiceWithOidcSupport();
service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
service.exchangeAuthorizationCodeForLoginAuthenticationToken(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
assertNotNull(service.getJwt(TEST_REQUEST_IDENTIFIER));
}
@Test
public void testGetJwtExpiration() throws Exception {
final OidcService service = getServiceWithOidcSupportAndCustomExpiration(1, TimeUnit.SECONDS);
service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
service.exchangeAuthorizationCodeForLoginAuthenticationToken(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
Thread.sleep(3 * 1000);
@ -129,7 +129,7 @@ public class OidcServiceTest {
private OidcService getServiceWithOidcSupport() throws Exception {
final OidcIdentityProvider provider = mock(OidcIdentityProvider.class);
when(provider.isOidcEnabled()).thenReturn(true);
when(provider.exchangeAuthorizationCode(any())).then(invocation -> UUID.randomUUID().toString());
when(provider.exchangeAuthorizationCodeForLoginAuthenticationToken(any())).then(invocation -> UUID.randomUUID().toString());
final OidcService service = new OidcService(provider);
assertTrue(service.isOidcEnabled());
@ -140,7 +140,7 @@ public class OidcServiceTest {
private OidcService getServiceWithOidcSupportAndCustomExpiration(final int duration, final TimeUnit units) throws Exception {
final OidcIdentityProvider provider = mock(OidcIdentityProvider.class);
when(provider.isOidcEnabled()).thenReturn(true);
when(provider.exchangeAuthorizationCode(any())).then(invocation -> UUID.randomUUID().toString());
when(provider.exchangeAuthorizationCodeForLoginAuthenticationToken(any())).then(invocation -> UUID.randomUUID().toString());
final OidcService service = new OidcService(provider, duration, units);
assertTrue(service.isOidcEnabled());

View File

@ -40,13 +40,12 @@ public class LogoutFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
final boolean supportsOidc = Boolean.parseBoolean(servletContext.getInitParameter("oidc-supported"));
if (supportsOidc) {
final ServletContext apiContext = servletContext.getContext("/nifi-registry-api");
apiContext.getRequestDispatcher("/access/oidc/logout").forward(request, response);
} else {
final ServletContext apiContext = servletContext.getContext("/nifi-registry-api");
apiContext.getRequestDispatcher("/access/logout").forward(request, response);
apiContext.getRequestDispatcher("/access/logout/complete").forward(request, response);
}
}

View File

@ -72,7 +72,7 @@ NfRegistry.prototype = {
*/
logout: function () {
var self = this;
self.nfRegistryApi.deleteToLogout('../nifi-registry/logout').subscribe(
self.nfRegistryApi.deleteToLogout().subscribe(
function () {
// next call
},
@ -84,7 +84,7 @@ NfRegistry.prototype = {
self.nfStorage.removeItem('jwt');
delete self.nfRegistryService.currentUser.identity;
delete self.nfRegistryService.currentUser.anonymous;
self.router.navigateByUrl('login');
window.location.href = location.origin + '/nifi-registry/logout';
}
);
},

View File

@ -866,7 +866,7 @@ NfRegistryApi.prototype = {
*
* @returns {*}
*/
deleteToLogout: function (url) {
deleteToLogout: function () {
var self = this;
var options = {
headers: headers,
@ -874,7 +874,7 @@ NfRegistryApi.prototype = {
responseType: 'text'
};
return this.http.delete(url, options).pipe(
return this.http.delete('../nifi-registry-api/access/logout', options).pipe(
map(function (response) {
return response;
}),