NIFI-6085 - Added /access/logout endpoint to allow JWT auth tokens to be removed correctly. Added some tests. Found an error in the KeyDAO which did not allow key deletion.

NIFI-6085 - Updated logOut method to use NiFiUserUtils and updated tests.
NIFI-6085 - Added some more integration tests.
NIFI-6085 Suppressed stacktrace when token is used after being invalidated.

This closes #3362.

Signed-off-by: Andy LoPresto <alopresto@apache.org>
This commit is contained in:
thenatog 2019-03-08 16:53:11 -05:00 committed by Andy LoPresto
parent 376c344edb
commit cf6f517250
No known key found for this signature in database
GPG Key ID: 6EC293152D90B61D
8 changed files with 381 additions and 15 deletions

View File

@ -161,6 +161,7 @@ public class StandardKeyDAO implements KeyDAO {
try {
// add each authority for the specified user
statement = connection.prepareStatement(DELETE_KEYS);
statement.setString(1, identity);
statement.executeUpdate();
} catch (SQLException sqle) {
throw new DataAccessException(sqle);

View File

@ -175,6 +175,13 @@
<artifactId>nifi-web-security</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.nifi</groupId>
<artifactId>nifi-web-security</artifactId>
<version>1.10.0-SNAPSHOT</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.nifi</groupId>
<artifactId>nifi-web-optimistic-locking</artifactId>

View File

@ -344,6 +344,9 @@ public class AccessResource extends ApplicationResource {
.build();
httpServletResponse.sendRedirect(logoutUri.toString());
}
String authorizationHeader = httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION);
jwtService.logOut(authorizationHeader);
}
@GET
@ -744,6 +747,35 @@ public class AccessResource extends ApplicationResource {
return generateCreatedResponse(uri, token).build();
}
@GET
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.WILDCARD)
@Path("/logout")
@ApiOperation(
value = "Performs a logout for other providers that have been issued a JWT.",
notes = NON_GUARANTEED_ENDPOINT
)
@ApiResponses(
value = {
@ApiResponse(code = 200, message = "User was logged out successfully."),
@ApiResponse(code = 500, message = "Client failed to log out."),
}
)
public Response logOut(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) {
if (!httpServletRequest.isSecure()) {
throw new IllegalStateException("User authentication/authorization is only supported when running over HTTPS.");
}
String authorizationHeader = httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION);
final String token = StringUtils.substringAfterLast(authorizationHeader, " ");
try {
jwtService.logOut(token);
return generateOkResponse().build();
} catch (final JwtException e) {
return Response.serverError().build();
}
}
private long validateTokenExpiration(long proposedTokenExpiration, String identity) {
final long maxExpiration = TimeUnit.MILLISECONDS.convert(12, TimeUnit.HOURS);
final long minExpiration = TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES);

View File

