NIFI-13232 Add Authentication Configuration REST API method (#8834)

* NIFI-13232 Added Authentication Configuration REST API method

* NIFI-13236 Moved logoutSupported from Configuration to Current User

* NIFI-13232 Added externalLoginRequired field

This closes #8834
This commit is contained in:
David Handermann 2024-05-17 16:50:09 -05:00 committed by GitHub
parent b27fc46b60
commit 226ac9671f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 424 additions and 10 deletions

View File

@ -0,0 +1,85 @@
/*
* 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.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.xml.bind.annotation.XmlType;
/**
* Authentication Configuration endpoint and status information
*/
@XmlType(name = "authenticationConfiguration")
public class AuthenticationConfigurationDTO {
private boolean externalLoginRequired;
private boolean loginSupported;
private String loginUri;
private String logoutUri;
@Schema(
description = "Whether the system requires login through an external Identity Provider",
accessMode = Schema.AccessMode.READ_ONLY
)
public boolean isExternalLoginRequired() {
return externalLoginRequired;
}
public void setExternalLoginRequired(final boolean externalLoginRequired) {
this.externalLoginRequired = externalLoginRequired;
}
@Schema(
description = "Whether the system is configured to support login operations",
accessMode = Schema.AccessMode.READ_ONLY
)
public boolean isLoginSupported() {
return loginSupported;
}
public void setLoginSupported(final boolean loginSupported) {
this.loginSupported = loginSupported;
}
@Schema(
description = "Location for initiating login processing",
accessMode = Schema.AccessMode.READ_ONLY,
nullable = true
)
public String getLoginUri() {
return loginUri;
}
public void setLoginUri(final String loginUri) {
this.loginUri = loginUri;
}
@Schema(
description = "Location for initiating logout processing",
accessMode = Schema.AccessMode.READ_ONLY,
nullable = true
)
public String getLogoutUri() {
return logoutUri;
}
public void setLogoutUri(final String logoutUri) {
this.logoutUri = logoutUri;
}
}

View File

@ -0,0 +1,34 @@
/*
* 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.entity;
import jakarta.xml.bind.annotation.XmlRootElement;
import org.apache.nifi.web.api.dto.AuthenticationConfigurationDTO;
@XmlRootElement(name = "authenticationConfigurationEntity")
public class AuthenticationConfigurationEntity extends Entity {
private AuthenticationConfigurationDTO authenticationConfiguration;
public AuthenticationConfigurationDTO getAuthenticationConfiguration() {
return authenticationConfiguration;
}
public void setAuthenticationConfiguration(final AuthenticationConfigurationDTO authenticationConfiguration) {
this.authenticationConfiguration = authenticationConfiguration;
}
}

View File

@ -31,6 +31,7 @@ public class CurrentUserEntity extends Entity {
private String identity;
private boolean anonymous;
private boolean logoutSupported;
private PermissionsDTO provenancePermissions;
private PermissionsDTO countersPermissions;
@ -68,6 +69,18 @@ public class CurrentUserEntity extends Entity {
this.anonymous = anonymous;
}
@Schema(
description = "Whether the system is configured to support logout operations based on current user authentication status",
accessMode = Schema.AccessMode.READ_ONLY
)
public boolean isLogoutSupported() {
return logoutSupported;
}
public void setLogoutSupported(final boolean logoutSupported) {
this.logoutSupported = logoutSupported;
}
/**
* @return if the use can query provenance
*/

View File

@ -23,6 +23,7 @@ import org.springframework.security.core.context.SecurityContextHolder;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Utility methods for retrieving information about the current application user.
@ -89,4 +90,27 @@ public final class NiFiUserUtils {
return proxyChain;
}
/**
* Get Authentication Credentials from the current Spring Security Context Authentication object
*
* @return Optional Credentials from Spring Security Context
*/
public static Optional<Object> getAuthenticationCredentials() {
final Optional<Object> authenticationCredentials;
final SecurityContext securityContext = SecurityContextHolder.getContext();
if (securityContext == null) {
authenticationCredentials = Optional.empty();
} else {
final Authentication authentication = securityContext.getAuthentication();
if (authentication == null) {
authenticationCredentials = Optional.empty();
} else {
final Object credentials = authentication.getCredentials();
authenticationCredentials = Optional.ofNullable(credentials);
}
}
return authenticationCredentials;
}
}

View File

