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:
Matt Gilman 2017-08-01 10:46:45 -04:00 committed by Andy LoPresto
parent 505e93065e
commit 528b82634f
No known key found for this signature in database
GPG Key ID: 6EC293152D90B61D
24 changed files with 1373 additions and 30 deletions

View File

@ -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() {

View File

@ -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
--------------------------

View File

@ -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.

View File

@ -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>

View File

@ -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

View File

@ -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);

View File

@ -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>

View File

@ -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

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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"/>

View File

@ -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>

View File

@ -53,7 +53,7 @@ public class JwtAuthenticationRequestToken extends NiFiAuthenticationRequestToke
@Override
public String toString() {
return getName();
return "<JWT token>";
}
}

View File

@ -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;
}

View File

@ -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));
}
}

View File

@ -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());
}
}
}

View File

@ -60,7 +60,7 @@ public class OtpAuthenticationRequestToken extends NiFiAuthenticationRequestToke
@Override
public String toString() {
return getName();
return "<OTP token>";
}
}

View File

@ -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>

View File

@ -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"));
}
}

View File

@ -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() {
}
}

View File

@ -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>

View File

@ -15,7 +15,7 @@
-->
<web-app version="3.0" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd">
<display-name>nifi</display-name>
<!-- servlet to map to canvas page -->
<servlet>
<servlet-name>NiFiCanvas</servlet-name>
@ -25,7 +25,7 @@
<servlet-name>NiFiCanvas</servlet-name>
<url-pattern>/canvas</url-pattern>
</servlet-mapping>
<!-- servlet to map to summary page -->
<servlet>
<servlet-name>NiFiSummary</servlet-name>
@ -35,7 +35,7 @@
<servlet-name>NiFiSummary</servlet-name>
<url-pattern>/summary</url-pattern>
</servlet-mapping>
<!-- servlet to map to history page -->
<servlet>
<servlet-name>NiFiHistory</servlet-name>
@ -45,7 +45,7 @@
<servlet-name>NiFiHistory</servlet-name>
<url-pattern>/history</url-pattern>
</servlet-mapping>
<!-- servlet to map to provenance page -->
<servlet>
<servlet-name>NiFiProvenance</servlet-name>
@ -55,7 +55,7 @@
<servlet-name>NiFiProvenance</servlet-name>
<url-pattern>/provenance</url-pattern>
</servlet-mapping>
<!-- servlet to map to counters page -->
<servlet>
<servlet-name>NiFiCounters</servlet-name>
@ -65,7 +65,7 @@
<servlet-name>NiFiCounters</servlet-name>
<url-pattern>/counters</url-pattern>
</servlet-mapping>
<!-- servlet to map to templates page -->
<servlet>
<servlet-name>NiFiTemplates</servlet-name>
@ -75,7 +75,7 @@
<servlet-name>NiFiTemplates</servlet-name>
<url-pattern>/templates</url-pattern>
</servlet-mapping>
<!-- servlet to map to users page -->
<servlet>
<servlet-name>NiFiUsers</servlet-name>
@ -85,7 +85,7 @@
<servlet-name>NiFiUsers</servlet-name>
<url-pattern>/users</url-pattern>
</servlet-mapping>
<!-- servlet to map to cluster page -->
<servlet>
<servlet-name>NiFiCluster</servlet-name>
@ -95,7 +95,7 @@
<servlet-name>NiFiCluster</servlet-name>
<url-pattern>/cluster</url-pattern>
</servlet-mapping>
<!-- servlet to map to bulletin board page -->
<servlet>
<servlet-name>BulletinBoard</servlet-name>
@ -105,7 +105,7 @@
<servlet-name>BulletinBoard</servlet-name>
<url-pattern>/bulletin-board</url-pattern>
</servlet-mapping>
<!-- servlet to support message page -->
<servlet>
<servlet-name>MessagePage</servlet-name>
@ -115,7 +115,7 @@
<servlet-name>MessagePage</servlet-name>
<url-pattern>/message</url-pattern>
</servlet-mapping>
<!-- servlet to login page -->
<servlet>
<servlet-name>Login</servlet-name>
@ -125,7 +125,17 @@
<servlet-name>Login</servlet-name>
<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>

View 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);
}

View File

@ -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>