@ -16,6 +16,8 @@
*/
package org.apache.nifi.integration.accesscontrol;
import org.apache.nifi.web.security.jwt.JwtServiceTest;
import net.minidev.json.JSONObject;
import org.apache.commons.io.FileUtils;
import org.apache.nifi.bundle.Bundle;
import org.apache.nifi.integration.util.NiFiTestServer;
@ -48,14 +50,19 @@ import javax.ws.rs.core.Response;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Calendar;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.StringJoiner;
/**
* Access token endpoint test.
*/
public class ITAccessTokenEndpoint {
private final String user = "unregistered-user@nifi";
private final String password = "password";
private static final String CLIENT_ID = "token-endpoint-id";
private static final String CONTEXT_PATH = "/nifi-api";
@ -114,7 +121,7 @@ public class ITAccessTokenEndpoint {
}
// -----------
// LOGIN CONIG
// LOGIN CONFIG
// -----------
/**
* Test getting access configuration.
@ -254,7 +261,7 @@ public class ITAccessTokenEndpoint {
// verify unknown
Assert.assertEquals("UNKNOWN", accessStatus.getStatus());
response = TOKEN_USER.testCreateToken(accessTokenUrl, "unregistered-user@nifi", "password");
response = TOKEN_USER.testCreateToken(accessTokenUrl, user, password);
// ensure the request is successful
Assert.assertEquals(201, response.getStatus());
@ -279,6 +286,202 @@ public class ITAccessTokenEndpoint {
Assert.assertEquals("ACTIVE", accessStatus.getStatus());
}
@Test
public void testLogOutSuccess() throws Exception {
String accessStatusUrl = BASE_URL + "/access";
String accessTokenUrl = BASE_URL + "/access/token";
String logoutUrl = BASE_URL + "/access/logout";
Response response = TOKEN_USER.testGet(accessStatusUrl);
// ensure the request is successful
Assert.assertEquals(200, response.getStatus());
AccessStatusEntity accessStatusEntity = response.readEntity(AccessStatusEntity.class);
AccessStatusDTO accessStatus = accessStatusEntity.getAccessStatus();
// verify unknown
Assert.assertEquals("UNKNOWN", accessStatus.getStatus());
response = TOKEN_USER.testCreateToken(accessTokenUrl, user, password);
// ensure the request is successful
Assert.assertEquals(201, response.getStatus());
// get the token
String token = response.readEntity(String.class);
// authorization header
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + token);
// check the status with the token
response = TOKEN_USER.testGetWithHeaders(accessStatusUrl, null, headers);
// ensure the request is successful
Assert.assertEquals(200, response.getStatus());
accessStatusEntity = response.readEntity(AccessStatusEntity.class);
accessStatus = accessStatusEntity.getAccessStatus();
// verify unregistered
Assert.assertEquals("ACTIVE", accessStatus.getStatus());
// log out
response = TOKEN_USER.testGetWithHeaders(logoutUrl, null, headers);
Assert.assertEquals(200, response.getStatus());
// ensure we can no longer use our token
response = TOKEN_USER.testGetWithHeaders(accessStatusUrl, null, headers);
Assert.assertEquals(401, response.getStatus());
}
@Test
public void testLogOutNoTokenHeader() throws Exception {
String accessStatusUrl = BASE_URL + "/access";
String accessTokenUrl = BASE_URL + "/access/token";
String logoutUrl = BASE_URL + "/access/logout";
Response response = TOKEN_USER.testGet(accessStatusUrl);
// ensure the request is successful
Assert.assertEquals(200, response.getStatus());
AccessStatusEntity accessStatusEntity = response.readEntity(AccessStatusEntity.class);
AccessStatusDTO accessStatus = accessStatusEntity.getAccessStatus();
// verify unknown
Assert.assertEquals("UNKNOWN", accessStatus.getStatus());
response = TOKEN_USER.testCreateToken(accessTokenUrl, user, password);
// ensure the request is successful
Assert.assertEquals(201, response.getStatus());
// get the token
String token = response.readEntity(String.class);
// authorization header
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + token);
// check the status with the token
response = TOKEN_USER.testGetWithHeaders(accessStatusUrl, null, headers);
// ensure the request is successful
Assert.assertEquals(200, response.getStatus());
accessStatusEntity = response.readEntity(AccessStatusEntity.class);
accessStatus = accessStatusEntity.getAccessStatus();
// verify unregistered
Assert.assertEquals("ACTIVE", accessStatus.getStatus());
// log out should fail as we provided no token for logout to use
response = TOKEN_USER.testGetWithHeaders(logoutUrl, null, null);
Assert.assertEquals(500, response.getStatus());
}
@Test
public void testLogOutUnknownToken() throws Exception {
// Arrange
final String ALG_HEADER = "{\"alg\":\"HS256\"}";
final int EXPIRATION_SECONDS = 60;
Calendar now = Calendar.getInstance();
final long currentTime = (long) (now.getTimeInMillis() / 1000.0);
final long TOKEN_ISSUED_AT = currentTime;
final long TOKEN_EXPIRATION_SECONDS = currentTime + EXPIRATION_SECONDS;
// Always use LinkedHashMap to enforce order of the keys because the signature depends on order
Map<String, Object> claims = new LinkedHashMap<>();
claims.put("sub", "unknownuser");
claims.put("iss", "MockIdentityProvider");
claims.put("aud", "MockIdentityProvider");
claims.put("preferred_username", "unknownuser");
claims.put("kid", 1);
claims.put("exp", TOKEN_EXPIRATION_SECONDS);
claims.put("iat", TOKEN_ISSUED_AT);
final String EXPECTED_PAYLOAD = new JSONObject(claims).toString();
String accessStatusUrl = BASE_URL + "/access";
String accessTokenUrl = BASE_URL + "/access/token";
String logoutUrl = BASE_URL + "/access/logout";
Response response = TOKEN_USER.testCreateToken(accessTokenUrl, user, password);
Response responseA = TOKEN_USER.testCreateToken(accessTokenUrl, "jack", password);
// ensure the request is successful
Assert.assertEquals(201, response.getStatus());
// get the token
String token = response.readEntity(String.class);
// authorization header
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + token);
// check the status with the token
response = TOKEN_USER.testGetWithHeaders(accessStatusUrl, null, headers);
Assert.assertEquals(200, response.getStatus());
// Generate a token that will not match signatures with the generated token.
final String UNKNOWN_USER_TOKEN = JwtServiceTest.generateHS256Token(ALG_HEADER, EXPECTED_PAYLOAD, true, true);
Map<String, String> badHeaders = new HashMap<>();
badHeaders.put("Authorization", "Bearer " + UNKNOWN_USER_TOKEN);
// Log out should fail as we provide a bad token to use, signatures will mismatch
response = TOKEN_USER.testGetWithHeaders(logoutUrl, null, badHeaders);
Assert.assertEquals(401, response.getStatus());
}
@Test
public void testLogOutSplicedTokenSignature() throws Exception {
// Arrange
final String ALG_HEADER = "{\"alg\":\"HS256\"}";
final int EXPIRATION_SECONDS = 60;
Calendar now = Calendar.getInstance();
final long currentTime = (long) (now.getTimeInMillis() / 1000.0);
final long TOKEN_ISSUED_AT = currentTime;
final long TOKEN_EXPIRATION_SECONDS = currentTime + EXPIRATION_SECONDS;
String accessTokenUrl = BASE_URL + "/access/token";
String logoutUrl = BASE_URL + "/access/logout";
Response response = TOKEN_USER.testCreateToken(accessTokenUrl, user, password);
// ensure the request is successful
Assert.assertEquals(201, response.getStatus());
// replace the user in the token with an unknown user
String realToken = response.readEntity(String.class);
String realSignature = realToken.split("\\.")[2];
// Generate a token that we will add a valid signature from a different token
// Always use LinkedHashMap to enforce order of the keys because the signature depends on order
Map<String, Object> claims = new LinkedHashMap<>();
claims.put("sub", "unknownuser");
claims.put("iss", "MockIdentityProvider");
claims.put("aud", "MockIdentityProvider");
claims.put("preferred_username", "unknownuser");
claims.put("kid", 1);
claims.put("exp", TOKEN_EXPIRATION_SECONDS);
claims.put("iat", TOKEN_ISSUED_AT);
final String EXPECTED_PAYLOAD = new JSONObject(claims).toString();
final String tempToken = JwtServiceTest.generateHS256Token(ALG_HEADER, EXPECTED_PAYLOAD, true, true);
// Splice this token with the real token from above
String[] splitToken = tempToken.split("\\.");
StringJoiner joiner = new StringJoiner(".");
joiner.add(splitToken[0]);
joiner.add(splitToken[1]);
joiner.add(realSignature);
String splicedUserToken = joiner.toString();
Map<String, String> badHeaders = new HashMap<>();
badHeaders.put("Authorization", "Bearer " + splicedUserToken);
// Log out should fail as we provide a bad token to use, signatures will mismatch
response = TOKEN_USER.testGetWithHeaders(logoutUrl, null, badHeaders);
Assert.assertEquals(401, response.getStatus());
}
@AfterClass
public static void cleanup() throws Exception {
// shutdown the server

View File

@ -31,6 +31,18 @@
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.1</version>
<executions>
<execution>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>jaxb2-maven-plugin</artifactId>

View File

@ -27,16 +27,16 @@ import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.SigningKeyResolverAdapter;
import io.jsonwebtoken.UnsupportedJwtException;
import java.nio.charset.StandardCharsets;
import java.util.Calendar;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.admin.service.AdministrationException;
import org.apache.nifi.admin.service.KeyService;
import org.apache.nifi.authorization.user.NiFiUserUtils;
import org.apache.nifi.key.Key;
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.Calendar;
/**
*
*/
@ -76,7 +76,19 @@ public class JwtService {
} catch (JwtException e) {
logger.debug("The Base64 encoded JWT: " + base64EncodedToken);
final String errorMessage = "There was an error validating the JWT";
logger.error(errorMessage, e);
// A common attack is someone trying to use a token after the user is logged out
// No need to show a stacktrace for an expected and handled scenario
String causeMessage = e.getLocalizedMessage();
if (e.getCause() != null) {
causeMessage += "\n\tCaused by: " + e.getCause().getLocalizedMessage();
}
if (logger.isDebugEnabled()) {
logger.error(errorMessage, e);
} else {
logger.error(errorMessage);
logger.error(causeMessage);
}
throw e;
}
}
@ -157,4 +169,19 @@ public class JwtService {
throw new JwtException(errorMessage, e);
}
}
public void logOut(String authorizationHeader) {
if (authorizationHeader == null || authorizationHeader.isEmpty()) {
throw new JwtException("Log out failed: The required Authorization header was not present in the request to log out user.");
}
String identity = NiFiUserUtils.getNiFiUserIdentity();
try {
keyService.deleteKey(identity);
} catch (Exception e) {
logger.error("Unable to log out user: " + identity + ". Failed to remove their token from database.");
throw e;
}
}
}