@ -19,14 +19,18 @@ package org.apache.nifi.web;
import org.apache.nifi.admin.service.AuditService;
import org.apache.nifi.admin.service.EntityStoreAuditService;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.web.configuration.AuthenticationConfiguration;
import org.apache.nifi.web.security.configuration.WebSecurityConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.ImportResource;
import org.springframework.util.StringUtils;
import java.io.File;
import java.net.URI;
import java.net.URISyntaxException;
/**
* Web Application Spring Configuration
@ -41,6 +45,23 @@ import java.io.File;
"classpath:nifi-cluster-protocol-context.xml",
"classpath:nifi-web-api-context.xml"})
public class NiFiWebApiConfiguration {
private static final URI OAUTH2_AUTHORIZATION_URI = getPathUri("/nifi-api/oauth2/authorization/consumer");
private static final URI OIDC_LOGOUT_URI = getPathUri("/nifi-api/access/oidc/logout");
private static final URI SAML2_AUTHENTICATE_URI = getPathUri("/nifi-api/saml2/authenticate/consumer");
private static final URI SAML_LOCAL_LOGOUT_URI = getPathUri("/nifi-api/access/saml/local-logout/request");
private static final URI SAML_SINGLE_LOGOUT_URI = getPathUri("/nifi-api/access/saml/single-logout/request");
private static final URI LOGIN_FORM_URI = getLoginFormUri();
private static final URI LOGOUT_COMPLETE_URI = getPathUri("/nifi-api/access/logout/complete");
private static final String UI_PATH = "/nf/";
private static final String LOGIN_FRAGMENT = "/login";
public NiFiWebApiConfiguration() {
super();
@ -58,4 +79,61 @@ public class NiFiWebApiConfiguration {
final File databaseDirectory = properties.getDatabaseRepositoryPath().toFile();
return new EntityStoreAuditService(databaseDirectory);
}
@Autowired
@Bean
public AuthenticationConfiguration authenticationConfiguration(final NiFiProperties properties) {
final URI loginUri;
final URI logoutUri;
final boolean externalLoginRequired;
// HTTPS is required for authentication
if (properties.isHTTPSConfigured()) {
final String loginIdentityProvider = properties.getProperty(NiFiProperties.SECURITY_USER_LOGIN_IDENTITY_PROVIDER);
if (properties.isOidcEnabled()) {
externalLoginRequired = true;
loginUri = OAUTH2_AUTHORIZATION_URI;
logoutUri = OIDC_LOGOUT_URI;
} else if (properties.isSamlEnabled()) {
externalLoginRequired = true;
loginUri = SAML2_AUTHENTICATE_URI;
if (properties.isSamlSingleLogoutEnabled()) {
logoutUri = SAML_SINGLE_LOGOUT_URI;
} else {
logoutUri = SAML_LOCAL_LOGOUT_URI;
}
} else if (StringUtils.hasText(loginIdentityProvider)) {
externalLoginRequired = false;
loginUri = LOGIN_FORM_URI;
logoutUri = LOGOUT_COMPLETE_URI;
} else {
externalLoginRequired = false;
loginUri = null;
logoutUri = null;
}
} else {
externalLoginRequired = false;
loginUri = null;
logoutUri = null;
}
final boolean loginSupported = loginUri != null;
return new AuthenticationConfiguration(externalLoginRequired, loginSupported, loginUri, logoutUri);
}
private static URI getPathUri(final String path) {
try {
return new URI(null, null, path, null);
} catch (final URISyntaxException e) {
throw new IllegalArgumentException("Path URI construction failed", e);
}
}
private static URI getLoginFormUri() {
try {
return new URI(null, null, UI_PATH, LOGIN_FRAGMENT);
} catch (final URISyntaxException e) {
throw new IllegalArgumentException("Path Fragment URI construction failed", e);
}
}
}

View File

@ -98,6 +98,7 @@ public class NiFiWebApiResourceConfig extends ResourceConfig {
register(ctx.getBean("systemDiagnosticsResource"));
register(ctx.getBean("accessResource"));
register(ctx.getBean("accessPolicyResource"));
register(ctx.getBean("authenticationResource"));
register(ctx.getBean("tenantsResource"));
register(ctx.getBean("versionsResource"));
register(ctx.getBean("parameterContextResource"));

View File

@ -377,6 +377,8 @@ import org.slf4j.LoggerFactory;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.springframework.security.oauth2.core.OAuth2Token;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
@ -4777,6 +4779,7 @@ public class StandardNiFiServiceFacade implements NiFiServiceFacade {
final CurrentUserEntity entity = new CurrentUserEntity();
entity.setIdentity(user.getIdentity());
entity.setAnonymous(user.isAnonymous());
entity.setLogoutSupported(isLogoutSupported());
entity.setProvenancePermissions(dtoFactory.createPermissionsDto(authorizableLookup.getProvenance()));
entity.setCountersPermissions(dtoFactory.createPermissionsDto(authorizableLookup.getCounters()));
entity.setTenantsPermissions(dtoFactory.createPermissionsDto(authorizableLookup.getTenant()));
@ -6651,6 +6654,13 @@ public class StandardNiFiServiceFacade implements NiFiServiceFacade {
return propertyDescriptor;
}
private boolean isLogoutSupported() {
// Logout is supported when authenticated using a JSON Web Token
return NiFiUserUtils.getAuthenticationCredentials()
.map(credentials -> credentials instanceof OAuth2Token)
.orElse(false);
}
@Override
public void verifyPublicInputPortUniqueness(final String portId, final String portName) {
inputPortDAO.verifyPublicPortUniqueness(portId, portName);

View File

@ -0,0 +1,105 @@
/*
* 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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.apache.nifi.cluster.coordination.ClusterCoordinator;
import org.apache.nifi.cluster.coordination.http.replication.RequestReplicator;
import org.apache.nifi.controller.FlowController;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.web.api.dto.AuthenticationConfigurationDTO;
import org.apache.nifi.web.api.entity.AccessConfigurationEntity;
import org.apache.nifi.web.api.entity.AuthenticationConfigurationEntity;
import org.apache.nifi.web.configuration.AuthenticationConfiguration;
import org.apache.nifi.web.util.RequestUriBuilder;
import org.springframework.util.StringUtils;
import java.net.URI;
import java.util.Objects;
@Path("/authentication")
@Tag(name = "Authentication")
public class AuthenticationResource extends ApplicationResource {
private final AuthenticationConfiguration authenticationConfiguration;
public AuthenticationResource(
final AuthenticationConfiguration authenticationConfiguration,
final NiFiProperties properties,
final RequestReplicator requestReplicator,
final ClusterCoordinator clusterCoordinator,
final FlowController flowController
) {
this.authenticationConfiguration = Objects.requireNonNull(authenticationConfiguration);
setProperties(properties);
setRequestReplicator(requestReplicator);
setClusterCoordinator(clusterCoordinator);
setFlowController(flowController);
}
@GET
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.APPLICATION_JSON)
@Path("/configuration")
@Operation(
summary = "Retrieves the authentication configuration endpoint and status information",
responses = @ApiResponse(content = @Content(schema = @Schema(implementation = AccessConfigurationEntity.class)))
)
public Response getAuthenticationConfiguration() {
final AuthenticationConfigurationDTO configuration = new AuthenticationConfigurationDTO();
configuration.setExternalLoginRequired(authenticationConfiguration.externalLoginRequired());
configuration.setLoginSupported(authenticationConfiguration.loginSupported());
final URI configuredLoginUri = authenticationConfiguration.loginUri();
if (configuredLoginUri != null) {
final String loginUri = getAuthenticationUri(configuredLoginUri);
configuration.setLoginUri(loginUri);
}
final URI configuredLogoutUri = authenticationConfiguration.logoutUri();
if (configuredLogoutUri != null) {
final String logoutUri = getAuthenticationUri(configuredLogoutUri);
configuration.setLogoutUri(logoutUri);
}
final AuthenticationConfigurationEntity entity = new AuthenticationConfigurationEntity();
entity.setAuthenticationConfiguration(configuration);
return generateOkResponse(entity).build();
}
private String getAuthenticationUri(final URI configuredUri) {
final RequestUriBuilder builder = RequestUriBuilder.fromHttpServletRequest(httpServletRequest);
builder.path(configuredUri.getPath());
final String fragment = configuredUri.getFragment();
if (StringUtils.hasText(fragment)) {
builder.fragment(fragment);
}
return builder.build().toString();
}
}

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.configuration;
import java.net.URI;
/**
* Authentication Configuration based on configured application properties
*
* @param externalLoginRequired Whether login through an external Identity Provider is required
* @param loginSupported Whether login operations are supported
* @param loginUri Optional URI for login operations
* @param logoutUri Optional URI for logout operations
*/
public record AuthenticationConfiguration(
boolean externalLoginRequired,
boolean loginSupported,
URI loginUri,
URI logoutUri
) {
}

