CVE-2017-8031
Description
An issue was discovered in Cloud Foundry Foundation cf-release (all versions prior to v279) and UAA (30.x versions prior to 30.6, 45.x versions prior to 45.4, 52.x versions prior to 52.1). In some cases, the UAA allows an authenticated user for a particular client to revoke client tokens for other users on the same client. This occurs only if the client is using opaque tokens or JWT tokens validated using the check_token endpoint. A malicious actor could cause denial of service.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.cloudfoundry.identity:cloudfoundry-identity-serverMaven | >= 4.6.0, < 4.7.1 | 4.7.1 |
org.cloudfoundry.identity:cloudfoundry-identity-serverMaven | >= 4.0.0, < 4.5.3 | 4.5.3 |
org.cloudfoundry.identity:cloudfoundry-identity-serverMaven | < 3.20.1 | 3.20.1 |
Affected products
3cpe:2.3:a:cloudfoundry:uaa-release:*:*:*:*:*:*:*:*+ 1 more
- cpe:2.3:a:cloudfoundry:uaa-release:*:*:*:*:*:*:*:*range: >=30,<30.6
- cpe:2.3:a:cloudfoundry:uaa-release:52:*:*:*:*:*:*:*
Patches
366166d17781aMerge branch 'feature/token_self_revocation' into prerelease/4.7.x
6 files changed · +197 −6
server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/JdbcRevocableTokenProvisioning.java+1 −1 modified@@ -55,7 +55,7 @@ public class JdbcRevocableTokenProvisioning implements RevocableTokenProvisionin protected AtomicLong lastExpiredCheck = new AtomicLong(0); protected long expirationCheckInterval = 30000; //30 seconds - protected JdbcRevocableTokenProvisioning(JdbcTemplate jdbcTemplate) { + public JdbcRevocableTokenProvisioning(JdbcTemplate jdbcTemplate) { this.rowMapper = new RevocableTokenRowMapper(); this.template = jdbcTemplate; }
server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenRevocationEndpoint.java+5 −2 modified@@ -16,6 +16,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.cloudfoundry.identity.uaa.audit.event.SystemDeletable; import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; import org.cloudfoundry.identity.uaa.oauth.token.RevocableToken; @@ -88,10 +89,12 @@ public ResponseEntity<Void> revokeTokensForUserAndClient(@PathVariable String us @RequestMapping("/oauth/token/revoke/client/{clientId}") public ResponseEntity<Void> revokeTokensForClient(@PathVariable String clientId) { logger.debug("Revoking tokens for client: " + clientId); - BaseClientDetails client = (BaseClientDetails)clientDetailsService.loadClientByClientId(clientId, IdentityZoneHolder.get().getId()); + String zoneId = IdentityZoneHolder.get().getId(); + BaseClientDetails client = (BaseClientDetails)clientDetailsService.loadClientByClientId(clientId, zoneId); client.addAdditionalInformation(ClientConstants.TOKEN_SALT,generator.generate()); - clientDetailsService.updateClientDetails(client, IdentityZoneHolder.get().getId()); + clientDetailsService.updateClientDetails(client, zoneId); logger.debug("Tokens revoked for client: " + clientId); + ((SystemDeletable)tokenProvisioning).deleteByClient(clientId, zoneId); return new ResponseEntity<>(OK); }
server/src/test/java/org/cloudfoundry/identity/uaa/oauth/TokenRevocationEndpointTests.java+122 −0 added@@ -0,0 +1,122 @@ +/* + * **************************************************************************** + * Cloud Foundry + * Copyright (c) [2009-2017] Pivotal Software, Inc. All Rights Reserved. + * + * This product is licensed to you under the Apache License, Version 2.0 (the "License"). + * You may not use this product except in compliance with the License. + * + * This product includes a number of subcomponents with + * separate copyright notices and license terms. Your use of these + * subcomponents is subject to the terms and conditions of the + * subcomponent's license, as noted in the LICENSE file. + * **************************************************************************** + */ + +package org.cloudfoundry.identity.uaa.oauth; + +import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; +import org.cloudfoundry.identity.uaa.authentication.UaaAuthenticationDetails; +import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; +import org.cloudfoundry.identity.uaa.constants.OriginKeys; +import org.cloudfoundry.identity.uaa.oauth.token.JdbcRevocableTokenProvisioning; +import org.cloudfoundry.identity.uaa.oauth.token.RevocableToken; +import org.cloudfoundry.identity.uaa.resources.jdbc.JdbcPagingListFactory; +import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; +import org.cloudfoundry.identity.uaa.scim.jdbc.JdbcScimUserProvisioning; +import org.cloudfoundry.identity.uaa.test.JdbcTestBase; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MultitenantJdbcClientDetailsService; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.common.util.RandomValueStringGenerator; +import org.springframework.security.oauth2.provider.ClientDetails; +import org.springframework.security.oauth2.provider.OAuth2Request; +import org.springframework.security.oauth2.provider.client.BaseClientDetails; + +import java.util.Collections; + +import static org.cloudfoundry.identity.uaa.oauth.client.ClientConstants.TOKEN_SALT; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +public class TokenRevocationEndpointTests extends JdbcTestBase { + + private TokenRevocationEndpoint endpoint; + private RandomValueStringGenerator generator; + private BaseClientDetails client; + private ApplicationEventPublisher publisher; + private MultitenantJdbcClientDetailsService clientService; + + @Before + public void setupForTokenRevocation() throws Exception { + String zoneId = IdentityZoneHolder.get().getId(); + generator = new RandomValueStringGenerator(); + String clientId = generator.generate().toLowerCase(); + client = new BaseClientDetails(clientId, "", "some.scopes", "client_credentials", "authorities"); + client.addAdditionalInformation(TOKEN_SALT, "pre-salt"); + clientService = spy(new MultitenantJdbcClientDetailsService(jdbcTemplate)); + clientService.addClientDetails(client, zoneId); + + ScimUserProvisioning userProvisioning = new JdbcScimUserProvisioning( + jdbcTemplate, + new JdbcPagingListFactory(jdbcTemplate, limitSqlAdapter) + ); + JdbcRevocableTokenProvisioning provisioning = spy(new JdbcRevocableTokenProvisioning(jdbcTemplate)); + endpoint = spy(new TokenRevocationEndpoint(clientService, userProvisioning, provisioning)); + publisher = mock(ApplicationEventPublisher.class); + + SecurityContextHolder.getContext().setAuthentication( + new UaaOauth2Authentication( + "token-value", + zoneId, + mock(OAuth2Request.class), + new UaaAuthentication( + new UaaPrincipal("id", "username", "username@test.com", OriginKeys.UAA, "", zoneId), + Collections.emptyList(), + mock(UaaAuthenticationDetails.class) + ) + ) + ); + + provisioning.create( + new RevocableToken() + .setClientId(client.getClientId()) + .setTokenId("token-id") + .setUserId(null) + .setResponseType(RevocableToken.TokenType.ACCESS_TOKEN) + .setValue("value") + .setIssuedAt(System.currentTimeMillis()), + zoneId + ); + } + + @After + public void cleanup() throws Exception { + SecurityContextHolder.clearContext(); + IdentityZoneHolder.clear(); + } + + @Test + public void revokeTokensForClient() throws Exception { + assertEquals("pre-salt", getClient().getAdditionalInformation().get(TOKEN_SALT)); + assertEquals(1, clientTokenCount()); + endpoint.revokeTokensForClient(client.getClientId()); + assertNotEquals("pre-salt", getClient().getAdditionalInformation().get(TOKEN_SALT)); + assertEquals(0, clientTokenCount()); + } + + public ClientDetails getClient() { + return clientService.loadClientByClientId(client.getClientId()); + } + + public int clientTokenCount() { + return jdbcTemplate.queryForObject("select count(*) from revocable_tokens where client_id = ?", Integer.class, client.getClientId()); + } + +} \ No newline at end of file
uaa/src/main/webapp/WEB-INF/spring/oauth-endpoints.xml+1 −1 modified@@ -63,7 +63,7 @@ authentication-manager-ref="emptyAuthenticationManager" entry-point-ref="oauthAuthenticationEntryPoint" xmlns="http://www.springframework.org/schema/security" use-expressions="true"> - <intercept-url pattern="/oauth/token/revoke/client/**" access="#oauth2.hasScope('uaa.admin') or @self.isClientTokenRevocationForSelf(request, 4)" /> + <intercept-url pattern="/oauth/token/revoke/client/**" access="#oauth2.hasScope('tokens.revoke')" /> <intercept-url pattern="/oauth/token/revoke/user/**" access="#oauth2.hasScope('uaa.admin') or (#oauth2.hasScope('tokens.revoke') and @self.isUserTokenRevocationForSelf(request, 4))" /> <intercept-url pattern="/oauth/token/revoke/**" access="#oauth2.hasScope('tokens.revoke') or @self.isTokenRevocationForSelf(request, 3)" method="DELETE"/> <intercept-url pattern="/**" access="denyAll" />
uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java+1 −1 modified@@ -707,7 +707,7 @@ public void revokeAllTokens_forAClient() throws Exception { true ); Snippet requestHeaders = requestHeaders( - headerWithName("Authorization").description("Bearer token with uaa.admin or tokens.revoke scope. Any token with the matching client_id may also be used for self revocation."), + headerWithName("Authorization").description("Bearer token with uaa.admin or tokens.revoke scope."), IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER );
uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenRevocationEndpointTest.java+67 −1 modified@@ -72,7 +72,7 @@ public void revokeOwnJWToken() throws Exception { } @Test - public void revokeOtherClientToken() throws Exception { + public void revokeOtherClientTokenByJti() throws Exception { String revokerClientId = generator.generate(); String resourceClientId = generator.generate(); @@ -128,6 +128,72 @@ public void revokeOtherClientToken() throws Exception { } } + @Test + public void revokeOtherClientTokenByClientId_tokensDotRevoke() throws Exception { + revokeOtherClientTokenByClientId("tokens.revoke"); + } + + @Test + public void revokeOtherClientTokenByClientId_uaaDotAdmin() throws Exception { + revokeOtherClientTokenByClientId("uaa.admin"); + } + + public void revokeOtherClientTokenByClientId(String scope) throws Exception { + String revokerClientId = generator.generate(); + String resourceClientId = generator.generate(); + + BaseClientDetails revokerClient = + setUpClients(revokerClientId, + scope, + "openid", + "client_credentials,password", + true + ); + + + BaseClientDetails targetClient = + setUpClients(resourceClientId, + "uaa.none", + "openid", + "client_credentials,password", + true + ); + + + //this is the token we will revoke + String revokeAccessToken = + getClientCredentialsOAuthAccessToken( + getMockMvc(), + revokerClient.getClientId(), + SECRET, + scope, + null, + false + ); + + String tokenToBeRevoked = + getClientCredentialsOAuthAccessToken( + getMockMvc(), + resourceClientId, + SECRET, + null, + null, + true + ); + + getMockMvc().perform(delete("/oauth/token/revoke/client/" + resourceClientId) + .header("Authorization", "Bearer " + revokeAccessToken)) + .andExpect(status().isOk()); + + + try { + tokenProvisioning.retrieve(tokenToBeRevoked, IdentityZoneHolder.get().getId()); + fail("Token should have been deleted"); + } catch (EmptyResultDataAccessException e) { + //expected + } + } + @Test public void revokeOtherClientTokenForbidden() throws Exception { String resourceClientId = generator.generate();
1e2a746968cdMerge branch 'feature/token_self_revocation' into prerelease/3.20.x
7 files changed · +229 −30
server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/JdbcRevocableTokenProvisioning.java+7 −1 modified@@ -46,6 +46,7 @@ public class JdbcRevocableTokenProvisioning implements RevocableTokenProvisionin protected final static String DELETE_QUERY = "DELETE FROM " + TABLE + " WHERE token_id=? and identity_zone_id=?"; protected final static String DELETE_EXPIRED_QUERY = "DELETE FROM " + TABLE + " WHERE expires_at < ?"; protected final static String DELETE_REFRESH_TOKEN_QUERY = "DELETE FROM " + TABLE + " WHERE user_id=? AND client_id=? AND response_type='" +REFRESH_TOKEN_RESPONSE_TYPE+ "' AND identity_zone_id=?"; + protected final static String DELETE_BY_CLIENT_QUERY = "DELETE FROM " + TABLE + " WHERE identity_zone_id=? and client_id=?"; protected final static String DELETE_BY_ZONE_QUERY = "DELETE FROM " + TABLE + " WHERE identity_zone_id=?"; @@ -56,7 +57,7 @@ public class JdbcRevocableTokenProvisioning implements RevocableTokenProvisionin protected AtomicLong lastExpiredCheck = new AtomicLong(0); protected long expirationCheckInterval = 30000; //30 seconds - protected JdbcRevocableTokenProvisioning(JdbcTemplate jdbcTemplate) { + public JdbcRevocableTokenProvisioning(JdbcTemplate jdbcTemplate) { this.rowMapper = new RevocableTokenRowMapper(); this.template = jdbcTemplate; } @@ -136,6 +137,11 @@ public RevocableToken delete(String id, int version) { return previous; } + @Override + public int deleteByClient(String clientId, String zoneId) { + return template.update(DELETE_BY_CLIENT_QUERY, zoneId, clientId); + } + @Override public int deleteByIdentityZone(String zoneId) { return template.update(DELETE_BY_ZONE_QUERY, IdentityZoneHolder.get().getId());
server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/RevocableTokenProvisioning.java+1 −1 modified@@ -26,6 +26,6 @@ public interface RevocableTokenProvisioning extends ResourceManager<RevocableTok List<RevocableToken> getClientTokens(String clientId); - + int deleteByClient(String clientId, String zoneId); }
server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenRevocationEndpoint.java+3 −0 modified@@ -23,6 +23,7 @@ import org.cloudfoundry.identity.uaa.scim.ScimUser; import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; import org.cloudfoundry.identity.uaa.scim.exception.ScimResourceNotFoundException; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.cloudfoundry.identity.uaa.zone.MultitenantJdbcClientDetailsService; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.http.ResponseEntity; @@ -75,10 +76,12 @@ public ResponseEntity<Void> revokeTokensForUser(@PathVariable String userId) { @RequestMapping("/oauth/token/revoke/client/{clientId}") public ResponseEntity<Void> revokeTokensForClient(@PathVariable String clientId) { logger.debug("Revoking tokens for client: " + clientId); + String zoneId = IdentityZoneHolder.get().getId(); BaseClientDetails client = (BaseClientDetails)clientDetailsService.loadClientByClientId(clientId); client.addAdditionalInformation(ClientConstants.TOKEN_SALT,generator.generate()); clientDetailsService.updateClientDetails(client); logger.debug("Tokens revoked for client: " + clientId); + tokenProvisioning.deleteByClient(clientId, zoneId); return new ResponseEntity<>(OK); }
server/src/test/java/org/cloudfoundry/identity/uaa/oauth/TokenRevocationEndpointTests.java+121 −0 added@@ -0,0 +1,121 @@ +/* + * **************************************************************************** + * Cloud Foundry + * Copyright (c) [2009-2017] Pivotal Software, Inc. All Rights Reserved. + * + * This product is licensed to you under the Apache License, Version 2.0 (the "License"). + * You may not use this product except in compliance with the License. + * + * This product includes a number of subcomponents with + * separate copyright notices and license terms. Your use of these + * subcomponents is subject to the terms and conditions of the + * subcomponent's license, as noted in the LICENSE file. + * **************************************************************************** + */ + +package org.cloudfoundry.identity.uaa.oauth; + +import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; +import org.cloudfoundry.identity.uaa.authentication.UaaAuthenticationDetails; +import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; +import org.cloudfoundry.identity.uaa.constants.OriginKeys; +import org.cloudfoundry.identity.uaa.oauth.token.JdbcRevocableTokenProvisioning; +import org.cloudfoundry.identity.uaa.oauth.token.RevocableToken; +import org.cloudfoundry.identity.uaa.resources.jdbc.JdbcPagingListFactory; +import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; +import org.cloudfoundry.identity.uaa.scim.jdbc.JdbcScimUserProvisioning; +import org.cloudfoundry.identity.uaa.test.JdbcTestBase; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MultitenantJdbcClientDetailsService; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.common.util.RandomValueStringGenerator; +import org.springframework.security.oauth2.provider.ClientDetails; +import org.springframework.security.oauth2.provider.OAuth2Request; +import org.springframework.security.oauth2.provider.client.BaseClientDetails; + +import java.util.Collections; + +import static org.cloudfoundry.identity.uaa.oauth.client.ClientConstants.TOKEN_SALT; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +public class TokenRevocationEndpointTests extends JdbcTestBase { + + private TokenRevocationEndpoint endpoint; + private RandomValueStringGenerator generator; + private BaseClientDetails client; + private ApplicationEventPublisher publisher; + private MultitenantJdbcClientDetailsService clientService; + + @Before + public void setupForTokenRevocation() throws Exception { + String zoneId = IdentityZoneHolder.get().getId(); + generator = new RandomValueStringGenerator(); + String clientId = generator.generate().toLowerCase(); + client = new BaseClientDetails(clientId, "", "some.scopes", "client_credentials", "authorities"); + client.addAdditionalInformation(TOKEN_SALT, "pre-salt"); + clientService = spy(new MultitenantJdbcClientDetailsService(dataSource)); + clientService.addClientDetails(client); + + ScimUserProvisioning userProvisioning = new JdbcScimUserProvisioning( + jdbcTemplate, + new JdbcPagingListFactory(jdbcTemplate, limitSqlAdapter) + ); + JdbcRevocableTokenProvisioning provisioning = spy(new JdbcRevocableTokenProvisioning(jdbcTemplate)); + endpoint = spy(new TokenRevocationEndpoint(clientService, userProvisioning, provisioning)); + publisher = mock(ApplicationEventPublisher.class); + + SecurityContextHolder.getContext().setAuthentication( + new UaaOauth2Authentication( + "token-value", + zoneId, + mock(OAuth2Request.class), + new UaaAuthentication( + new UaaPrincipal("id", "username", "username@test.com", OriginKeys.UAA, "", zoneId), + Collections.emptyList(), + mock(UaaAuthenticationDetails.class) + ) + ) + ); + + provisioning.create( + new RevocableToken() + .setClientId(client.getClientId()) + .setTokenId("token-id") + .setUserId(null) + .setResponseType(RevocableToken.TokenType.ACCESS_TOKEN) + .setValue("value") + .setIssuedAt(System.currentTimeMillis()) + ); + } + + @After + public void cleanup() throws Exception { + SecurityContextHolder.clearContext(); + IdentityZoneHolder.clear(); + } + + @Test + public void revokeTokensForClient() throws Exception { + assertEquals("pre-salt", getClient().getAdditionalInformation().get(TOKEN_SALT)); + assertEquals(1, clientTokenCount()); + endpoint.revokeTokensForClient(client.getClientId()); + assertNotEquals("pre-salt", getClient().getAdditionalInformation().get(TOKEN_SALT)); + assertEquals(0, clientTokenCount()); + } + + public ClientDetails getClient() { + return clientService.loadClientByClientId(client.getClientId()); + } + + public int clientTokenCount() { + return jdbcTemplate.queryForObject("select count(*) from revocable_tokens where client_id = ?", Integer.class, client.getClientId()); + } + +} \ No newline at end of file
uaa/src/main/webapp/WEB-INF/spring/oauth-endpoints.xml+1 −1 modified@@ -69,7 +69,7 @@ authentication-manager-ref="emptyAuthenticationManager" entry-point-ref="oauthAuthenticationEntryPoint" xmlns="http://www.springframework.org/schema/security" use-expressions="true"> - <intercept-url pattern="/oauth/token/revoke/client/**" access="#oauth2.hasScope('uaa.admin') or @self.isClientTokenRevocationForSelf(request, 4)" /> + <intercept-url pattern="/oauth/token/revoke/client/**" access="#oauth2.hasScope('tokens.revoke')" /> <intercept-url pattern="/oauth/token/revoke/user/**" access="#oauth2.hasScope('uaa.admin') or (#oauth2.hasScope('tokens.revoke') and @self.isUserTokenRevocationForSelf(request, 4))" /> <intercept-url pattern="/oauth/token/revoke/**" access="#oauth2.hasScope('tokens.revoke') or @self.isTokenRevocationForSelf(request, 3)" method="DELETE"/> <intercept-url pattern="/**" access="denyAll" />
uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java+1 −1 modified@@ -692,7 +692,7 @@ public void revokeAllTokens_forAClient() throws Exception { true ); Snippet requestHeaders = requestHeaders( - headerWithName("Authorization").description("Bearer token with uaa.admin or tokens.revoke scope. Any token with the matching client_id may also be used for self revocation."), + headerWithName("Authorization").description("Bearer token with uaa.admin or tokens.revoke scope."), IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER );
uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenMvcMockTests.java+95 −26 modified@@ -2948,50 +2948,119 @@ public void revokeOwnJWToken() throws Exception { } @Test - public void revokeOtherClientToken() throws Exception { + public void revokeOtherClientTokenByJti() throws Exception { + String revokerClientId = generator.generate(); String resourceClientId = generator.generate(); BaseClientDetails resourceClient = new BaseClientDetails( resourceClientId, - "", + "tokens.revoke", "uaa.resource", "client_credentials,password", - "uaa.resource"); + "uaa.resource", + "http://redirect.uri"); resourceClient.setClientSecret("secret"); createClient(getMockMvc(), adminToken, resourceClient); - BaseClientDetails client = new BaseClientDetails( + BaseClientDetails revokerClient = new BaseClientDetails( generator.generate(), - "", + "uaa.none", "openid", "client_credentials,password", - "tokens.revoke"); - client.setClientSecret("secret"); - createClient(getMockMvc(), adminToken, client); + "tokens.revoke", + "http://redirect.uri"); + revokerClient.setClientSecret("secret"); + createClient(getMockMvc(), adminToken, revokerClient); //this is the token we will revoke String revokeAccessToken = - getClientCredentialsOAuthAccessToken( - getMockMvc(), - client.getClientId(), - client.getClientSecret(), - "tokens.revoke", - null, - false - ); + getClientCredentialsOAuthAccessToken( + getMockMvc(), + revokerClient.getClientId(), + SECRET, + "tokens.revoke", + null, + false + ); String tokenToBeRevoked = - getClientCredentialsOAuthAccessToken( - getMockMvc(), - resourceClientId, - resourceClient.getClientSecret(), - null, - null, - true - ); + getClientCredentialsOAuthAccessToken( + getMockMvc(), + resourceClientId, + SECRET, + null, + null, + true + ); getMockMvc().perform(delete("/oauth/token/revoke/" + tokenToBeRevoked) - .header("Authorization", "Bearer " + revokeAccessToken)) - .andExpect(status().isOk()); + .header("Authorization", "Bearer " + revokeAccessToken)) + .andExpect(status().isOk()); + + + try { + tokenProvisioning.retrieve(tokenToBeRevoked); + fail("Token should have been deleted"); + } catch (EmptyResultDataAccessException e) { + //expected + } + } + + @Test + public void revokeOtherClientTokenByClientId_tokensDotRevoke() throws Exception { + revokeOtherClientTokenByClientId("tokens.revoke"); + } + + @Test + public void revokeOtherClientTokenByClientId_uaaDotAdmin() throws Exception { + revokeOtherClientTokenByClientId("uaa.admin"); + } + + public void revokeOtherClientTokenByClientId(String scope) throws Exception { + String revokerClientId = generator.generate(); + String resourceClientId = generator.generate(); + + BaseClientDetails revokerClient = + setUpClients(revokerClientId, + scope, + "openid", + "client_credentials,password", + true + ); + + + BaseClientDetails targetClient = + setUpClients(resourceClientId, + "uaa.none", + "openid", + "client_credentials,password", + true + ); + + + //this is the token we will revoke + String revokeAccessToken = + getClientCredentialsOAuthAccessToken( + getMockMvc(), + revokerClient.getClientId(), + SECRET, + scope, + null, + false + ); + + String tokenToBeRevoked = + getClientCredentialsOAuthAccessToken( + getMockMvc(), + resourceClientId, + SECRET, + null, + null, + true + ); + + getMockMvc().perform(delete("/oauth/token/revoke/client/" + resourceClientId) + .header("Authorization", "Bearer " + revokeAccessToken)) + .andExpect(status().isOk()); try {
20808046de8bMerge branch 'feature/token_self_revocation' into prerelease/4.5.x
6 files changed · +233 −42
server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/JdbcRevocableTokenProvisioning.java+1 −1 modified@@ -55,7 +55,7 @@ public class JdbcRevocableTokenProvisioning implements RevocableTokenProvisionin protected AtomicLong lastExpiredCheck = new AtomicLong(0); protected long expirationCheckInterval = 30000; //30 seconds - protected JdbcRevocableTokenProvisioning(JdbcTemplate jdbcTemplate) { + public JdbcRevocableTokenProvisioning(JdbcTemplate jdbcTemplate) { this.rowMapper = new RevocableTokenRowMapper(); this.template = jdbcTemplate; }
server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenRevocationEndpoint.java+5 −2 modified@@ -16,6 +16,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.cloudfoundry.identity.uaa.audit.event.SystemDeletable; import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; import org.cloudfoundry.identity.uaa.oauth.token.RevocableToken; @@ -76,10 +77,12 @@ public ResponseEntity<Void> revokeTokensForUser(@PathVariable String userId) { @RequestMapping("/oauth/token/revoke/client/{clientId}") public ResponseEntity<Void> revokeTokensForClient(@PathVariable String clientId) { logger.debug("Revoking tokens for client: " + clientId); - BaseClientDetails client = (BaseClientDetails)clientDetailsService.loadClientByClientId(clientId, IdentityZoneHolder.get().getId()); + String zoneId = IdentityZoneHolder.get().getId(); + BaseClientDetails client = (BaseClientDetails)clientDetailsService.loadClientByClientId(clientId, zoneId); client.addAdditionalInformation(ClientConstants.TOKEN_SALT,generator.generate()); - clientDetailsService.updateClientDetails(client, IdentityZoneHolder.get().getId()); + clientDetailsService.updateClientDetails(client, zoneId); logger.debug("Tokens revoked for client: " + clientId); + ((SystemDeletable)tokenProvisioning).deleteByClient(clientId, zoneId); return new ResponseEntity<>(OK); }
server/src/test/java/org/cloudfoundry/identity/uaa/oauth/TokenRevocationEndpointTests.java+122 −0 added@@ -0,0 +1,122 @@ +/* + * **************************************************************************** + * Cloud Foundry + * Copyright (c) [2009-2017] Pivotal Software, Inc. All Rights Reserved. + * + * This product is licensed to you under the Apache License, Version 2.0 (the "License"). + * You may not use this product except in compliance with the License. + * + * This product includes a number of subcomponents with + * separate copyright notices and license terms. Your use of these + * subcomponents is subject to the terms and conditions of the + * subcomponent's license, as noted in the LICENSE file. + * **************************************************************************** + */ + +package org.cloudfoundry.identity.uaa.oauth; + +import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; +import org.cloudfoundry.identity.uaa.authentication.UaaAuthenticationDetails; +import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; +import org.cloudfoundry.identity.uaa.constants.OriginKeys; +import org.cloudfoundry.identity.uaa.oauth.token.JdbcRevocableTokenProvisioning; +import org.cloudfoundry.identity.uaa.oauth.token.RevocableToken; +import org.cloudfoundry.identity.uaa.resources.jdbc.JdbcPagingListFactory; +import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; +import org.cloudfoundry.identity.uaa.scim.jdbc.JdbcScimUserProvisioning; +import org.cloudfoundry.identity.uaa.test.JdbcTestBase; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MultitenantJdbcClientDetailsService; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.common.util.RandomValueStringGenerator; +import org.springframework.security.oauth2.provider.ClientDetails; +import org.springframework.security.oauth2.provider.OAuth2Request; +import org.springframework.security.oauth2.provider.client.BaseClientDetails; + +import java.util.Collections; + +import static org.cloudfoundry.identity.uaa.oauth.client.ClientConstants.TOKEN_SALT; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +public class TokenRevocationEndpointTests extends JdbcTestBase { + + private TokenRevocationEndpoint endpoint; + private RandomValueStringGenerator generator; + private BaseClientDetails client; + private ApplicationEventPublisher publisher; + private MultitenantJdbcClientDetailsService clientService; + + @Before + public void setupForTokenRevocation() throws Exception { + String zoneId = IdentityZoneHolder.get().getId(); + generator = new RandomValueStringGenerator(); + String clientId = generator.generate().toLowerCase(); + client = new BaseClientDetails(clientId, "", "some.scopes", "client_credentials", "authorities"); + client.addAdditionalInformation(TOKEN_SALT, "pre-salt"); + clientService = spy(new MultitenantJdbcClientDetailsService(jdbcTemplate)); + clientService.addClientDetails(client, zoneId); + + ScimUserProvisioning userProvisioning = new JdbcScimUserProvisioning( + jdbcTemplate, + new JdbcPagingListFactory(jdbcTemplate, limitSqlAdapter) + ); + JdbcRevocableTokenProvisioning provisioning = spy(new JdbcRevocableTokenProvisioning(jdbcTemplate)); + endpoint = spy(new TokenRevocationEndpoint(clientService, userProvisioning, provisioning)); + publisher = mock(ApplicationEventPublisher.class); + + SecurityContextHolder.getContext().setAuthentication( + new UaaOauth2Authentication( + "token-value", + zoneId, + mock(OAuth2Request.class), + new UaaAuthentication( + new UaaPrincipal("id", "username", "username@test.com", OriginKeys.UAA, "", zoneId), + Collections.emptyList(), + mock(UaaAuthenticationDetails.class) + ) + ) + ); + + provisioning.create( + new RevocableToken() + .setClientId(client.getClientId()) + .setTokenId("token-id") + .setUserId(null) + .setResponseType(RevocableToken.TokenType.ACCESS_TOKEN) + .setValue("value") + .setIssuedAt(System.currentTimeMillis()), + zoneId + ); + } + + @After + public void cleanup() throws Exception { + SecurityContextHolder.clearContext(); + IdentityZoneHolder.clear(); + } + + @Test + public void revokeTokensForClient() throws Exception { + assertEquals("pre-salt", getClient().getAdditionalInformation().get(TOKEN_SALT)); + assertEquals(1, clientTokenCount()); + endpoint.revokeTokensForClient(client.getClientId()); + assertNotEquals("pre-salt", getClient().getAdditionalInformation().get(TOKEN_SALT)); + assertEquals(0, clientTokenCount()); + } + + public ClientDetails getClient() { + return clientService.loadClientByClientId(client.getClientId()); + } + + public int clientTokenCount() { + return jdbcTemplate.queryForObject("select count(*) from revocable_tokens where client_id = ?", Integer.class, client.getClientId()); + } + +} \ No newline at end of file
uaa/src/main/webapp/WEB-INF/spring/oauth-endpoints.xml+1 −1 modified@@ -63,7 +63,7 @@ authentication-manager-ref="emptyAuthenticationManager" entry-point-ref="oauthAuthenticationEntryPoint" xmlns="http://www.springframework.org/schema/security" use-expressions="true"> - <intercept-url pattern="/oauth/token/revoke/client/**" access="#oauth2.hasScope('uaa.admin') or @self.isClientTokenRevocationForSelf(request, 4)" /> + <intercept-url pattern="/oauth/token/revoke/client/**" access="#oauth2.hasScope('tokens.revoke')" /> <intercept-url pattern="/oauth/token/revoke/user/**" access="#oauth2.hasScope('uaa.admin') or (#oauth2.hasScope('tokens.revoke') and @self.isUserTokenRevocationForSelf(request, 4))" /> <intercept-url pattern="/oauth/token/revoke/**" access="#oauth2.hasScope('tokens.revoke') or @self.isTokenRevocationForSelf(request, 3)" method="DELETE"/> <intercept-url pattern="/**" access="denyAll" />
uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java+1 −1 modified@@ -705,7 +705,7 @@ public void revokeAllTokens_forAClient() throws Exception { true ); Snippet requestHeaders = requestHeaders( - headerWithName("Authorization").description("Bearer token with uaa.admin or tokens.revoke scope. Any token with the matching client_id may also be used for self revocation."), + headerWithName("Authorization").description("Bearer token with uaa.admin or tokens.revoke scope."), IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER );
uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenMvcMockTests.java+103 −37 modified@@ -3411,52 +3411,118 @@ public void revokeOwnJWToken() throws Exception { } @Test - public void revokeOtherClientToken() throws Exception { + public void revokeOtherClientTokenByJti() throws Exception { + String revokerClientId = generator.generate(); String resourceClientId = generator.generate(); - BaseClientDetails resourceClient = new BaseClientDetails( - resourceClientId, - "", - "uaa.resource", - "client_credentials,password", - "uaa.resource", - "http://redirect.uri"); - resourceClient.setClientSecret("secret"); - createClient(getMockMvc(), adminToken, resourceClient); - BaseClientDetails client = new BaseClientDetails( - generator.generate(), - "", - "openid", - "client_credentials,password", - "tokens.revoke", - "http://redirect.uri"); - client.setClientSecret("secret"); - createClient(getMockMvc(), adminToken, client); + BaseClientDetails revokerClient = + setUpClients(revokerClientId, + "tokens.revoke", + "openid", + "client_credentials,password", + true + ); + + + BaseClientDetails targetClient = + setUpClients(resourceClientId, + "uaa.none", + "openid", + "client_credentials,password", + true + ); + //this is the token we will revoke String revokeAccessToken = - getClientCredentialsOAuthAccessToken( - getMockMvc(), - client.getClientId(), - client.getClientSecret(), - "tokens.revoke", - null, - false - ); + getClientCredentialsOAuthAccessToken( + getMockMvc(), + revokerClient.getClientId(), + SECRET, + "tokens.revoke", + null, + false + ); String tokenToBeRevoked = - getClientCredentialsOAuthAccessToken( - getMockMvc(), - resourceClientId, - resourceClient.getClientSecret(), - null, - null, - true - ); + getClientCredentialsOAuthAccessToken( + getMockMvc(), + resourceClientId, + SECRET, + null, + null, + true + ); getMockMvc().perform(delete("/oauth/token/revoke/" + tokenToBeRevoked) - .header("Authorization", "Bearer " + revokeAccessToken)) - .andExpect(status().isOk()); + .header("Authorization", "Bearer " + revokeAccessToken)) + .andExpect(status().isOk()); + + + try { + tokenProvisioning.retrieve(tokenToBeRevoked, IdentityZoneHolder.get().getId()); + fail("Token should have been deleted"); + } catch (EmptyResultDataAccessException e) { + //expected + } + } + + @Test + public void revokeOtherClientTokenByClientId_tokensDotRevoke() throws Exception { + revokeOtherClientTokenByClientId("tokens.revoke"); + } + + @Test + public void revokeOtherClientTokenByClientId_uaaDotAdmin() throws Exception { + revokeOtherClientTokenByClientId("uaa.admin"); + } + + public void revokeOtherClientTokenByClientId(String scope) throws Exception { + String revokerClientId = generator.generate(); + String resourceClientId = generator.generate(); + + BaseClientDetails revokerClient = + setUpClients(revokerClientId, + scope, + "openid", + "client_credentials,password", + true + ); + + + BaseClientDetails targetClient = + setUpClients(resourceClientId, + "uaa.none", + "openid", + "client_credentials,password", + true + ); + + + //this is the token we will revoke + String revokeAccessToken = + getClientCredentialsOAuthAccessToken( + getMockMvc(), + revokerClient.getClientId(), + SECRET, + scope, + null, + false + ); + + String tokenToBeRevoked = + getClientCredentialsOAuthAccessToken( + getMockMvc(), + resourceClientId, + SECRET, + null, + null, + true + ); + + getMockMvc().perform(delete("/oauth/token/revoke/client/" + resourceClientId) + .header("Authorization", "Bearer " + revokeAccessToken)) + .andExpect(status().isOk()); try {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
12- www.securityfocus.com/bid/101967nvdThird Party AdvisoryVDB Entry
- github.com/advisories/GHSA-j4p3-2m2h-cv5fghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2017-8031ghsaADVISORY
- www.cloudfoundry.org/cve-2017-8031/nvdIssue TrackingThird Party Advisory
- github.com/cloudfoundry/uaa/commit/1e2a746968cdac5b53164ca8955646e4257ecc7ghsaWEB
- github.com/cloudfoundry/uaa/commit/20808046de8bbdc6fb2ac62829d4cc9d7a19f37ghsaWEB
- github.com/cloudfoundry/uaa/commit/66166d17781aa257ff77a2fb7c69f72d0b611beghsaWEB
- github.com/cloudfoundry/uaa/releases/tag/3.20.1ghsaWEB
- github.com/cloudfoundry/uaa/releases/tag/4.5.3ghsaWEB
- github.com/cloudfoundry/uaa/releases/tag/4.7.1ghsaWEB
- web.archive.org/web/20200227134207/http://www.securityfocus.com/bid/101967ghsaWEB
- www.cloudfoundry.org/cve-2017-8031ghsaWEB
News mentions
0No linked articles in our index yet.