View File

@ -21,16 +21,24 @@ import org.apache.commons.codec.CharEncoding;
import org.apache.commons.codec.binary.Base64;
import org.apache.nifi.admin.service.AdministrationException;
import org.apache.nifi.admin.service.KeyService;
import org.apache.nifi.authorization.user.NiFiUserDetails;
import org.apache.nifi.authorization.user.StandardNiFiUser;
import org.apache.nifi.key.Key;
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
import org.codehaus.jettison.json.JSONObject;
import org.junit.After;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.mockito.Mockito;
import org.junit.rules.ExpectedException;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
@ -44,6 +52,8 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import static org.mockito.Matchers.anyInt;
import static org.mockito.Matchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class JwtServiceTest {
@ -136,11 +146,11 @@ public class JwtServiceTest {
// Class under test
private JwtService jwtService;
private String generateHS256Token(String rawHeader, String rawPayload, boolean isValid, boolean isSigned) {
public static String generateHS256Token(String rawHeader, String rawPayload, boolean isValid, boolean isSigned) {
return generateHS256Token(rawHeader, rawPayload, HMAC_SECRET, isValid, isSigned);
}
private String generateHS256Token(String rawHeader, String rawPayload, String hmacSecret, boolean isValid,
private static String generateHS256Token(String rawHeader, String rawPayload, String hmacSecret, boolean isValid,
boolean isSigned) {
try {
logger.info("Generating token for " + rawHeader + " + " + rawPayload);
@ -162,7 +172,7 @@ public class JwtServiceTest {
}
}
private String generateHMAC(String hmacSecret, String body) throws NoSuchAlgorithmException,
private static String generateHMAC(String hmacSecret, String body) throws NoSuchAlgorithmException,
UnsupportedEncodingException, InvalidKeyException {
Mac hmacSHA256 = Mac.getInstance("HmacSHA256");
SecretKeySpec secret_key = new SecretKeySpec(hmacSecret.getBytes("UTF-8"), "HmacSHA256");
@ -177,15 +187,38 @@ public class JwtServiceTest {
key.setIdentity(DEFAULT_IDENTITY);
key.setKey(HMAC_SECRET);
mockKeyService = Mockito.mock(KeyService.class);
when(mockKeyService.getKey(anyInt())).thenReturn(key);
Answer<Key> keyAnswer = new Answer<Key>() {
Key answerKey = key;
@Override
public Key answer(InvocationOnMock invocation) throws Throwable {
if(invocation.getMethod().equals(KeyService.class.getMethod("deleteKey", String.class))) {
answerKey = null;
}
return answerKey;
}
};
StandardNiFiUser nifiUser = mock(StandardNiFiUser.class);
when(nifiUser.getIdentity()).thenReturn(DEFAULT_IDENTITY);
NiFiUserDetails nifiUserDetails = mock(NiFiUserDetails.class);
when(nifiUserDetails.getNiFiUser()).thenReturn(nifiUser);
Authentication authentication = mock(Authentication.class);
SecurityContext securityContext = mock(SecurityContext.class);
when(securityContext.getAuthentication()).thenReturn(authentication);
SecurityContextHolder.setContext(securityContext);
when(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).thenReturn(nifiUserDetails);
mockKeyService = mock(KeyService.class);
when(mockKeyService.getKey(anyInt())).thenAnswer(keyAnswer);
when(mockKeyService.getOrCreateKey(anyString())).thenReturn(key);
doAnswer(keyAnswer).when(mockKeyService).deleteKey(anyString());
jwtService = new JwtService(mockKeyService);
}
@After
public void tearDown() throws Exception {
jwtService = null;
}
@Test
@ -425,13 +458,13 @@ public class JwtServiceTest {
public void testShouldNotGenerateTokenWithMissingKey() throws Exception {
// Arrange
final int EXPIRATION_MILLIS = 60000;
LoginAuthenticationToken loginAuthenticationToken = new LoginAuthenticationToken("alopresto",
LoginAuthenticationToken loginAuthenticationToken = new LoginAuthenticationToken(DEFAULT_IDENTITY,
EXPIRATION_MILLIS,
"MockIdentityProvider");
logger.debug("Generating token for " + loginAuthenticationToken);
// Set up the bad key service
KeyService missingKeyService = Mockito.mock(KeyService.class);
KeyService missingKeyService = mock(KeyService.class);
when(missingKeyService.getOrCreateKey(anyString())).thenThrow(new AdministrationException("Could not find a "
+ "key for that user"));
jwtService = new JwtService(missingKeyService);
@ -442,4 +475,50 @@ public class JwtServiceTest {
// Assert
// Should throw exception
}
@Rule
public ExpectedException expectedException = ExpectedException.none();
@Test
public void testShouldLogOutUser() throws Exception {
// Arrange
expectedException.expect(JwtException.class);
expectedException.expectMessage("Unable to validate the access token.");
// Token expires in 60 seconds
final int EXPIRATION_MILLIS = 60000;
LoginAuthenticationToken loginAuthenticationToken = new LoginAuthenticationToken(DEFAULT_IDENTITY,
EXPIRATION_MILLIS,
"MockIdentityProvider");
logger.debug("Generating token for " + loginAuthenticationToken);
// Act
String token = jwtService.generateSignedToken(loginAuthenticationToken);
logger.debug("Generated JWT: " + token);
String authID = jwtService.getAuthenticationFromToken(token);
assertEquals(DEFAULT_IDENTITY, authID);
logger.debug("Logging out user: " + DEFAULT_IDENTITY);
jwtService.logOut(token);
logger.debug("Logged out user: " + DEFAULT_IDENTITY);
jwtService.getAuthenticationFromToken(token);
// Assert
// Should throw exception when user is not found
}
@Test
public void testLogoutWhenAuthTokenIsEmptyShouldThrowError() throws Exception {
// Arrange
expectedException.expect(JwtException.class);
expectedException.expectMessage("Log out failed: The required Authorization header was not present in the request to log out user.");
// Act
jwtService.logOut(null);
// Assert
// Should throw exception when authorization header is null
}
}

View File

@ -117,6 +117,11 @@
*/
this.logoutCtrl = {
logout: function () {
$.ajax({
type: 'GET',
url: '../nifi-api/access/logout',
dataType: 'json'
})
nfStorage.removeItem("jwt");
window.location = '../nifi/logout';
}