View File

@ -630,6 +630,13 @@
<constructor-arg ref="requestReplicator" />
<constructor-arg ref="flowController" />
</bean>
<bean id="authenticationResource" class="org.apache.nifi.web.api.AuthenticationResource" scope="singleton">
<constructor-arg ref="authenticationConfiguration"/>
<constructor-arg ref="nifiProperties"/>
<constructor-arg ref="clusterCoordinator"/>
<constructor-arg ref="requestReplicator" />
<constructor-arg ref="flowController" />
</bean>
<bean id="tenantsResource" class="org.apache.nifi.web.api.TenantsResource" scope="singleton">
<constructor-arg ref="serviceFacade"/>
<constructor-arg ref="authorizer"/>

View File

@ -118,7 +118,8 @@ public class WebSecurityConfiguration {
"/access/kerberos",
"/access/knox/callback",
"/access/knox/request",
"/access/logout/complete"
"/access/logout/complete",
"/authentication/configuration"
).permitAll()
.anyRequest().authenticated()
)

View File

@ -56,7 +56,7 @@ public class StandardJwtAuthenticationConverter implements Converter<Jwt, NiFiAu
@Override
public NiFiAuthenticationToken convert(final Jwt jwt) {
final NiFiUser user = getUser(jwt);
return new NiFiAuthenticationToken(new NiFiUserDetails(user));
return new NiFiAuthenticationToken(new NiFiUserDetails(user), jwt);
}
private NiFiUser getUser(final Jwt jwt) {

View File

@ -26,16 +26,34 @@ public class NiFiAuthenticationToken extends AbstractAuthenticationToken {
final UserDetails nifiUserDetails;
public NiFiAuthenticationToken(final UserDetails nifiUserDetails) {
super(nifiUserDetails.getAuthorities());
private final Object credentials;
/**
* Token constructor with User Details and without additional credentials
*
* @param userDetails Spring Security User Details
*/
public NiFiAuthenticationToken(final UserDetails userDetails) {
this(userDetails, userDetails.getPassword());
}
/**
* Token constructor with User Details and optional credentials from authentication processing
*
* @param userDetails Spring Security User Details
* @param credentials Optional credentials from authentication processing
*/
public NiFiAuthenticationToken(final UserDetails userDetails, final Object credentials) {
super(userDetails.getAuthorities());
super.setAuthenticated(true);
setDetails(nifiUserDetails);
this.nifiUserDetails = nifiUserDetails;
setDetails(userDetails);
this.nifiUserDetails = userDetails;
this.credentials = credentials;
}
@Override
public Object getCredentials() {
return nifiUserDetails.getPassword();
return credentials;
}
@Override

View File

@ -39,6 +39,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ -80,16 +81,18 @@ public class X509AuthenticationProvider extends NiFiAuthenticationProvider {
final X509AuthenticationRequestToken request = (X509AuthenticationRequestToken) authentication;
// attempt to authenticate if certificates were found
final X509Certificate[] certificates = request.getCertificates();;
final AuthenticationResponse authenticationResponse;
try {
authenticationResponse = certificateIdentityProvider.authenticate(request.getCertificates());
authenticationResponse = certificateIdentityProvider.authenticate(certificates);
} catch (final IllegalArgumentException iae) {
throw new InvalidAuthenticationException(iae.getMessage(), iae);
}
if (StringUtils.isBlank(request.getProxiedEntitiesChain())) {
final String mappedIdentity = mapIdentity(authenticationResponse.getIdentity());
return new NiFiAuthenticationToken(new NiFiUserDetails(new Builder().identity(mappedIdentity).groups(getUserGroups(mappedIdentity)).clientAddress(request.getClientAddress()).build()));
final NiFiUser user = new Builder().identity(mappedIdentity).groups(getUserGroups(mappedIdentity)).clientAddress(request.getClientAddress()).build();
return new NiFiAuthenticationToken(new NiFiUserDetails(user), certificates);
} else {
// get the idp groups for the end-user that were sent over in the X-ProxiedEntityGroups header
final Set<String> endUserIdpGroups = ProxiedEntitiesUtils.tokenizeProxiedEntityGroups(request.getProxiedEntityGroups());
@ -139,7 +142,7 @@ public class X509AuthenticationProvider extends NiFiAuthenticationProvider {
logProxyChain(proxy);
}
return new NiFiAuthenticationToken(new NiFiUserDetails(proxy));
return new NiFiAuthenticationToken(new NiFiUserDetails(proxy), certificates);
}
}