exceptionfactory commented on a change in pull request #4988:
URL: https://github.com/apache/nifi/pull/4988#discussion_r611632508
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
##########
@@ -1456,10 +1464,12 @@ public Response createAccessToken(
// generate JWT for response
final String token =
jwtService.generateSignedToken(loginAuthenticationToken);
+ // currently there is no way to use javax.servlet-api to set
SameSite=Strict, so we do this using Jetty
+ HttpCookie jwtCookie = new
HttpCookie(JwtAuthenticationFilter.JWT_COOKIE_NAME, token, null, "/",
TWELVE_HOURS, true, true, null, 0, HttpCookie.SameSite.STRICT);
// build the response
final URI uri = URI.create(generateResourceUri("access", "token"));
- return generateCreatedResponse(uri, token).build();
+ return generateCreatedResponse(uri,
token).header(HttpHeader.SET_COOKIE.asString(),
jwtCookie.getRFC6265SetCookie()).build();
Review comment:
Wrapping the `header()` calls for setting the cookie in a single method
would help indicate which methods set the cookie. Something along the lines
for `generateTokenResponse(ResponseBuilder builder, String jwt)` would provide
an opportunity for streamlining cookie response generation.
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
##########
@@ -847,8 +850,10 @@ public Response oidcExchange(@Context HttpServletRequest
httpServletRequest, @Co
throw new IllegalArgumentException("A JWT for this login request
identifier could not be found. Unable to continue.");
}
+ HttpCookie jwtCookie = new
HttpCookie(JwtAuthenticationFilter.JWT_COOKIE_NAME, jwt, null, "/",
TWELVE_HOURS, true, true, null, 0, HttpCookie.SameSite.STRICT);
Review comment:
Instead of instantiating `HttpCookie` in multiple methods, it would be
helpful to encapsulate the cookie generation using an interface and
implementation class. Having an interface method such as `String
CookieProvider.getJwtCookie(String jwt)` would streamline references here in
`AccessResource`, and also provide opportunity for introducing a unit test that
ensures certain fields are set as intended.
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
##########
@@ -1812,4 +1826,25 @@ public void setLogoutRequestManager(LogoutRequestManager
logoutRequestManager) {
this.logoutRequestManager = logoutRequestManager;
}
+ private void logOutUser(HttpServletRequest httpServletRequest) throws
LogoutException {
+ final String jwt = getJwtFromRequest(httpServletRequest);
+ jwtService.logOut(jwt);
+ }
+
+ private String getJwtFromRequest(HttpServletRequest httpServletRequest) {
+ final String authCookie =
getCookieValue(httpServletRequest.getCookies(),
JwtAuthenticationFilter.JWT_COOKIE_NAME);
+ final String authHeader =
httpServletRequest.getHeader(httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION));
+
+ String jwt = null;
+
+ if (authCookie != null && !authCookie.isEmpty()) {
+ jwt = authCookie;
+ }
+
+ if (authHeader != null && !authHeader.isEmpty()) {
+ jwt = JwtAuthenticationFilter.getTokenFromHeader(authHeader);
Review comment:
Are the separate conditionals intentional? As implemented, request may
contain different values in the `Cookie` and `Authorization` headers, and the
`Authorization` header will override whatever was provided in the `Cookie`.
It seems like the `Authorization` header should be checked first, following
the current implementation.
Looking at the rest of the PR, this method also has some logic from the
retrieval used in the `JwtAuthenticationFilter`. It seems like this should be
centralized in a common place to avoid confusion. The Spring Security OAuth2
library includes the concept of a `BearerTokenResolver` interface and
implementation. We should either consider using that library, or at least
implementation a similar approach.
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
##########
@@ -1812,4 +1826,25 @@ public void setLogoutRequestManager(LogoutRequestManager
logoutRequestManager) {
this.logoutRequestManager = logoutRequestManager;
}
+ private void logOutUser(HttpServletRequest httpServletRequest) throws
LogoutException {
+ final String jwt = getJwtFromRequest(httpServletRequest);
+ jwtService.logOut(jwt);
+ }
+
+ private String getJwtFromRequest(HttpServletRequest httpServletRequest) {
+ final String authCookie =
getCookieValue(httpServletRequest.getCookies(),
JwtAuthenticationFilter.JWT_COOKIE_NAME);
+ final String authHeader =
httpServletRequest.getHeader(httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION));
+
+ String jwt = null;
+
+ if (authCookie != null && !authCookie.isEmpty()) {
+ jwt = authCookie;
+ }
+
+ if (authHeader != null && !authHeader.isEmpty()) {
Review comment:
This could be streamlined using `StringUtils.isNotBlank()`:
```suggestion
if (StringUtils.isNotBlank(authHeader)) {
```
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
##########
@@ -43,33 +51,63 @@ public Authentication attemptAuthentication(final
HttpServletRequest request) {
return null;
}
- // TODO: Refactor request header extraction logic to shared utility as
it is duplicated in AccessResource
+ // Check for JWT in cookie and header
+ final String cookieToken = getTokenFromCookie(request);
+ final String headerToken = getTokenFromHeader(request);
Review comment:
As mentioned in the `TODO` comment, recommend refactor this logic to a
shared interface and implementation class named `BearerTokenResolver` or
`AuthorizationTokenResolver`.
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
##########
@@ -143,6 +146,7 @@
private static final Pattern REVOKE_ACCESS_TOKEN_LOGOUT_FORMAT =
Pattern.compile("(\\.google\\.com)");
private static final Pattern ID_TOKEN_LOGOUT_FORMAT =
Pattern.compile("(\\.okta)");
private static final int msTimeout = 30_000;
+ private static final int TWELVE_HOURS = 43200;
Review comment:
Hard-coding cookie maximum age does not seem like the best approach.
Using a Session Cookie, with a maximum age of `-1` will remove the cookie when
closing the browser. Multiple tabs and windows should continue to send the
session cookie, but closing the browser will remove the cookie and require the
user to login again. This seems like a better approach that follows the
current implementation more closely.
The other option is to base the cookie expiration using the JWT expiration,
but making this a session cookie seems like the better option.
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js
##########
@@ -102,7 +102,6 @@
type: 'DELETE',
url: '../nifi-api/access/logout',
}).done(function () {
- nfStorage.removeItem("jwt");
Review comment:
Is there a reason for not the JWT token after a successful logout? It
seems like that should still occur.
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
##########
@@ -1456,10 +1464,12 @@ public Response createAccessToken(
// generate JWT for response
final String token =
jwtService.generateSignedToken(loginAuthenticationToken);
+ // currently there is no way to use javax.servlet-api to set
SameSite=Strict, so we do this using Jetty
Review comment:
Refactoring cookie generation and rendering would also provide the
opportunity to include this comment in one location.
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
##########
@@ -868,6 +873,10 @@ public void oidcLogout(@Context HttpServletRequest
httpServletRequest, @Context
throw new
IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
}
+ final String mappedUserIdentity = NiFiUserUtils.getNiFiUserIdentity();
+ removeCookie(httpServletResponse,
JwtAuthenticationFilter.JWT_COOKIE_NAME);
+ logger.info("Successfully invalidated JWT for " + mappedUserIdentity);
Review comment:
It seems like this should be a debug message, and also recommend using
placeholder log statements with reference that this is related to OIDC.
Removing this statement would also remove the need for retrieving the NiFi User
Identity from `NiFiUserUtils`, so it this necessary?
```suggestion
logger.debug("OIDC User [{}] Logout Completed", mappedUserIdentity);
```
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
##########
@@ -1507,7 +1518,10 @@ public Response logOut(@Context HttpServletRequest
httpServletRequest, @Context
return generateOkResponse().build();
} catch (final JwtException e) {
- logger.error("Logout of user " + mappedUserIdentity + " failed due
to: " + e.getMessage(), e);
+ logger.error("There was a problem with [" + mappedUserIdentity +
"]'s JWT, failed due to: " + e.getMessage(), e);
Review comment:
Recommend taking the opportunity to change the logging statement to use
placeholders:
```suggestion
logger.error("JWT Processing Failed for User [{}]: {}",
mappedUserIdentity, e.getMessage(), e);
```
##########
File path:
nifi-framework-api/src/main/java/org/apache/nifi/authorization/user/NiFiUser.java
##########
@@ -61,4 +61,9 @@
*/
String getClientAddress();
+ /**
+ * @return <code>true</code> if the user can use the log out button
+ */
+ boolean canLogOut();
Review comment:
What do you think about renaming this to logOutEnabled? It is minor,
but it reads better for method names.
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
##########
@@ -1812,4 +1826,25 @@ public void setLogoutRequestManager(LogoutRequestManager
logoutRequestManager) {
this.logoutRequestManager = logoutRequestManager;
}
+ private void logOutUser(HttpServletRequest httpServletRequest) throws
LogoutException {
+ final String jwt = getJwtFromRequest(httpServletRequest);
+ jwtService.logOut(jwt);
+ }
+
+ private String getJwtFromRequest(HttpServletRequest httpServletRequest) {
+ final String authCookie =
getCookieValue(httpServletRequest.getCookies(),
JwtAuthenticationFilter.JWT_COOKIE_NAME);
+ final String authHeader =
httpServletRequest.getHeader(httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION));
+
+ String jwt = null;
+
+ if (authCookie != null && !authCookie.isEmpty()) {
Review comment:
This could be streamlined using `StringUtils.isNotBlank()`:
```suggestion
if (StringUtils.isNotBlank(authCookie)) {
```
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
##########
@@ -34,7 +38,11 @@
// The Authorization header contains authentication credentials
public static final String AUTHORIZATION = "Authorization";
- private static final Pattern tokenPattern = Pattern.compile("^Bearer
(\\S*\\.\\S*\\.\\S*)$");
+ public static final String JWT_COOKIE_NAME = "jwt-auth-cookie";
+ private static final Pattern BEARER_HEADER_PATTERN =
Pattern.compile("^Bearer (\\S*\\.\\S*\\.\\S*)$");
+ private static final Pattern JWT_PATTERN =
Pattern.compile("^(\\S*\\.\\S*\\.\\S*)$");
+ private static final List<String> UNAUTHENTICATED_METHODS =
Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS");
Review comment:
Recommend renaming this variable for clarity, since these methods still
require authentication, just not CSRF protection.
```suggestion
private static final List<String> IDEMPOTENT_METHODS =
Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS");
```
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
##########
@@ -43,33 +51,63 @@ public Authentication attemptAuthentication(final
HttpServletRequest request) {
return null;
}
- // TODO: Refactor request header extraction logic to shared utility as
it is duplicated in AccessResource
+ // Check for JWT in cookie and header
+ final String cookieToken = getTokenFromCookie(request);
+ final String headerToken = getTokenFromHeader(request);
+ if (cookieToken != null && !cookieToken.isEmpty()) {
+ if
(!UNAUTHENTICATED_METHODS.contains(request.getMethod().toUpperCase())) {
+ // To protect against CSRF when using a cookie, if the request
method requires authentication the request must have a matching Authorization
header JWT
+ if (headerToken.equals(cookieToken)) {
+ return new JwtAuthenticationRequestToken(headerToken,
request.getRemoteAddr());
+ } else {
+ throw new InvalidAuthenticationException("Authorization
HTTP header and authentication cookie did not match.");
+ }
+ } else {
+ return new JwtAuthenticationRequestToken(cookieToken,
request.getRemoteAddr());
+ }
+ } else if (headerToken != null && !headerToken.isEmpty()) {
Review comment:
This could be refactored to use `StringUtils.isNotBlank()`
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
##########
@@ -34,7 +38,11 @@
// The Authorization header contains authentication credentials
public static final String AUTHORIZATION = "Authorization";
- private static final Pattern tokenPattern = Pattern.compile("^Bearer
(\\S*\\.\\S*\\.\\S*)$");
+ public static final String JWT_COOKIE_NAME = "jwt-auth-cookie";
Review comment:
As an added security measure, the `__Host-` prefix should be added to
this cookie name so that browsers will not allow the potential for compromised
subdomains to override the cookie value. The [Mozilla Developer Network
Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie)
documentation describes the details.
```suggestion
public static final String JWT_COOKIE_NAME = "__Host-jwt-auth-cookie";
```
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtService.java
##########
@@ -186,7 +187,7 @@ public String generateSignedToken(final
LoginAuthenticationToken authenticationT
* @throws JwtException if there is a problem with the token input
* @throws Exception if there is an issue logging the user out
*/
- public void logOut(String token) {
+ public void logOut(String token) throws LogoutException {
Review comment:
As a subclass of `RuntimeException`, `LogoutException` does not need to
be declared.
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
##########
@@ -43,33 +51,63 @@ public Authentication attemptAuthentication(final
HttpServletRequest request) {
return null;
}
- // TODO: Refactor request header extraction logic to shared utility as
it is duplicated in AccessResource
+ // Check for JWT in cookie and header
+ final String cookieToken = getTokenFromCookie(request);
+ final String headerToken = getTokenFromHeader(request);
+ if (cookieToken != null && !cookieToken.isEmpty()) {
+ if
(!UNAUTHENTICATED_METHODS.contains(request.getMethod().toUpperCase())) {
+ // To protect against CSRF when using a cookie, if the request
method requires authentication the request must have a matching Authorization
header JWT
+ if (headerToken.equals(cookieToken)) {
+ return new JwtAuthenticationRequestToken(headerToken,
request.getRemoteAddr());
+ } else {
+ throw new InvalidAuthenticationException("Authorization
HTTP header and authentication cookie did not match.");
+ }
+ } else {
+ return new JwtAuthenticationRequestToken(cookieToken,
request.getRemoteAddr());
+ }
+ } else if (headerToken != null && !headerToken.isEmpty()) {
+ return new JwtAuthenticationRequestToken(headerToken,
request.getRemoteAddr());
+ } else {
+ return null;
+ }
+ }
+
+ private boolean validJwtFormat(String authenticationHeader) {
+ Matcher matcher = BEARER_HEADER_PATTERN.matcher(authenticationHeader);
+ return matcher.matches();
+ }
- // get the principal out of the user token
+ private String getTokenFromHeader(final HttpServletRequest request) {
final String authorizationHeader = request.getHeader(AUTHORIZATION);
// if there is no authorization header, we don't know the user
if (authorizationHeader == null ||
!validJwtFormat(authorizationHeader)) {
return null;
} else {
// Extract the Base64 encoded token from the Authorization header
- final String token = getTokenFromHeader(authorizationHeader);
- return new JwtAuthenticationRequestToken(token,
request.getRemoteAddr());
+ return getTokenFromHeader(authorizationHeader);
}
}
- private boolean validJwtFormat(String authenticationHeader) {
- Matcher matcher = tokenPattern.matcher(authenticationHeader);
- return matcher.matches();
- }
-
public static String getTokenFromHeader(String authenticationHeader) {
- Matcher matcher = tokenPattern.matcher(authenticationHeader);
+ Matcher matcher = BEARER_HEADER_PATTERN.matcher(authenticationHeader);
if(matcher.matches()) {
return matcher.group(1);
} else {
throw new InvalidAuthenticationException("JWT did not match
expected pattern.");
}
}
+ private String getTokenFromCookie(final HttpServletRequest request) {
Review comment:
Spring `WebUtils` provides a `getCookie` method that could be used to
simplify this method.
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
##########
@@ -1507,7 +1518,10 @@ public Response logOut(@Context HttpServletRequest
httpServletRequest, @Context
return generateOkResponse().build();
} catch (final JwtException e) {
- logger.error("Logout of user " + mappedUserIdentity + " failed due
to: " + e.getMessage(), e);
+ logger.error("There was a problem with [" + mappedUserIdentity +
"]'s JWT, failed due to: " + e.getMessage(), e);
+ return Response.serverError().build();
+ } catch (final LogoutException e) {
+ logger.error("There was a problem logging out user [%s] due to: ",
mappedUserIdentity, e);
Review comment:
The `%s` placeholder is valid for String formatting, but not logging
statements:
```suggestion
logger.error("Logout Failed for User [{}]", mappedUserIdentity,
e);
```
##########
File path:
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
##########
@@ -43,33 +51,63 @@ public Authentication attemptAuthentication(final
HttpServletRequest request) {
return null;
}
- // TODO: Refactor request header extraction logic to shared utility as
it is duplicated in AccessResource
+ // Check for JWT in cookie and header
+ final String cookieToken = getTokenFromCookie(request);
+ final String headerToken = getTokenFromHeader(request);
+ if (cookieToken != null && !cookieToken.isEmpty()) {
+ if
(!UNAUTHENTICATED_METHODS.contains(request.getMethod().toUpperCase())) {
+ // To protect against CSRF when using a cookie, if the request
method requires authentication the request must have a matching Authorization
header JWT
+ if (headerToken.equals(cookieToken)) {
+ return new JwtAuthenticationRequestToken(headerToken,
request.getRemoteAddr());
+ } else {
+ throw new InvalidAuthenticationException("Authorization
HTTP header and authentication cookie did not match.");
+ }
+ } else {
+ return new JwtAuthenticationRequestToken(cookieToken,
request.getRemoteAddr());
+ }
+ } else if (headerToken != null && !headerToken.isEmpty()) {
Review comment:
It would be helpful to refactor this set of conditionals to make the
logic easier to follow. Part of the challenge here seems to be attempting to
accomplish multiple goals in a single method. It would be easier to follow if
CSRF protection were handled separate from JWT request token retrieval, even
though they are related.
Leveraging Spring Security's CsrfFilter and customizing token retrieval and
comparison seems like a better way to enforce CSRF protection. The CsrfFilter
default configuration already handled skipping idempotent HTTP methods, so it
would be a better of customizing the CsrfTokenRepository implementation to
retrieve the appropriate header, and then compare it against the same value
provided in the `Authorization` header.
Separating the implementation will require some more, but it should help
avoid confusion, and avoid potential pitfalls by using less custom code.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
For queries about this service, please contact Infrastructure at:
[email protected]