mirror of https://github.com/apache/nifi.git
NIFI-4210:
- Introducing support for OpenId Connect. - Updating REST API and UI to support the authorization code flow. - Adding/fixing documentation. - Implementing time constant equality checks where appropriate. - Corrected error handling during startup and throughout the OIDC login sequence. - Redacting the token values from the user log. - Defaulting to RS256 when not preferred algorithm is specified. - Marking the OIDC endpoints as non-guaranteed in to allow for minor adjustments if/when additional SSO techniques are introduced. This closes #2047. Signed-off-by: Andy LoPresto <alopresto@apache.org>
This commit is contained in:
parent
505e93065e
commit
528b82634f
|
@ -148,6 +148,14 @@ public abstract class NiFiProperties {
|
|||
public static final String SECURITY_IDENTITY_MAPPING_PATTERN_PREFIX = "nifi.security.identity.mapping.pattern.";
|
||||
public static final String SECURITY_IDENTITY_MAPPING_VALUE_PREFIX = "nifi.security.identity.mapping.value.";
|
||||
|
||||
// oidc
|
||||
public static final String SECURITY_USER_OIDC_DISCOVERY_URL = "nifi.security.user.oidc.discovery.url";
|
||||
public static final String SECURITY_USER_OIDC_CONNECT_TIMEOUT = "nifi.security.user.oidc.connect.timeout";
|
||||
public static final String SECURITY_USER_OIDC_READ_TIMEOUT = "nifi.security.user.oidc.read.timeout";
|
||||
public static final String SECURITY_USER_OIDC_CLIENT_ID = "nifi.security.user.oidc.client.id";
|
||||
public static final String SECURITY_USER_OIDC_CLIENT_SECRET = "nifi.security.user.oidc.client.secret";
|
||||
public static final String SECURITY_USER_OIDC_PREFERRED_JWSALGORITHM = "nifi.security.user.oidc.preferred.jwsalgorithm";
|
||||
|
||||
// web properties
|
||||
public static final String WEB_WAR_DIR = "nifi.web.war.directory";
|
||||
public static final String WEB_HTTP_PORT = "nifi.web.http.port";
|
||||
|
@ -244,6 +252,8 @@ public abstract class NiFiProperties {
|
|||
public static final String DEFAULT_FLOW_CONFIGURATION_ARCHIVE_ENABLED = "true";
|
||||
public static final String DEFAULT_FLOW_CONFIGURATION_ARCHIVE_MAX_TIME = "30 days";
|
||||
public static final String DEFAULT_FLOW_CONFIGURATION_ARCHIVE_MAX_STORAGE = "500 MB";
|
||||
public static final String DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT = "5 secs";
|
||||
public static final String DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT = "5 secs";
|
||||
|
||||
// cluster common defaults
|
||||
public static final String DEFAULT_CLUSTER_PROTOCOL_HEARTBEAT_INTERVAL = "5 sec";
|
||||
|
@ -803,18 +813,91 @@ public abstract class NiFiProperties {
|
|||
return !StringUtils.isBlank(getKerberosSpnegoPrincipal()) && !StringUtils.isBlank(getKerberosSpnegoKeytabLocation());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the login identity provider has been configured.
|
||||
*
|
||||
* @return true if the login identity provider has been configured
|
||||
*/
|
||||
public boolean isLoginIdentityProviderEnabled() {
|
||||
return !StringUtils.isBlank(getProperty(NiFiProperties.SECURITY_USER_LOGIN_IDENTITY_PROVIDER));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an OpenId Connect (OIDC) URL is set.
|
||||
*
|
||||
* @return whether an OpenId Connection URL is set
|
||||
*/
|
||||
public boolean isOidcEnabled() {
|
||||
return !StringUtils.isBlank(getOidcDiscoveryUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OpenId Connect (OIDC) URL. Null otherwise.
|
||||
*
|
||||
* @return OIDC discovery url
|
||||
*/
|
||||
public String getOidcDiscoveryUrl() {
|
||||
return getProperty(SECURITY_USER_OIDC_DISCOVERY_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OpenId Connect connect timeout. Non null.
|
||||
*
|
||||
* @return OIDC connect timeout
|
||||
*/
|
||||
public String getOidcConnectTimeout() {
|
||||
return getProperty(SECURITY_USER_OIDC_CONNECT_TIMEOUT, DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OpenId Connect read timeout. Non null.
|
||||
*
|
||||
* @return OIDC read timeout
|
||||
*/
|
||||
public String getOidcReadTimeout() {
|
||||
return getProperty(SECURITY_USER_OIDC_READ_TIMEOUT, DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OpenId Connect client id.
|
||||
*
|
||||
* @return OIDC client id
|
||||
*/
|
||||
public String getOidcClientId() {
|
||||
return getProperty(SECURITY_USER_OIDC_CLIENT_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OpenId Connect client secret.
|
||||
*
|
||||
* @return OIDC client secret
|
||||
*/
|
||||
public String getOidcClientSecret() {
|
||||
return getProperty(SECURITY_USER_OIDC_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preferred json web signature algorithm. May be null/blank.
|
||||
*
|
||||
* @return OIDC preferred json web signature algorithm
|
||||
*/
|
||||
public String getOidcPreferredJwsAlgorithm() {
|
||||
return getProperty(SECURITY_USER_OIDC_PREFERRED_JWSALGORITHM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if client certificates are required for REST API. Determined
|
||||
* if the following conditions are all true:
|
||||
* <p>
|
||||
* - login identity provider is not populated
|
||||
* - Kerberos service support is not enabled
|
||||
* - openid connect is not enabled
|
||||
*
|
||||
* @return true if client certificates are required for access to the REST
|
||||
* API
|
||||
*/
|
||||
public boolean isClientAuthRequiredForRestApi() {
|
||||
return StringUtils.isBlank(getProperty(NiFiProperties.SECURITY_USER_LOGIN_IDENTITY_PROVIDER)) && !isKerberosSpnegoSupportEnabled();
|
||||
return !isLoginIdentityProviderEnabled() && !isKerberosSpnegoSupportEnabled() && !isOidcEnabled();
|
||||
}
|
||||
|
||||
public InetSocketAddress getNodeApiAddress() {
|
||||
|
|
|
@ -282,15 +282,21 @@ For a client certificate that can be easily imported into the browser, specify:
|
|||
User Authentication
|
||||
-------------------
|
||||
|
||||
NiFi supports user authentication via client certificates or via username/password. Username/password authentication is performed by a 'Login Identity
|
||||
Provider'. The Login Identity Provider is a pluggable mechanism for authenticating users via their username/password. Which Login Identity Provider
|
||||
to use is configured in two properties in the _nifi.properties_ file.
|
||||
NiFi supports user authentication via client certificates, via username/password, or using OpenId Connect (http://openid.net/connect).
|
||||
|
||||
Username/password authentication is performed by a 'Login Identity Provider'. The Login Identity Provider is a pluggable mechanism for
|
||||
authenticating users via their username/password. Which Login Identity Provider to use is configured in two properties in the _nifi.properties_ file.
|
||||
|
||||
The `nifi.login.identity.provider.configuration.file` property specifies the configuration file for Login Identity Providers.
|
||||
The `nifi.security.user.login.identity.provider` property indicates which of the configured Login Identity Provider should be
|
||||
used. If this property is not configured, NiFi will not support username/password authentication and will require client
|
||||
certificates for authenticating users over HTTPS. By default, this property is not configured meaning that username/password must be explicitly enabled.
|
||||
|
||||
During OpenId Connect authentication, NiFi will redirect users to login with the Provider before returning to NiFi. NiFi will then
|
||||
call the Provider to obtain the user identity.
|
||||
|
||||
NOTE: NiFi cannot be configured for both username/password and OpenId Connect authentication at the same time.
|
||||
|
||||
A secured instance of NiFi cannot be accessed anonymously unless configured to use an LDAP or Kerberos Login Identity Provider, which in turn must be configured to explicitly allow anonymous access. Anonymous access is not currently possible by the default FileAuthorizer (see <<authorizer-configuration>>), but is a future effort (https://issues.apache.org/jira/browse/NIFI-2730[NIFI-2730]).
|
||||
|
||||
NOTE: NiFi does not perform user authentication over HTTP. Using HTTP, all users will be granted all roles.
|
||||
|
@ -397,6 +403,26 @@ nifi.security.user.login.identity.provider=kerberos-provider
|
|||
|
||||
See also <<kerberos_service>> to allow single sign-on access via client Kerberos tickets.
|
||||
|
||||
[[openid_connect]]
|
||||
OpenId Connect
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
To enable authentication via OpenId Connect the following properties must be configured in nifi.properties.
|
||||
|
||||
[options="header,footer"]
|
||||
|==================================================================================================================================================
|
||||
| Property Name | Description
|
||||
|`nifi.security.user.oidc.discovery.url` | The discovery URL for the desired OpenId Connect Provider (http://openid.net/specs/openid-connect-discovery-1_0.html).
|
||||
|`nifi.security.user.oidc.connect.timeout` | Connect timeout when communicating with the OpenId Connect Provider.
|
||||
|`nifi.security.user.oidc.read.timeout` | Read timeout when communicating with the OpenId Connect Provider.
|
||||
|`nifi.security.user.oidc.client.id` | The client id for NiFi after registration with the OpenId Connect Provider.
|
||||
|`nifi.security.user.oidc.client.secret` | The client secret for NiFi after registration with the OpenId Connect Provider.
|
||||
|`nifi.security.user.oidc.preferred.jwsalgorithm` | The preferred algorithm for for validating identity tokens. If this value is blank, it will default to 'RS256' which is required to be supported
|
||||
by the OpenId Connect Provider according to the specification. If this value is 'HS256', 'HS384', or 'HS512', NiFi will attempt to validate HMAC protected tokens using the specified client secret.
|
||||
If this value is 'none', NiFi will attempt to validate unsecured/plain tokens. Other values for this algorithm will attempt to parse as an RSA or EC algorithm to be used in conjunction with the
|
||||
JSON Web Key (JWK) provided through the jwks_uri in the metadata found at the discovery URL.
|
||||
|==================================================================================================================================================
|
||||
|
||||
[[multi-tenant-authorization]]
|
||||
Multi-Tenant Authorization
|
||||
--------------------------
|
||||
|
|
|
@ -743,5 +743,33 @@ This product bundles 'jsonlint' which is available under an MIT license.
|
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
This product bundles 'asm' which is available under a 3-Clause BSD style license.
|
||||
For details see http://asm.ow2.org/asmdex-license.html
|
||||
|
||||
Copyright (c) 2012 France Télécom
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holders nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
|
||||
THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
|
|
@ -147,6 +147,14 @@
|
|||
<nifi.security.ocsp.responder.url />
|
||||
<nifi.security.ocsp.responder.certificate />
|
||||
|
||||
<!-- nifi.properties: openid connect -->
|
||||
<nifi.security.user.oidc.discovery.url />
|
||||
<nifi.security.user.oidc.connect.timeout>5 secs</nifi.security.user.oidc.connect.timeout>
|
||||
<nifi.security.user.oidc.read.timeout>5 secs</nifi.security.user.oidc.read.timeout>
|
||||
<nifi.security.user.oidc.client.id />
|
||||
<nifi.security.user.oidc.client.secret />
|
||||
<nifi.security.user.oidc.preferred.jwsalgorithm />
|
||||
|
||||
<!-- nifi.properties: cluster common properties (cluster manager and nodes must have same values) -->
|
||||
<nifi.cluster.protocol.heartbeat.interval>5 sec</nifi.cluster.protocol.heartbeat.interval>
|
||||
<nifi.cluster.protocol.is.secure>false</nifi.cluster.protocol.is.secure>
|
||||
|
|
|
@ -156,6 +156,14 @@ nifi.security.user.login.identity.provider=${nifi.security.user.login.identity.p
|
|||
nifi.security.ocsp.responder.url=${nifi.security.ocsp.responder.url}
|
||||
nifi.security.ocsp.responder.certificate=${nifi.security.ocsp.responder.certificate}
|
||||
|
||||
# OpenId Connect Properties #
|
||||
nifi.security.user.oidc.discovery.url=${nifi.security.user.oidc.discovery.url}
|
||||
nifi.security.user.oidc.connect.timeout=${nifi.security.user.oidc.connect.timeout}
|
||||
nifi.security.user.oidc.read.timeout=${nifi.security.user.oidc.read.timeout}
|
||||
nifi.security.user.oidc.client.id=${nifi.security.user.oidc.client.id}
|
||||
nifi.security.user.oidc.client.secret=${nifi.security.user.oidc.client.secret}
|
||||
nifi.security.user.oidc.preferred.jwsalgorithm=${nifi.security.user.oidc.preferred.jwsalgorithm}
|
||||
|
||||
# Identity Mapping Properties #
|
||||
# These properties allow normalizing user identities such that identities coming from different identity providers
|
||||
# (certificates, LDAP, Kerberos) can be treated the same internally in NiFi. The following example demonstrates normalizing
|
||||
|
|
|
@ -289,7 +289,9 @@ public class JettyServer implements NiFiServer {
|
|||
}
|
||||
|
||||
// load the web ui app
|
||||
handlers.addHandler(loadWar(webUiWar, "/nifi", frameworkClassLoader));
|
||||
final WebAppContext webUiContext = loadWar(webUiWar, "/nifi", frameworkClassLoader);
|
||||
webUiContext.getInitParams().put("oidc-supported", String.valueOf(props.isOidcEnabled()));
|
||||
handlers.addHandler(webUiContext);
|
||||
|
||||
// load the web api app
|
||||
webApiContext = loadWar(webApiWar, "/nifi-api", frameworkClassLoader);
|
||||
|
|
|
@ -326,6 +326,11 @@
|
|||
<artifactId>cglib-nodep</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>oauth2-oidc-sdk</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- testing dependencies -->
|
||||
<dependency>
|
||||
|
|
|
@ -78,7 +78,7 @@ public class NiFiWebApiSecurityConfiguration extends WebSecurityConfigurerAdapte
|
|||
// the /access/download-token and /access/ui-extension-token endpoints
|
||||
webSecurity
|
||||
.ignoring()
|
||||
.antMatchers("/access", "/access/config", "/access/token", "/access/kerberos");
|
||||
.antMatchers("/access", "/access/config", "/access/token", "/access/kerberos", "/access/oidc/**");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -16,6 +16,14 @@
|
|||
*/
|
||||
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.id.State;
|
||||
import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse;
|
||||
import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser;
|
||||
import com.nimbusds.openid.connect.sdk.AuthenticationSuccessResponse;
|
||||
import com.wordnik.swagger.annotations.Api;
|
||||
import com.wordnik.swagger.annotations.ApiOperation;
|
||||
import com.wordnik.swagger.annotations.ApiResponse;
|
||||
|
@ -45,6 +53,7 @@ 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.kerberos.KerberosService;
|
||||
import org.apache.nifi.web.security.oidc.OidcService;
|
||||
import org.apache.nifi.web.security.otp.OtpService;
|
||||
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
|
||||
import org.apache.nifi.web.security.token.NiFiAuthenticationToken;
|
||||
|
@ -59,7 +68,10 @@ import org.springframework.security.core.Authentication;
|
|||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor;
|
||||
|
||||
import javax.servlet.ServletContext;
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.FormParam;
|
||||
import javax.ws.rs.GET;
|
||||
|
@ -69,8 +81,10 @@ 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.net.URI;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
|
@ -85,6 +99,9 @@ public class AccessResource extends ApplicationResource {
|
|||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AccessResource.class);
|
||||
|
||||
private static final String OIDC_REQUEST_IDENTIFIER = "oidc-request-identifier";
|
||||
private static final String OIDC_ERROR_TITLE = "Unable to continue login sequence";
|
||||
|
||||
private X509CertificateExtractor certificateExtractor;
|
||||
private X509AuthenticationProvider x509AuthenticationProvider;
|
||||
private X509PrincipalExtractor principalExtractor;
|
||||
|
@ -93,6 +110,7 @@ public class AccessResource extends ApplicationResource {
|
|||
private JwtAuthenticationProvider jwtAuthenticationProvider;
|
||||
private JwtService jwtService;
|
||||
private OtpService otpService;
|
||||
private OidcService oidcService;
|
||||
|
||||
private KerberosService kerberosService;
|
||||
|
||||
|
@ -125,6 +143,176 @@ public class AccessResource extends ApplicationResource {
|
|||
return generateOkResponse(entity).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
@Produces(MediaType.WILDCARD)
|
||||
@Path("oidc/request")
|
||||
@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()) {
|
||||
forwardToMessagePage(httpServletRequest, httpServletResponse, "User authentication/authorization is only supported when running over HTTPS.");
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure oidc is enabled
|
||||
if (!oidcService.isOidcEnabled()) {
|
||||
forwardToMessagePage(httpServletRequest, httpServletResponse, "OpenId Connect is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// generate the response
|
||||
httpServletResponse.sendRedirect(authorizationUri.toString());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
@Produces(MediaType.WILDCARD)
|
||||
@Path("oidc/callback")
|
||||
@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 {
|
||||
// only consider user specific access over https
|
||||
if (!httpServletRequest.isSecure()) {
|
||||
forwardToMessagePage(httpServletRequest, httpServletResponse, "User authentication/authorization is only supported when running over HTTPS.");
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure oidc is enabled
|
||||
if (!oidcService.isOidcEnabled()) {
|
||||
forwardToMessagePage(httpServletRequest, httpServletResponse, "OpenId Connect is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
|
||||
if (oidcRequestIdentifier == null) {
|
||||
forwardToMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was not found in the request. Unable to continue.");
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
forwardToMessagePage(httpServletRequest, httpServletResponse, "Unable to parse the redirect URI from the OpenId Connect Provider. Unable to continue login process.");
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
forwardToMessagePage(httpServletRequest, httpServletResponse, "Purposed state does not match the stored state. Unable to continue login process.");
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (final Exception e) {
|
||||
logger.error("Unable to exchange authorization for ID token: " + e.getMessage(), e);
|
||||
|
||||
// remove the oidc request cookie
|
||||
removeOidcRequestCookie(httpServletResponse);
|
||||
|
||||
// forward to the error page
|
||||
forwardToMessagePage(httpServletRequest, httpServletResponse, "Unable to exchange authorization for ID token: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
// redirect to the name page
|
||||
httpServletResponse.sendRedirect("../../../nifi");
|
||||
} else {
|
||||
// remove the oidc request cookie
|
||||
removeOidcRequestCookie(httpServletResponse);
|
||||
|
||||
// report the unsuccessful login
|
||||
final AuthenticationErrorResponse errorOidcResponse = (AuthenticationErrorResponse) oidcResponse;
|
||||
forwardToMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful login attempt: " + errorOidcResponse.getErrorObject().getDescription());
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Path("oidc/exchange")
|
||||
@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) throws Exception {
|
||||
// only consider user specific access over https
|
||||
if (!httpServletRequest.isSecure()) {
|
||||
throw new IllegalStateException("User authentication/authorization is only supported when running over HTTPS.");
|
||||
}
|
||||
|
||||
// ensure oidc is enabled
|
||||
if (!oidcService.isOidcEnabled()) {
|
||||
throw new IllegalStateException("OpenId Connect is not configured.");
|
||||
}
|
||||
|
||||
final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
|
||||
if (oidcRequestIdentifier == null) {
|
||||
throw new IllegalArgumentException("The login request identifier was not found in the request. Unable to continue.");
|
||||
}
|
||||
|
||||
// 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.");
|
||||
}
|
||||
|
||||
// generate the response
|
||||
return generateOkResponse(jwt).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status the client's access.
|
||||
*
|
||||
|
@ -470,6 +658,46 @@ 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");
|
||||
}
|
||||
|
||||
private void removeOidcRequestCookie(final HttpServletResponse httpServletResponse) {
|
||||
final Cookie cookie = new Cookie(OIDC_REQUEST_IDENTIFIER, null);
|
||||
cookie.setPath("/");
|
||||
cookie.setHttpOnly(true);
|
||||
cookie.setMaxAge(0);
|
||||
cookie.setSecure(true);
|
||||
httpServletResponse.addCookie(cookie);
|
||||
}
|
||||
|
||||
private void forwardToMessagePage(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse, final String message) throws Exception {
|
||||
httpServletRequest.setAttribute("title", OIDC_ERROR_TITLE);
|
||||
httpServletRequest.setAttribute("messages", message);
|
||||
|
||||
final ServletContext uiContext = httpServletRequest.getServletContext().getContext("/nifi");
|
||||
uiContext.getRequestDispatcher("/WEB-INF/pages/message-page.jsp").forward(httpServletRequest, httpServletResponse);
|
||||
}
|
||||
|
||||
// setters
|
||||
|
||||
public void setLoginIdentityProvider(LoginIdentityProvider loginIdentityProvider) {
|
||||
|
@ -504,4 +732,7 @@ public class AccessResource extends ApplicationResource {
|
|||
this.otpService = otpService;
|
||||
}
|
||||
|
||||
public void setOidcService(OidcService oidcService) {
|
||||
this.oidcService = oidcService;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -295,6 +295,10 @@ public abstract class ApplicationResource {
|
|||
return uriInfo.getAbsolutePath();
|
||||
}
|
||||
|
||||
protected URI getRequestUri() {
|
||||
return uriInfo.getRequestUri();
|
||||
}
|
||||
|
||||
protected MultivaluedMap<String, String> getRequestParameters() {
|
||||
final MultivaluedMap<String, String> entity = new MultivaluedMapImpl();
|
||||
|
||||
|
|
|
@ -382,6 +382,7 @@
|
|||
</bean>
|
||||
<bean id="accessResource" class="org.apache.nifi.web.api.AccessResource" scope="singleton">
|
||||
<property name="loginIdentityProvider" ref="loginIdentityProvider"/>
|
||||
<property name="oidcService" ref="oidcService"/>
|
||||
<property name="x509AuthenticationProvider" ref="x509AuthenticationProvider"/>
|
||||
<property name="certificateExtractor" ref="certificateExtractor"/>
|
||||
<property name="principalExtractor" ref="principalExtractor"/>
|
||||
|
|
|
@ -136,5 +136,9 @@
|
|||
<artifactId>spring-security-kerberos-core</artifactId>
|
||||
<version>1.0.1.RELEASE</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>oauth2-oidc-sdk</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
|
|
@ -53,7 +53,7 @@ public class JwtAuthenticationRequestToken extends NiFiAuthenticationRequestToke
|
|||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getName();
|
||||
return "<JWT token>";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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.oidc;
|
||||
|
||||
|
||||
import com.nimbusds.oauth2.sdk.AuthorizationGrant;
|
||||
import com.nimbusds.oauth2.sdk.Scope;
|
||||
import com.nimbusds.oauth2.sdk.id.ClientID;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
|
||||
public interface OidcIdentityProvider {
|
||||
|
||||
String OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED = "OpenId Connect support is not configured";
|
||||
|
||||
/**
|
||||
* Returns whether OIDC support is enabled.
|
||||
*
|
||||
* @return whether OIDC support is enabled
|
||||
*/
|
||||
boolean isOidcEnabled();
|
||||
|
||||
/**
|
||||
* Returns the configured client id.
|
||||
*
|
||||
* @return the client id
|
||||
*/
|
||||
ClientID getClientId();
|
||||
|
||||
/**
|
||||
* Returns the URI for the authorization endpoint.
|
||||
*
|
||||
* @return uri for the authorization endpoint
|
||||
*/
|
||||
URI getAuthorizationEndpoint();
|
||||
|
||||
/**
|
||||
* Returns the scopes supported by the OIDC provider.
|
||||
*
|
||||
* @return support scopes
|
||||
*/
|
||||
Scope getScope();
|
||||
|
||||
/**
|
||||
* Exchanges the supplied authorization grant for an ID token. Extracts the identity from the ID
|
||||
* token and converts it into NiFi JWT.
|
||||
*
|
||||
* @param authorizationGrant authorization grant for invoking the Token Endpoint
|
||||
* @return a NiFi JWT
|
||||
* @throws IOException if there was an exceptional error while communicating with the OIDC provider
|
||||
*/
|
||||
String exchangeAuthorizationCode(AuthorizationGrant authorizationGrant) throws IOException;
|
||||
}
|
|
@ -0,0 +1,246 @@
|
|||
/*
|
||||
* 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.oidc;
|
||||
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import com.nimbusds.oauth2.sdk.AuthorizationGrant;
|
||||
import com.nimbusds.oauth2.sdk.Scope;
|
||||
import com.nimbusds.oauth2.sdk.id.State;
|
||||
import org.apache.nifi.web.security.util.CacheKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.apache.nifi.web.security.oidc.StandardOidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED;
|
||||
|
||||
/**
|
||||
* OidcService is a service for managing the OpenId Connect Authorization flow.
|
||||
*/
|
||||
public class OidcService {
|
||||
|
||||
private OidcIdentityProvider identityProvider;
|
||||
private Cache<CacheKey, State> stateLookupForPendingRequests; // identifier from cookie -> state value
|
||||
private Cache<CacheKey, String> jwtLookupForCompletedRequests; // identifier from cookie -> jwt or identity (and generate jwt on retrieval)
|
||||
|
||||
/**
|
||||
* Creates a new OtpService with an expiration of 1 minute.
|
||||
*
|
||||
* @param identityProvider The identity provider
|
||||
*/
|
||||
public OidcService(final OidcIdentityProvider identityProvider) {
|
||||
this(identityProvider, 60, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new OtpService.
|
||||
*
|
||||
* @param identityProvider The identity provider
|
||||
* @param duration The expiration duration
|
||||
* @param units The expiration units
|
||||
* @throws NullPointerException If units is null
|
||||
* @throws IllegalArgumentException If duration is negative
|
||||
*/
|
||||
public OidcService(final OidcIdentityProvider identityProvider, final int duration, final TimeUnit units) {
|
||||
if (identityProvider == null) {
|
||||
throw new RuntimeException("The OidcIdentityProvider must be specified.");
|
||||
}
|
||||
|
||||
this.identityProvider = identityProvider;
|
||||
this.stateLookupForPendingRequests = CacheBuilder.newBuilder().expireAfterWrite(duration, units).build();
|
||||
this.jwtLookupForCompletedRequests = CacheBuilder.newBuilder().expireAfterWrite(duration, units).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether OpenId Connect is enabled.
|
||||
*
|
||||
* @return whether OpenId Connect is enabled
|
||||
*/
|
||||
public boolean isOidcEnabled() {
|
||||
return identityProvider.isOidcEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OpenId Connect authorization endpoint.
|
||||
*
|
||||
* @return the authorization endpoint
|
||||
*/
|
||||
public URI getAuthorizationEndpoint() {
|
||||
return identityProvider.getAuthorizationEndpoint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OpenId Connect scope.
|
||||
*
|
||||
* @return scope
|
||||
*/
|
||||
public Scope getScope() {
|
||||
return identityProvider.getScope();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OpenId Connect client id.
|
||||
*
|
||||
* @return client id
|
||||
*/
|
||||
public String getClientId() {
|
||||
return identityProvider.getClientId().getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates an OpenId Connection authorization code flow using the specified request identifier to maintain state.
|
||||
*
|
||||
* @param oidcRequestIdentifier request identifier
|
||||
* @return state
|
||||
*/
|
||||
public State createState(final String oidcRequestIdentifier) {
|
||||
if (!isOidcEnabled()) {
|
||||
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
|
||||
final State state = new State(generateStateValue());
|
||||
|
||||
try {
|
||||
synchronized (stateLookupForPendingRequests) {
|
||||
final State cachedState = stateLookupForPendingRequests.get(oidcRequestIdentifierKey, () -> state);
|
||||
if (!timeConstantEqualityCheck(state.getValue(), cachedState.getValue())) {
|
||||
throw new IllegalStateException("An existing login request is already in progress.");
|
||||
}
|
||||
}
|
||||
} catch (ExecutionException e) {
|
||||
throw new IllegalStateException("Unable to store the login request state.");
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a value to use as State in the OpenId Connect login sequence. 128 bits is considered cryptographically strong
|
||||
* with current hardware/software, but a Base32 digit needs 5 bits to be fully encoded, so 128 is rounded up to 130. Base32
|
||||
* is chosen because it encodes data with a single case and without including confusing or URI-incompatible characters,
|
||||
* unlike Base64, but is approximately 20% more compact than Base16/hexadecimal
|
||||
*
|
||||
* @return the state value
|
||||
*/
|
||||
private String generateStateValue() {
|
||||
return new BigInteger(130, new SecureRandom()).toString(32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the proposed state with the given request identifier. Will return false if the
|
||||
* state does not match or if entry for this request identifier has expired.
|
||||
*
|
||||
* @param oidcRequestIdentifier request identifier
|
||||
* @param proposedState proposed state
|
||||
* @return whether the state is valid or not
|
||||
*/
|
||||
public boolean isStateValid(final String oidcRequestIdentifier, final State proposedState) {
|
||||
if (!isOidcEnabled()) {
|
||||
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
if (proposedState == null) {
|
||||
throw new IllegalArgumentException("Proposed state must be specified.");
|
||||
}
|
||||
|
||||
final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
|
||||
|
||||
synchronized (stateLookupForPendingRequests) {
|
||||
final State state = stateLookupForPendingRequests.getIfPresent(oidcRequestIdentifierKey);
|
||||
if (state != null) {
|
||||
stateLookupForPendingRequests.invalidate(oidcRequestIdentifierKey);
|
||||
}
|
||||
|
||||
return state != null && timeConstantEqualityCheck(state.getValue(), proposedState.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges the specified authorization grant for an ID token for the given request identifier.
|
||||
*
|
||||
* @param oidcRequestIdentifier request identifier
|
||||
* @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 {
|
||||
if (!isOidcEnabled()) {
|
||||
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
|
||||
final String nifiJwt = identityProvider.exchangeAuthorizationCode(authorizationGrant);
|
||||
|
||||
try {
|
||||
// cache the jwt for later retrieval
|
||||
synchronized (jwtLookupForCompletedRequests) {
|
||||
final String cachedJwt = jwtLookupForCompletedRequests.get(oidcRequestIdentifierKey, () -> nifiJwt);
|
||||
if (!timeConstantEqualityCheck(nifiJwt, cachedJwt)) {
|
||||
throw new IllegalStateException("An existing login request is already in progress.");
|
||||
}
|
||||
}
|
||||
} catch (final ExecutionException e) {
|
||||
throw new IllegalStateException("Unable to store the login authentication token.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* this request identifier expired.
|
||||
*
|
||||
* @param oidcRequestIdentifier request identifier
|
||||
* @return jwt token
|
||||
*/
|
||||
public String getJwt(final String oidcRequestIdentifier) {
|
||||
if (!isOidcEnabled()) {
|
||||
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
|
||||
|
||||
synchronized (jwtLookupForCompletedRequests) {
|
||||
final String jwt = jwtLookupForCompletedRequests.getIfPresent(oidcRequestIdentifierKey);
|
||||
if (jwt != null) {
|
||||
jwtLookupForCompletedRequests.invalidate(oidcRequestIdentifierKey);
|
||||
}
|
||||
|
||||
return jwt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a time constant equality check. If either value is null, false is returned.
|
||||
*
|
||||
* @param value1 value1
|
||||
* @param value2 value2
|
||||
* @return if value1 equals value2
|
||||
*/
|
||||
private boolean timeConstantEqualityCheck(final String value1, final String value2) {
|
||||
if (value1 == null || value2 == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return MessageDigest.isEqual(value1.getBytes(StandardCharsets.UTF_8), value2.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,364 @@
|
|||
/*
|
||||
* 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.oidc;
|
||||
|
||||
import com.nimbusds.jose.JOSEException;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.proc.BadJOSEException;
|
||||
import com.nimbusds.jose.util.DefaultResourceRetriever;
|
||||
import com.nimbusds.jose.util.ResourceRetriever;
|
||||
import com.nimbusds.jwt.JWT;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.oauth2.sdk.AuthorizationGrant;
|
||||
import com.nimbusds.oauth2.sdk.ParseException;
|
||||
import com.nimbusds.oauth2.sdk.Scope;
|
||||
import com.nimbusds.oauth2.sdk.TokenErrorResponse;
|
||||
import com.nimbusds.oauth2.sdk.TokenRequest;
|
||||
import com.nimbusds.oauth2.sdk.TokenResponse;
|
||||
import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
|
||||
import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
|
||||
import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
|
||||
import com.nimbusds.oauth2.sdk.auth.ClientSecretPost;
|
||||
import com.nimbusds.oauth2.sdk.auth.Secret;
|
||||
import com.nimbusds.oauth2.sdk.http.HTTPRequest;
|
||||
import com.nimbusds.oauth2.sdk.http.HTTPResponse;
|
||||
import com.nimbusds.oauth2.sdk.id.ClientID;
|
||||
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
|
||||
import com.nimbusds.openid.connect.sdk.OIDCScopeValue;
|
||||
import com.nimbusds.openid.connect.sdk.OIDCTokenResponse;
|
||||
import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser;
|
||||
import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse;
|
||||
import com.nimbusds.openid.connect.sdk.UserInfoRequest;
|
||||
import com.nimbusds.openid.connect.sdk.UserInfoResponse;
|
||||
import com.nimbusds.openid.connect.sdk.UserInfoSuccessResponse;
|
||||
import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet;
|
||||
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
|
||||
import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
|
||||
import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator;
|
||||
import net.minidev.json.JSONObject;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.nifi.util.FormatUtils;
|
||||
import org.apache.nifi.util.NiFiProperties;
|
||||
import org.apache.nifi.web.security.jwt.JwtService;
|
||||
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static com.nimbusds.openid.connect.sdk.claims.UserInfo.EMAIL_CLAIM_NAME;
|
||||
|
||||
/**
|
||||
* OidcProvider for managing the OpenId Connect Authorization flow.
|
||||
*/
|
||||
public class StandardOidcIdentityProvider implements OidcIdentityProvider {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(StandardOidcIdentityProvider.class);
|
||||
|
||||
private NiFiProperties properties;
|
||||
private JwtService jwtService;
|
||||
private OIDCProviderMetadata oidcProviderMetadata;
|
||||
private int oidcConnectTimeout;
|
||||
private int oidcReadTimeout;
|
||||
private IDTokenValidator tokenValidator;
|
||||
private ClientID clientId;
|
||||
private Secret clientSecret;
|
||||
|
||||
/**
|
||||
* Creates a new StandardOidcIdentityProvider.
|
||||
*
|
||||
* @param jwtService jwt service
|
||||
* @param properties properties
|
||||
*/
|
||||
public StandardOidcIdentityProvider(final JwtService jwtService, final NiFiProperties properties) {
|
||||
this.properties = properties;
|
||||
this.jwtService = jwtService;
|
||||
|
||||
// attempt to process the oidc configuration if configured
|
||||
if (properties.isOidcEnabled()) {
|
||||
if (properties.isLoginIdentityProviderEnabled()) {
|
||||
throw new RuntimeException("OpenId Connect support cannot be enabled if the Login Identity Provider is configured.");
|
||||
}
|
||||
|
||||
// oidc connect timeout
|
||||
final String rawConnectTimeout = properties.getOidcConnectTimeout();
|
||||
try {
|
||||
oidcConnectTimeout = (int) FormatUtils.getTimeDuration(rawConnectTimeout, TimeUnit.MILLISECONDS);
|
||||
} catch (final Exception e) {
|
||||
logger.warn("Failed to parse value of property '{}' as a valid time period. Value was '{}'. Ignoring this value and using the default value of '{}'",
|
||||
NiFiProperties.SECURITY_USER_OIDC_CONNECT_TIMEOUT, rawConnectTimeout, NiFiProperties.DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT);
|
||||
oidcConnectTimeout = (int) FormatUtils.getTimeDuration(NiFiProperties.DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
// oidc read timeout
|
||||
final String rawReadTimeout = properties.getOidcReadTimeout();
|
||||
try {
|
||||
oidcReadTimeout = (int) FormatUtils.getTimeDuration(rawReadTimeout, TimeUnit.MILLISECONDS);
|
||||
} catch (final Exception e) {
|
||||
logger.warn("Failed to parse value of property '{}' as a valid time period. Value was '{}'. Ignoring this value and using the default value of '{}'",
|
||||
NiFiProperties.SECURITY_USER_OIDC_READ_TIMEOUT, rawReadTimeout, NiFiProperties.DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT);
|
||||
oidcReadTimeout = (int) FormatUtils.getTimeDuration(NiFiProperties.DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
// client id
|
||||
final String rawClientId = properties.getOidcClientId();
|
||||
if (StringUtils.isBlank(rawClientId)) {
|
||||
throw new RuntimeException("Client ID is required when configuring an OIDC Provider.");
|
||||
}
|
||||
clientId = new ClientID(rawClientId);
|
||||
|
||||
// client secret
|
||||
final String rawClientSecret = properties.getOidcClientSecret();
|
||||
if (StringUtils.isBlank(rawClientSecret)) {
|
||||
throw new RuntimeException("Client secret is required when configured an OIDC Provider.");
|
||||
}
|
||||
clientSecret = new Secret(rawClientSecret);
|
||||
|
||||
try {
|
||||
// retrieve the oidc provider metadata
|
||||
oidcProviderMetadata = retrieveOidcProviderMetadata(properties.getOidcDiscoveryUrl());
|
||||
} catch (IOException | ParseException e) {
|
||||
throw new RuntimeException("Unable to retrieve OpenId Connect Provider metadata from: " + properties.getOidcDiscoveryUrl(), e);
|
||||
}
|
||||
|
||||
// ensure the authorization endpoint is present
|
||||
if (oidcProviderMetadata.getAuthorizationEndpointURI() == null) {
|
||||
throw new RuntimeException("OpenId Connect Provider metadata does not contain an Authorization Endpoint.");
|
||||
}
|
||||
|
||||
// ensure the token endpoint is present
|
||||
if (oidcProviderMetadata.getTokenEndpointURI() == null) {
|
||||
throw new RuntimeException("OpenId Connect Provider metadata does not contain a Token Endpoint.");
|
||||
}
|
||||
|
||||
// ensure the required scopes are present
|
||||
if (oidcProviderMetadata.getScopes() == null) {
|
||||
if (!oidcProviderMetadata.getScopes().contains(OIDCScopeValue.OPENID)) {
|
||||
throw new RuntimeException("OpenId Connect Provider does not support the required scope: " + OIDCScopeValue.OPENID.getValue());
|
||||
}
|
||||
|
||||
if (!oidcProviderMetadata.getScopes().contains(OIDCScopeValue.EMAIL) && oidcProviderMetadata.getUserInfoEndpointURI() == null) {
|
||||
throw new RuntimeException(String.format("OpenId Connect Provider does not support '%s' scope and does not provide a UserInfo Endpoint.", OIDCScopeValue.EMAIL.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
// ensure the oidc provider supports basic or post client auth
|
||||
final List<ClientAuthenticationMethod> clientAuthenticationMethods = oidcProviderMetadata.getTokenEndpointAuthMethods();
|
||||
if (clientAuthenticationMethods == null
|
||||
|| (!clientAuthenticationMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||
&& !clientAuthenticationMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_POST))) {
|
||||
|
||||
throw new RuntimeException(String.format("OpenId Connect Provider does not support %s or %s",
|
||||
ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue(),
|
||||
ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue()));
|
||||
}
|
||||
|
||||
// extract the supported json web signature algorithms
|
||||
final List<JWSAlgorithm> allowedAlgorithms = oidcProviderMetadata.getIDTokenJWSAlgs();
|
||||
if (allowedAlgorithms == null || allowedAlgorithms.isEmpty()) {
|
||||
throw new RuntimeException("The OpenId Connect Provider does not support any JWS algorithms.");
|
||||
}
|
||||
|
||||
try {
|
||||
// get the preferred json web signature algorithm
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredJwsAlgorithm == null) {
|
||||
tokenValidator = new IDTokenValidator(oidcProviderMetadata.getIssuer(), clientId);
|
||||
} else if (JWSAlgorithm.HS256.equals(preferredJwsAlgorithm) || JWSAlgorithm.HS384.equals(preferredJwsAlgorithm) || JWSAlgorithm.HS512.equals(preferredJwsAlgorithm)) {
|
||||
tokenValidator = new IDTokenValidator(oidcProviderMetadata.getIssuer(), clientId, preferredJwsAlgorithm, clientSecret);
|
||||
} else {
|
||||
final ResourceRetriever retriever = new DefaultResourceRetriever(oidcConnectTimeout, oidcReadTimeout);
|
||||
tokenValidator = new IDTokenValidator(oidcProviderMetadata.getIssuer(), clientId, preferredJwsAlgorithm, oidcProviderMetadata.getJWKSetURI().toURL(), retriever);
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
throw new RuntimeException("Unable to create the ID token validator for the configured OpenId Connect Provider: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private OIDCProviderMetadata retrieveOidcProviderMetadata(final String discoveryUri) throws IOException, ParseException {
|
||||
final URL url = new URL(discoveryUri);
|
||||
final HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.GET, url);
|
||||
httpRequest.setConnectTimeout(oidcConnectTimeout);
|
||||
httpRequest.setReadTimeout(oidcReadTimeout);
|
||||
|
||||
final HTTPResponse httpResponse = httpRequest.send();
|
||||
|
||||
if (httpResponse.getStatusCode() != 200) {
|
||||
throw new IOException("Unable to download OpenId Connect Provider metadata from " + url + ": Status code " + httpResponse.getStatusCode());
|
||||
}
|
||||
|
||||
final JSONObject jsonObject = httpResponse.getContentAsJSONObject();
|
||||
return OIDCProviderMetadata.parse(jsonObject);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOidcEnabled() {
|
||||
return properties.isOidcEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public URI getAuthorizationEndpoint() {
|
||||
if (!isOidcEnabled()) {
|
||||
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
return oidcProviderMetadata.getAuthorizationEndpointURI();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Scope getScope() {
|
||||
if (!isOidcEnabled()) {
|
||||
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
final Scope scope = new Scope("openid");
|
||||
|
||||
// if this provider supports email scope, include it to prevent a subsequent request to the user endpoint
|
||||
if (oidcProviderMetadata.getScopes() != null && oidcProviderMetadata.getScopes().contains(OIDCScopeValue.EMAIL)) {
|
||||
scope.add("email");
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientID getClientId() {
|
||||
if (!isOidcEnabled()) {
|
||||
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
return clientId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String exchangeAuthorizationCode(final AuthorizationGrant authorizationGrant) throws IOException {
|
||||
if (!isOidcEnabled()) {
|
||||
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
final ClientAuthentication clientAuthentication;
|
||||
if (oidcProviderMetadata.getTokenEndpointAuthMethods().contains(ClientAuthenticationMethod.CLIENT_SECRET_POST)) {
|
||||
clientAuthentication = new ClientSecretPost(clientId, clientSecret);
|
||||
} else {
|
||||
clientAuthentication = new ClientSecretBasic(clientId, clientSecret);
|
||||
}
|
||||
|
||||
try {
|
||||
// build the token request
|
||||
final TokenRequest request = new TokenRequest(oidcProviderMetadata.getTokenEndpointURI(), clientAuthentication, authorizationGrant, getScope());
|
||||
final HTTPRequest tokenHttpRequest = request.toHTTPRequest();
|
||||
tokenHttpRequest.setConnectTimeout(oidcConnectTimeout);
|
||||
tokenHttpRequest.setReadTimeout(oidcReadTimeout);
|
||||
|
||||
// get the token response
|
||||
final TokenResponse response = OIDCTokenResponseParser.parse(tokenHttpRequest.send());
|
||||
|
||||
if (response.indicatesSuccess()) {
|
||||
final OIDCTokenResponse oidcTokenResponse = (OIDCTokenResponse) response;
|
||||
final OIDCTokens oidcTokens = oidcTokenResponse.getOIDCTokens();
|
||||
final JWT oidcJwt = oidcTokens.getIDToken();
|
||||
|
||||
// validate the token - no nonce required for authorization code flow
|
||||
final IDTokenClaimsSet claimsSet = tokenValidator.validate(oidcJwt, null);
|
||||
|
||||
// attempt to extract the email from the id token if possible
|
||||
String email = claimsSet.getStringClaim(EMAIL_CLAIM_NAME);
|
||||
if (StringUtils.isBlank(email)) {
|
||||
// extract the bearer access token
|
||||
final BearerAccessToken bearerAccessToken = oidcTokens.getBearerAccessToken();
|
||||
if (bearerAccessToken == null) {
|
||||
throw new IllegalStateException("No access token found in the ID tokens");
|
||||
}
|
||||
|
||||
// invoke the UserInfo endpoint
|
||||
email = lookupEmail(bearerAccessToken);
|
||||
}
|
||||
|
||||
// extract expiration details from the claims set
|
||||
final Calendar now = Calendar.getInstance();
|
||||
final Date expiration = claimsSet.getExpirationTime();
|
||||
final long expiresIn = expiration.getTime() - now.getTimeInMillis();
|
||||
|
||||
// convert into a nifi jwt for retrieval later
|
||||
final LoginAuthenticationToken loginToken = new LoginAuthenticationToken(email, email, expiresIn, claimsSet.getIssuer().getValue());
|
||||
return jwtService.generateSignedToken(loginToken);
|
||||
} else {
|
||||
final TokenErrorResponse errorResponse = (TokenErrorResponse) response;
|
||||
throw new RuntimeException("An error occurred while invoking the Token endpoint: " + errorResponse.getErrorObject().getDescription());
|
||||
}
|
||||
} catch (final ParseException | JOSEException | BadJOSEException e) {
|
||||
throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String lookupEmail(final BearerAccessToken bearerAccessToken) throws IOException {
|
||||
try {
|
||||
// build the user request
|
||||
final UserInfoRequest request = new UserInfoRequest(oidcProviderMetadata.getUserInfoEndpointURI(), bearerAccessToken);
|
||||
final HTTPRequest tokenHttpRequest = request.toHTTPRequest();
|
||||
tokenHttpRequest.setConnectTimeout(oidcConnectTimeout);
|
||||
tokenHttpRequest.setReadTimeout(oidcReadTimeout);
|
||||
|
||||
// send the user request
|
||||
final UserInfoResponse response = UserInfoResponse.parse(request.toHTTPRequest().send());
|
||||
|
||||
// interpret the details
|
||||
if (response.indicatesSuccess()) {
|
||||
final UserInfoSuccessResponse successResponse = (UserInfoSuccessResponse) response;
|
||||
|
||||
final JWTClaimsSet claimsSet;
|
||||
if (successResponse.getUserInfo() != null) {
|
||||
claimsSet = successResponse.getUserInfo().toJWTClaimsSet();
|
||||
} else {
|
||||
claimsSet = successResponse.getUserInfoJWT().getJWTClaimsSet();
|
||||
}
|
||||
|
||||
final String email = claimsSet.getStringClaim(EMAIL_CLAIM_NAME);
|
||||
|
||||
// ensure we were able to get the user email
|
||||
if (StringUtils.isBlank(email)) {
|
||||
throw new IllegalStateException("Unable to extract email from the UserInfo token.");
|
||||
} else {
|
||||
return email;
|
||||
}
|
||||
} else {
|
||||
final UserInfoErrorResponse errorResponse = (UserInfoErrorResponse) response;
|
||||
throw new RuntimeException("An error occurred while invoking the UserInfo endpoint: " + errorResponse.getErrorObject().getDescription());
|
||||
}
|
||||
} catch (final ParseException | java.text.ParseException e) {
|
||||
throw new RuntimeException("Unable to parse the response from the UserInfo token request: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -60,7 +60,7 @@ public class OtpAuthenticationRequestToken extends NiFiAuthenticationRequestToke
|
|||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getName();
|
||||
return "<OTP token>";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -78,4 +78,13 @@
|
|||
<property name="properties" ref="nifiProperties"/>
|
||||
</bean>
|
||||
|
||||
<!-- oidc -->
|
||||
<bean id="oidcProvider" class="org.apache.nifi.web.security.oidc.StandardOidcIdentityProvider">
|
||||
<constructor-arg ref="jwtService" index="0"/>
|
||||
<constructor-arg ref="nifiProperties" index="1"/>
|
||||
</bean>
|
||||
<bean id="oidcService" class="org.apache.nifi.web.security.oidc.OidcService">
|
||||
<constructor-arg ref="oidcProvider"/>
|
||||
</bean>
|
||||
|
||||
</beans>
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* 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.oidc;
|
||||
|
||||
import com.nimbusds.oauth2.sdk.AuthorizationCode;
|
||||
import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
|
||||
import com.nimbusds.oauth2.sdk.id.State;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Matchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class OidcServiceTest {
|
||||
|
||||
public static final String TEST_REQUEST_IDENTIFIER = "test-request-identifier";
|
||||
public static final String TEST_STATE = "test-state";
|
||||
|
||||
@Test(expected = IllegalStateException.class)
|
||||
public void testOidcNotEnabledCreateState() throws Exception {
|
||||
final OidcService service = getServiceWithNoOidcSupport();
|
||||
service.createState(TEST_REQUEST_IDENTIFIER);
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException.class)
|
||||
public void testCreateStateMultipleInvocations() throws Exception {
|
||||
final OidcService service = getServiceWithOidcSupport();
|
||||
service.createState(TEST_REQUEST_IDENTIFIER);
|
||||
service.createState(TEST_REQUEST_IDENTIFIER);
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException.class)
|
||||
public void testOidcNotEnabledValidateState() throws Exception {
|
||||
final OidcService service = getServiceWithNoOidcSupport();
|
||||
service.isStateValid(TEST_REQUEST_IDENTIFIER, new State(TEST_STATE));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOidcUnknownState() throws Exception {
|
||||
final OidcService service = getServiceWithOidcSupport();
|
||||
assertFalse(service.isStateValid(TEST_REQUEST_IDENTIFIER, new State(TEST_STATE)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateState() throws Exception {
|
||||
final OidcService service = getServiceWithOidcSupport();
|
||||
final State state = service.createState(TEST_REQUEST_IDENTIFIER);
|
||||
assertTrue(service.isStateValid(TEST_REQUEST_IDENTIFIER, state));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateStateExpiration() throws Exception {
|
||||
final OidcService service = getServiceWithOidcSupportAndCustomExpiration(1, TimeUnit.SECONDS);
|
||||
final State state = service.createState(TEST_REQUEST_IDENTIFIER);
|
||||
|
||||
Thread.sleep(3 * 1000);
|
||||
|
||||
assertFalse(service.isStateValid(TEST_REQUEST_IDENTIFIER, state));
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException.class)
|
||||
public void testOidcNotEnabledExchangeCode() throws Exception {
|
||||
final OidcService service = getServiceWithNoOidcSupport();
|
||||
service.exchangeAuthorizationCode(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());
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException.class)
|
||||
public void testOidcNotEnabledGetJwt() throws Exception {
|
||||
final OidcService service = getServiceWithNoOidcSupport();
|
||||
service.getJwt(TEST_REQUEST_IDENTIFIER);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetJwt() throws Exception {
|
||||
final OidcService service = getServiceWithOidcSupport();
|
||||
service.exchangeAuthorizationCode(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());
|
||||
|
||||
Thread.sleep(3 * 1000);
|
||||
|
||||
assertNull(service.getJwt(TEST_REQUEST_IDENTIFIER));
|
||||
}
|
||||
|
||||
private OidcService getServiceWithNoOidcSupport() {
|
||||
final OidcIdentityProvider provider = mock(OidcIdentityProvider.class);
|
||||
when(provider.isOidcEnabled()).thenReturn(false);
|
||||
|
||||
final OidcService service = new OidcService(provider);
|
||||
assertFalse(service.isOidcEnabled());
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
final OidcService service = new OidcService(provider);
|
||||
assertTrue(service.isOidcEnabled());
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
final OidcService service = new OidcService(provider, duration, units);
|
||||
assertTrue(service.isOidcEnabled());
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
private AuthorizationCodeGrant getAuthorizationCodeGrant() {
|
||||
return new AuthorizationCodeGrant(new AuthorizationCode("code"), URI.create("http://localhost:8080/nifi"));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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.filter;
|
||||
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.FilterConfig;
|
||||
import javax.servlet.ServletContext;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Filter for determining appropriate login location.
|
||||
*/
|
||||
public class LoginFilter implements Filter {
|
||||
|
||||
private ServletContext servletContext;
|
||||
|
||||
@Override
|
||||
public void init(FilterConfig filterConfig) throws ServletException {
|
||||
servletContext = filterConfig.getServletContext();
|
||||
}
|
||||
|
||||
@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-api");
|
||||
apiContext.getRequestDispatcher("/access/oidc/request").forward(request, response);
|
||||
} else {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
}
|
||||
}
|
|
@ -32,18 +32,37 @@
|
|||
<head>
|
||||
<title><%= request.getAttribute("title") == null ? "" : org.apache.nifi.util.EscapeUtils.escapeHtml(request.getAttribute("title").toString()) %></title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<link rel="shortcut icon" href="images/nifi16.ico"/>
|
||||
<link rel="shortcut icon" href="<%= contextPath %>/images/nifi16.ico"/>
|
||||
<link rel="stylesheet" href="<%= contextPath %>/nifi/assets/reset.css/reset.css" type="text/css" />
|
||||
<link rel="stylesheet" href="<%= contextPath %>/nifi/css/common-ui.css" type="text/css" />
|
||||
<link rel="stylesheet" href="<%= contextPath %>/nifi/fonts/flowfont/flowfont.css" type="text/css" />
|
||||
<link rel="stylesheet" href="<%= contextPath %>/nifi/assets/font-awesome/css/font-awesome.min.css" type="text/css" />
|
||||
<link rel="stylesheet" href="<%= contextPath %>/nifi/css/message-pane.css" type="text/css" />
|
||||
<link rel="stylesheet" href="<%= contextPath %>/nifi/css/message-page.css" type="text/css" />
|
||||
<script type="text/javascript" src="<%= contextPath %>/nifi/assets/jquery/dist/jquery.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
$('#user-home').on('mouseenter', function () {
|
||||
$(this).addClass('link-over');
|
||||
}).on('mouseleave', function () {
|
||||
$(this).removeClass('link-over');
|
||||
}).on('click', function () {
|
||||
window.location = '<%= contextPath %>/nifi';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body class="message-pane">
|
||||
<div class="message-pane-message-box">
|
||||
<div class="message-pane-title"><%= request.getAttribute("title") == null ? "" : org.apache.nifi.util.EscapeUtils.escapeHtml(request.getAttribute("title").toString()) %></div>
|
||||
<div id="user-links-container" style="margin-left: 20px; float: left;">
|
||||
<ul class="links">
|
||||
<li>
|
||||
<span id="user-home" class="link">home</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="message-pane-content"><%= request.getAttribute("messages") == null ? "" : org.apache.nifi.util.EscapeUtils.escapeHtml(request.getAttribute("messages").toString()) %></div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
@ -126,6 +126,16 @@
|
|||
<url-pattern>/login</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
<!-- login filter -->
|
||||
<filter>
|
||||
<filter-name>LoginFilter</filter-name>
|
||||
<filter-class>org.apache.nifi.web.filter.LoginFilter</filter-class>
|
||||
</filter>
|
||||
<filter-mapping>
|
||||
<filter-name>LoginFilter</filter-name>
|
||||
<url-pattern>/login</url-pattern>
|
||||
</filter-mapping>
|
||||
|
||||
<welcome-file-list>
|
||||
<welcome-file>canvas.jsp</welcome-file>
|
||||
<welcome-file>/WEB-INF/pages/canvas.jsp</welcome-file>
|
||||
|
|
|
@ -99,6 +99,7 @@
|
|||
currentUser: '../nifi-api/flow/current-user',
|
||||
controllerBulletins: '../nifi-api/flow/controller/bulletins',
|
||||
kerberos: '../nifi-api/access/kerberos',
|
||||
oidc: '../nifi-api/access/oidc/exchange',
|
||||
revision: '../nifi-api/flow/revision',
|
||||
banners: '../nifi-api/flow/banners'
|
||||
}
|
||||
|
@ -780,8 +781,16 @@
|
|||
* Initialize NiFi.
|
||||
*/
|
||||
init: function () {
|
||||
// attempt kerberos authentication
|
||||
// attempt kerberos/oidc authentication
|
||||
var ticketExchange = $.Deferred(function (deferred) {
|
||||
var successfulAuthentication = function (jwt) {
|
||||
// get the payload and store the token with the appropriate expiration
|
||||
var token = nfCommon.getJwtPayload(jwt);
|
||||
var expiration = parseInt(token['exp'], 10) * nfCommon.MILLIS_PER_SECOND;
|
||||
nfStorage.setItem('jwt', jwt, expiration);
|
||||
deferred.resolve();
|
||||
};
|
||||
|
||||
if (nfStorage.hasItem('jwt')) {
|
||||
deferred.resolve();
|
||||
} else {
|
||||
|
@ -790,13 +799,17 @@
|
|||
url: config.urls.kerberos,
|
||||
dataType: 'text'
|
||||
}).done(function (jwt) {
|
||||
// get the payload and store the token with the appropriate expiration
|
||||
var token = nfCommon.getJwtPayload(jwt);
|
||||
var expiration = parseInt(token['exp'], 10) * nfCommon.MILLIS_PER_SECOND;
|
||||
nfStorage.setItem('jwt', jwt, expiration);
|
||||
deferred.resolve();
|
||||
successfulAuthentication(jwt);
|
||||
}).fail(function () {
|
||||
deferred.reject();
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: config.urls.oidc,
|
||||
dataType: 'text'
|
||||
}).done(function (jwt) {
|
||||
successfulAuthentication(jwt)
|
||||
}).fail(function () {
|
||||
deferred.reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
}).promise();
|
||||
|
@ -822,7 +835,7 @@
|
|||
}).fail(function (xhr, status, error) {
|
||||
// there is no anonymous access and we don't know this user - open the login page which handles login/registration/etc
|
||||
if (xhr.status === 401) {
|
||||
window.location = '/nifi/login';
|
||||
window.location = '../nifi/login';
|
||||
} else {
|
||||
deferred.reject(xhr, status, error);
|
||||
}
|
||||
|
|
7
pom.xml
7
pom.xml
|
@ -95,7 +95,7 @@
|
|||
<jetty.version>9.4.3.v20170317</jetty.version>
|
||||
<lucene.version>4.10.4</lucene.version>
|
||||
<spring.version>4.2.4.RELEASE</spring.version>
|
||||
<spring.security.version>4.0.3.RELEASE</spring.security.version>
|
||||
<spring.security.version>4.2.3.RELEASE</spring.security.version>
|
||||
<jersey.version>1.19</jersey.version>
|
||||
<hadoop.version>2.7.3</hadoop.version>
|
||||
<hadoop.guava.version>12.0.1</hadoop.guava.version>
|
||||
|
@ -376,6 +376,11 @@
|
|||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>oauth2-oidc-sdk</artifactId>
|
||||
<version>5.34</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.sun.jersey.contribs</groupId>
|
||||
<artifactId>jersey-multipart</artifactId>
|
||||
|
|
Loading…
Reference in New Issue