diff --git a/API.md b/API.md index 32bc5a8e9..55c1eaebb 100644 --- a/API.md +++ b/API.md @@ -32,7 +32,9 @@ This endpoint will take a Users credentials and proxy the request to Vault to ge "metadata": { "username": "john.doe@nike.com", "is_admin": "false", - "groups": "Lst-CDT.CloudPlatformEngine.FTE,Lst-digital.platform-tools.internal" + "groups": "Lst-CDT.CloudPlatformEngine.FTE,Lst-digital.platform-tools.internal", + "refresh_count": 1, + "max_refresh_count": 24 }, "lease_duration": 3600, "renewable": true @@ -97,7 +99,9 @@ This endpoint will take a Users credentials and proxy the request to Vault to ge "metadata": { "username": "john.doe@nike.com", "is_admin": "false", - "groups": "Lst-CDT.CloudPlatformEngine.FTE,Lst-digital.platform-tools.internal" + "groups": "Lst-CDT.CloudPlatformEngine.FTE,Lst-digital.platform-tools.internal", + "refresh_count": 1, + "max_refresh_count": 24 }, "lease_duration": 3600, "renewable": true @@ -110,6 +114,8 @@ This endpoint will take a Users credentials and proxy the request to Vault to ge ### Refresh the user's token [GET] This endpoint allows a user to exchange their current token for a new one with updated policies. +There is a limit to the number of times this call can be made with a token and is store in the metadata +refresh_count and max_refresh_count can be used to determine when a re-authentication is required. + Request (application/json) @@ -133,7 +139,9 @@ This endpoint allows a user to exchange their current token for a new one with u "metadata": { "username": "john.doe@nike.com", "is_admin": "false", - "groups": "Lst-CDT.CloudPlatformEngine.FTE,Lst-digital.platform-tools.internal" + "groups": "Lst-CDT.CloudPlatformEngine.FTE,Lst-digital.platform-tools.internal", + "refresh_count": 2, + "max_refresh_count": 24 }, "lease_duration": 3600, "renewable": true diff --git a/src/main/java/com/nike/cerberus/error/DefaultApiError.java b/src/main/java/com/nike/cerberus/error/DefaultApiError.java index 5471af906..1bcaf3e6b 100644 --- a/src/main/java/com/nike/cerberus/error/DefaultApiError.java +++ b/src/main/java/com/nike/cerberus/error/DefaultApiError.java @@ -222,6 +222,16 @@ public enum DefaultApiError implements ApiError { */ AUTHENTICATION_ERROR_INVALID_REGION(99229, "Invalid AWS region provided during authentication.", HttpServletResponse.SC_BAD_REQUEST), + /** + * The token has exceeded the amount of times it can be refreshed + */ + MAXIMUM_TOKEN_REFRESH_COUNT_REACHED(99230, "Maximum token refresh count reached, re-authentication required.", HttpServletResponse.SC_FORBIDDEN), + + /** + * The token has exceeded the amount of times it can be refreshed + */ + IAM_PRINCIPALS_CANNOT_USE_USER_ONLY_RESOURCE(99231, "The requested resource is for User Principals only.", HttpServletResponse.SC_FORBIDDEN), + /** * Generic not found error. */ diff --git a/src/main/java/com/nike/cerberus/security/VaultAuthPrincipal.java b/src/main/java/com/nike/cerberus/security/VaultAuthPrincipal.java index 1c35986a6..e228074bf 100644 --- a/src/main/java/com/nike/cerberus/security/VaultAuthPrincipal.java +++ b/src/main/java/com/nike/cerberus/security/VaultAuthPrincipal.java @@ -51,6 +51,10 @@ public class VaultAuthPrincipal implements Principal { public static final String METADATA_KEY_AWS_REGION = "aws_region"; + public static final String METADATA_KEY_TOKEN_REFRESH_COUNT = "refresh_count"; + + public static final String METADATA_KEY_MAX_TOKEN_REFRESH_COUNT = "max_refresh_count"; + private final VaultClientTokenResponse clientToken; private final Set userGroupSet; @@ -61,12 +65,22 @@ public class VaultAuthPrincipal implements Principal { private final boolean isIamPrincipal; + private final Integer tokenRefreshCount; + public VaultAuthPrincipal(VaultClientTokenResponse clientToken) { this.clientToken = clientToken; this.roles = buildRoles(clientToken); this.userGroupSet = extractUserGroups(clientToken); this.username = extractUsername(clientToken); this.isIamPrincipal = extractIsIamPrincipal(clientToken); + this.tokenRefreshCount = extractTokenRefreshCount(clientToken); + } + + private Integer extractTokenRefreshCount(VaultClientTokenResponse clientToken) { + final Map meta = clientToken.getMeta(); + // if a Token that is the root token or created outside of CMS, + // then meta might be null and there will be no value set + return meta == null ? 0 : Integer.parseInt(meta.getOrDefault(METADATA_KEY_TOKEN_REFRESH_COUNT, "0")); } private boolean extractIsIamPrincipal(VaultClientTokenResponse clientToken) { @@ -130,4 +144,8 @@ public Set getUserGroups() { public boolean isIamPrincipal() { return isIamPrincipal; } + + public Integer getTokenRefreshCount() { + return tokenRefreshCount; + } } diff --git a/src/main/java/com/nike/cerberus/service/AuthenticationService.java b/src/main/java/com/nike/cerberus/service/AuthenticationService.java index aeb93ff71..d49a05b63 100644 --- a/src/main/java/com/nike/cerberus/service/AuthenticationService.java +++ b/src/main/java/com/nike/cerberus/service/AuthenticationService.java @@ -84,6 +84,7 @@ public class AuthenticationService { public static final String SYSTEM_USER = "system"; public static final String ADMIN_GROUP_PROPERTY = "cms.admin.group"; + public static final String MAX_TOKEN_REFRESH_COUNT = "auth.token.maxRefreshCount"; public static final String ADMIN_IAM_ROLES_PROPERTY = "cms.admin.roles"; public static final String USER_TOKEN_TTL_OVERRIDE = "cms.user.token.ttl.override"; public static final String IAM_TOKEN_TTL_OVERRIDE = "cms.iam.token.ttl.override"; @@ -117,6 +118,8 @@ public class AuthenticationService { @Named(IAM_TOKEN_TTL_OVERRIDE) String iamTokenTTL = DEFAULT_TOKEN_TTL; + private final int maxTokenRefreshCount; + @Inject public AuthenticationService(final SafeDepositBoxDao safeDepositBoxDao, final AwsIamRoleDao awsIamRoleDao, @@ -127,6 +130,7 @@ public AuthenticationService(final SafeDepositBoxDao safeDepositBoxDao, final VaultPolicyService vaultPolicyService, final ObjectMapper objectMapper, @Named(ADMIN_GROUP_PROPERTY) final String adminGroup, + @Named(MAX_TOKEN_REFRESH_COUNT) final int maxTokenRefreshCount, final DateTimeSupplier dateTimeSupplier, final AwsIamRoleArnParser awsIamRoleArnParser) { @@ -141,6 +145,7 @@ public AuthenticationService(final SafeDepositBoxDao safeDepositBoxDao, this.adminGroup = adminGroup; this.dateTimeSupplier = dateTimeSupplier; this.awsIamRoleArnParser = awsIamRoleArnParser; + this.maxTokenRefreshCount = maxTokenRefreshCount; } /** @@ -156,7 +161,7 @@ public AuthResponse authenticate(final UserCredentials credentials) { if (authResponse.getStatus() == AuthStatus.SUCCESS) { authResponse.getData().setClientToken(generateToken(credentials.getUsername(), - authServiceConnector.getGroups(authResponse.getData()))); + authServiceConnector.getGroups(authResponse.getData()), 0)); } return authResponse; @@ -175,7 +180,7 @@ public AuthResponse mfaCheck(final MfaCheckRequest mfaCheckRequest) { if (authResponse.getStatus() == AuthStatus.SUCCESS) { authResponse.getData().setClientToken(generateToken(authResponse.getData().getUsername(), - authServiceConnector.getGroups(authResponse.getData()))); + authServiceConnector.getGroups(authResponse.getData()), 0)); } return authResponse; @@ -322,6 +327,23 @@ protected byte[] validateAuthPayloadSizeAndTruncateIfLargerThanMaxKmsSupportedSi * @return The auth response directly from Vault with the token and metadata */ public AuthResponse refreshUserToken(final VaultAuthPrincipal authPrincipal) { + + if (authPrincipal.isIamPrincipal()) { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.IAM_PRINCIPALS_CANNOT_USE_USER_ONLY_RESOURCE) + .withExceptionMessage("The iam principal: %s attempted to use the user token refresh method") + .build(); + } + + Integer currentTokenRefreshCount = authPrincipal.getTokenRefreshCount(); + if (currentTokenRefreshCount >= maxTokenRefreshCount) { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.MAXIMUM_TOKEN_REFRESH_COUNT_REACHED) + .withExceptionMessage(String.format("The principal %s attempted to refresh its token but has " + + "reached the maximum number of refreshes allowed", authPrincipal.getName())) + .build(); + } + revoke(authPrincipal.getClientToken().getId()); final AuthResponse authResponse = new AuthResponse(); @@ -329,7 +351,9 @@ public AuthResponse refreshUserToken(final VaultAuthPrincipal authPrincipal) { final AuthData authData = new AuthData(); authResponse.setData(authData); authData.setUsername(authPrincipal.getName()); - authData.setClientToken(generateToken(authPrincipal.getName(), authPrincipal.getUserGroups())); + authData.setClientToken(generateToken(authPrincipal.getName(), + authPrincipal.getUserGroups(), + currentTokenRefreshCount + 1)); return authResponse; } @@ -360,7 +384,7 @@ public void revoke(final String vaultToken) { * @param userGroups The user's groups * @return The auth response directly from Vault with the token and metadata */ - private VaultAuthResponse generateToken(final String username, final Set userGroups) { + private VaultAuthResponse generateToken(final String username, final Set userGroups, int refreshCount) { final Map meta = Maps.newHashMap(); meta.put(VaultAuthPrincipal.METADATA_KEY_USERNAME, username); @@ -370,6 +394,8 @@ private VaultAuthResponse generateToken(final String username, final Set } meta.put(VaultAuthPrincipal.METADATA_KEY_IS_ADMIN, String.valueOf(isAdmin)); meta.put(VaultAuthPrincipal.METADATA_KEY_GROUPS, StringUtils.join(userGroups, ',')); + meta.put(VaultAuthPrincipal.METADATA_KEY_TOKEN_REFRESH_COUNT, String.valueOf(refreshCount)); + meta.put(VaultAuthPrincipal.METADATA_KEY_MAX_TOKEN_REFRESH_COUNT, String.valueOf(maxTokenRefreshCount)); final Set policies = buildPolicySet(userGroups); diff --git a/src/main/resources/cms.conf b/src/main/resources/cms.conf index f9d2b09c8..5dda6f776 100644 --- a/src/main/resources/cms.conf +++ b/src/main/resources/cms.conf @@ -52,6 +52,8 @@ disableCassandra=true # This will be replaced at build-time with the version number of the final fat jar artifact. service.version="@@RELEASE@@" +# This sets the maximum number of times a token can be refreshed +auth.token.maxRefreshCount=24 # Flyway flyway.schemas=cms diff --git a/src/test/java/com/nike/cerberus/service/AuthenticationServiceTest.java b/src/test/java/com/nike/cerberus/service/AuthenticationServiceTest.java index 42a3a327d..0d4b31442 100644 --- a/src/test/java/com/nike/cerberus/service/AuthenticationServiceTest.java +++ b/src/test/java/com/nike/cerberus/service/AuthenticationServiceTest.java @@ -19,11 +19,13 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nike.backstopper.exception.ApiException; import com.nike.cerberus.auth.connector.AuthConnector; import com.nike.cerberus.aws.KmsClientFactory; import com.nike.cerberus.dao.AwsIamRoleDao; import com.nike.cerberus.dao.SafeDepositBoxDao; import com.nike.cerberus.domain.IamPrincipalCredentials; +import com.nike.cerberus.error.DefaultApiError; import com.nike.cerberus.record.AwsIamRoleKmsKeyRecord; import com.nike.cerberus.record.AwsIamRoleRecord; import com.nike.cerberus.record.SafeDepositBoxRoleRecord; @@ -33,6 +35,7 @@ import com.nike.cerberus.util.DateTimeSupplier; import com.nike.vault.client.VaultAdminClient; import com.nike.vault.client.model.VaultAuthResponse; +import com.nike.vault.client.model.VaultClientTokenResponse; import org.apache.commons.lang3.RandomStringUtils; import org.assertj.core.util.Lists; import org.assertj.core.util.Sets; @@ -57,6 +60,7 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -99,13 +103,15 @@ public class AuthenticationServiceTest { private AuthenticationService authenticationService; + private static int MAX_LIMIT = 2; + @Before public void setup() { initMocks(this); objectMapper = CmsConfig.configureObjectMapper(); authenticationService = new AuthenticationService(safeDepositBoxDao, awsIamRoleDao, authConnector, kmsService, kmsClientFactory, - vaultAdminClient, vaultPolicyService, objectMapper, "foo", + vaultAdminClient, vaultPolicyService, objectMapper, "foo", MAX_LIMIT, dateTimeSupplier, awsIamRoleArnParser); } @@ -292,4 +298,55 @@ public void tests_that_validateAuthPayloadSizeAndTruncateIfLargerThanMaxKmsSuppo assertNotEquals(serializedAuth, actual); assertTrue(actual.length < AuthenticationService.KMS_SIZE_LIMIT); } + + @Test + public void tests_that_refreshUserToken_throws_access_denied_when_an_iam_principal_tries_to_call_it() { + VaultAuthPrincipal principal = mock(VaultAuthPrincipal.class); + + when(principal.isIamPrincipal()).thenReturn(true); + + Exception e = null; + try { + authenticationService.refreshUserToken(principal); + } catch (Exception e2) { + e = e2; + } + + assertTrue(e instanceof ApiException); + assertTrue(((ApiException) e).getApiErrors().contains(DefaultApiError.IAM_PRINCIPALS_CANNOT_USE_USER_ONLY_RESOURCE)); + } + + @Test + public void tests_that_refreshUserToken_refreshes_token_when_count_is_less_than_limit() { + VaultAuthPrincipal principal = mock(VaultAuthPrincipal.class); + + when(principal.isIamPrincipal()).thenReturn(false); + when(principal.getTokenRefreshCount()).thenReturn(MAX_LIMIT - 1); + + VaultClientTokenResponse response = mock(VaultClientTokenResponse.class); + + when(principal.getClientToken()).thenReturn(response); + + when(response.getId()).thenReturn(""); + + authenticationService.refreshUserToken(principal); + } + + @Test + public void tests_that_refreshUserToken_throws_access_denied_token_when_count_is_eq_or_greater_than_limit() { + VaultAuthPrincipal principal = mock(VaultAuthPrincipal.class); + + when(principal.isIamPrincipal()).thenReturn(false); + when(principal.getTokenRefreshCount()).thenReturn(MAX_LIMIT); + + Exception e = null; + try { + authenticationService.refreshUserToken(principal); + } catch (Exception e2) { + e = e2; + } + + assertTrue(e instanceof ApiException); + assertTrue(((ApiException) e).getApiErrors().contains(DefaultApiError.MAXIMUM_TOKEN_REFRESH_COUNT_REACHED)); + } } \ No newline at end of file