thenatog commented on a change in pull request #5079: URL: https://github.com/apache/nifi/pull/5079#discussion_r655436618
########## File path: nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OIDCAccessResource.java ########## @@ -0,0 +1,569 @@ +/* + * 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; + +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.AuthenticationResponse; +import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser; +import com.nimbusds.openid.connect.sdk.AuthenticationSuccessResponse; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +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.authentication.exception.AuthenticationNotSupportedException; +import org.apache.nifi.authorization.user.NiFiUserUtils; +import org.apache.nifi.util.NiFiProperties; +import org.apache.nifi.web.security.jwt.JwtService; +import org.apache.nifi.web.security.jwt.NiFiBearerTokenResolver; +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.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + + +@Path(OIDCEndpoints.OIDC_ACCESS_ROOT) +@Api( + value = OIDCEndpoints.OIDC_ACCESS_ROOT, + description = "Endpoints for obtaining an access token or checking access status." +) +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"; + private static final String ID_TOKEN_LOGOUT = "oidc_id_token_logout"; + private static final String STANDARD_LOGOUT = "oidc_standard_logout"; + 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 boolean LOGGING_IN = true; + + private OidcService oidcService; + private JwtService jwtService; + private CloseableHttpClient httpClient; + + public OIDCAccessResource() { + RequestConfig config = RequestConfig.custom() + .setConnectTimeout(msTimeout) + .setConnectionRequestTimeout(msTimeout) + .setSocketTimeout(msTimeout) + .build(); + + httpClient = HttpClientBuilder + .create() + .setDefaultRequestConfig(config) + .build(); + } + + @GET + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.WILDCARD) + @Path(OIDCEndpoints.LOGIN_REQUEST_RELATIVE) + @ApiOperation( + value = "Initiates a request to authenticate through the configured OpenId Connect provider.", + notes = NON_GUARANTEED_ENDPOINT + ) + public void oidcRequest(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { + // only consider user specific access over https + if (!httpServletRequest.isSecure()) { + forwardToLoginMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG); + return; + } + + // ensure oidc is enabled + if (!oidcService.isOidcEnabled()) { + forwardToLoginMessagePage(httpServletRequest, httpServletResponse, OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG); + return; + } + + // generate the authorization uri + URI authorizationURI = oidcRequestAuthorizationCode(httpServletResponse, getOidcCallback()); + + // generate the response + httpServletResponse.sendRedirect(authorizationURI.toString()); + } + + @GET + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.WILDCARD) + @Path(OIDCEndpoints.LOGIN_CALLBACK_RELATIVE) + @ApiOperation( + value = "Redirect/callback URI for processing the result of the OpenId Connect login sequence.", + notes = NON_GUARANTEED_ENDPOINT + ) + public void oidcCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { + final AuthenticationResponse oidcResponse = parseOidcResponse(httpServletRequest, httpServletResponse, LOGGING_IN); + + final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue(); + + if (oidcResponse != null && oidcResponse.indicatesSuccess()) { + final AuthenticationSuccessResponse successfulOidcResponse = (AuthenticationSuccessResponse) oidcResponse; + + checkOidcState(httpServletResponse, oidcRequestIdentifier, successfulOidcResponse, LOGGING_IN); + + try { + // exchange authorization code for id token + final AuthorizationCode authorizationCode = successfulOidcResponse.getAuthorizationCode(); + final AuthorizationGrant authorizationGrant = new AuthorizationCodeGrant(authorizationCode, URI.create(getOidcCallback())); + + // get the oidc token + LoginAuthenticationToken oidcToken = oidcService.exchangeAuthorizationCodeForLoginAuthenticationToken(authorizationGrant); + + // exchange the oidc token for the NiFi token + String nifiJwt = jwtService.generateSignedToken(oidcToken); + + // store the NiFi token + oidcService.storeJwt(oidcRequestIdentifier, nifiJwt); + } catch (final Exception e) { + logger.error(OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage(), e); + + // remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // forward to the error page + forwardToLoginMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage()); + return; + } + + // redirect to the name page + httpServletResponse.sendRedirect(getNiFiUri()); + } else { + // remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // report the unsuccessful login + final AuthenticationErrorResponse errorOidcResponse = (AuthenticationErrorResponse) oidcResponse; + forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful login attempt: " + + errorOidcResponse.getErrorObject().getDescription()); + } + } + + @POST + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.TEXT_PLAIN) + @Path(OIDCEndpoints.TOKEN_EXCHANGE_RELATIVE) + @ApiOperation( + value = "Retrieves a JWT following a successful login sequence using the configured OpenId Connect provider.", + response = String.class, + notes = NON_GUARANTEED_ENDPOINT + ) + public Response oidcExchange(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) { + // only consider user specific access over https + if (!httpServletRequest.isSecure()) { + throw new AuthenticationNotSupportedException(AUTHENTICATION_NOT_ENABLED_MSG); + } + + // ensure oidc is enabled + if (!oidcService.isOidcEnabled()) { + logger.debug(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG); + 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 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 oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // get the jwt + final String jwt = oidcService.getJwt(oidcRequestIdentifier); + 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); + } + + @GET + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.WILDCARD) + @Path(OIDCEndpoints.LOGOUT_REQUEST_RELATIVE) + @ApiOperation( + value = "Performs a logout in the OpenId Provider.", + notes = NON_GUARANTEED_ENDPOINT + ) + public void oidcLogout(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { + if (!httpServletRequest.isSecure()) { + throw new IllegalStateException(AUTHENTICATION_NOT_ENABLED_MSG); + } + + if (!oidcService.isOidcEnabled()) { + 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(); + + // Determine the logout method + String logoutMethod = determineLogoutMethod(oidcDiscoveryUrl); + + switch (logoutMethod) { + case REVOKE_ACCESS_TOKEN_LOGOUT: + case ID_TOKEN_LOGOUT: + // Make a request to the IdP + URI authorizationURI = oidcRequestAuthorizationCode(httpServletResponse, getOidcLogoutCallback()); + httpServletResponse.sendRedirect(authorizationURI.toString()); + break; + case STANDARD_LOGOUT: + default: + // Get the OIDC end session endpoint + URI endSessionEndpoint = oidcService.getEndSessionEndpoint(); + String postLogoutRedirectUri = generateResourceUri( "..", "nifi", "logout-complete"); + + if (endSessionEndpoint == null) { + httpServletResponse.sendRedirect(postLogoutRedirectUri); + } else { + URI logoutUri = UriBuilder.fromUri(endSessionEndpoint) + .queryParam("post_logout_redirect_uri", postLogoutRedirectUri) + .build(); + httpServletResponse.sendRedirect(logoutUri.toString()); + } + break; + } + } + + @GET + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.WILDCARD) + @Path(OIDCEndpoints.LOGOUT_CALLBACK_RELATIVE) + @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 { + final AuthenticationResponse oidcResponse = parseOidcResponse(httpServletRequest, httpServletResponse, !LOGGING_IN); + + final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue(); + + if (oidcResponse != null && oidcResponse.indicatesSuccess()) { + final AuthenticationSuccessResponse successfulOidcResponse = (AuthenticationSuccessResponse) oidcResponse; + + // confirm state + checkOidcState(httpServletResponse, oidcRequestIdentifier, successfulOidcResponse, false); + + // Get the oidc discovery url + String oidcDiscoveryUrl = properties.getOidcDiscoveryUrl(); + + // Determine which logout method to use + String logoutMethod = determineLogoutMethod(oidcDiscoveryUrl); + + // Get the authorization code and grant + final AuthorizationCode authorizationCode = successfulOidcResponse.getAuthorizationCode(); + final AuthorizationGrant authorizationGrant = new AuthorizationCodeGrant(authorizationCode, URI.create(getOidcLogoutCallback())); + + switch (logoutMethod) { + case REVOKE_ACCESS_TOKEN_LOGOUT: + // Use the Revocation endpoint + access token + final String accessToken; + try { + // Return the access token + accessToken = oidcService.exchangeAuthorizationCodeForAccessToken(authorizationGrant); + } catch (final Exception e) { + logger.error("Unable to exchange authorization for the Access token: " + e.getMessage(), e); + + // Remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // Forward to the error page + forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage()); + return; + } + + // Build the revoke URI and send the POST request + URI revokeEndpoint = getRevokeEndpoint(); + + if (revokeEndpoint != null) { + try { + // Logout with the revoke endpoint + revokeEndpointRequest(httpServletResponse, accessToken, revokeEndpoint); + + } catch (final IOException e) { + logger.error("There was an error logging out of the OpenId Connect Provider: " + + e.getMessage(), e); + + // Remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // Forward to the error page + forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, + "There was an error logging out of the OpenId Connect Provider: " + + e.getMessage()); + } + } + break; + case ID_TOKEN_LOGOUT: + // Use the end session endpoint + ID Token + final String idToken; + try { + // Return the ID Token + idToken = oidcService.exchangeAuthorizationCodeForIdToken(authorizationGrant); + } catch (final Exception e) { + logger.error(OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage(), e); + + // Remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // Forward to the error page + forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage()); + return; + } + + // Get the OIDC end session endpoint + URI endSessionEndpoint = oidcService.getEndSessionEndpoint(); + String postLogoutRedirectUri = generateResourceUri("..", "nifi", "logout-complete"); + + if (endSessionEndpoint == null) { + logger.debug("Unable to log out of the OpenId Connect Provider. The end session endpoint is: null." + + " Redirecting to the logout page."); + httpServletResponse.sendRedirect(postLogoutRedirectUri); + } else { + URI logoutUri = UriBuilder.fromUri(endSessionEndpoint) + .queryParam("id_token_hint", idToken) + .queryParam("post_logout_redirect_uri", postLogoutRedirectUri) + .build(); + httpServletResponse.sendRedirect(logoutUri.toString()); + } + break; + } + } else { + // remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // report the unsuccessful logout + final AuthenticationErrorResponse errorOidcResponse = (AuthenticationErrorResponse) oidcResponse; + forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful logout attempt: " + + errorOidcResponse.getErrorObject().getDescription()); + } + } + + /** + * 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 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 + 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 Authorization URI + return authorizationUri; + } + + private String determineLogoutMethod(String oidcDiscoveryUrl) { + Matcher accessTokenMatcher = REVOKE_ACCESS_TOKEN_LOGOUT_FORMAT.matcher(oidcDiscoveryUrl); + Matcher idTokenMatcher = ID_TOKEN_LOGOUT_FORMAT.matcher(oidcDiscoveryUrl); + + if (accessTokenMatcher.find()) { + return REVOKE_ACCESS_TOKEN_LOGOUT; + } else if (idTokenMatcher.find()) { + return ID_TOKEN_LOGOUT; + } else { + return STANDARD_LOGOUT; + } + } + + /** + * 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 { + + 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)) { + httpClient.close(); Review comment: Tried to commit this change but somehow 'Merge branch 'main' into NIFI-8025-rebased' happened. Will fix. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. For queries about this service, please contact Infrastructure at: [email protected]
