diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/AuthController.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/AuthController.groovy index 60a97d83a3..b992dc6a68 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/AuthController.groovy +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/AuthController.groovy @@ -18,11 +18,11 @@ package com.netflix.spinnaker.gate.controllers import com.netflix.spinnaker.gate.security.SpinnakerUser import com.netflix.spinnaker.gate.services.PermissionService +import com.netflix.spinnaker.gate.services.SessionService import com.netflix.spinnaker.security.AuthenticatedRequest import com.netflix.spinnaker.security.User import groovy.util.logging.Slf4j import io.swagger.annotations.ApiOperation -import org.apache.commons.lang3.exception.ExceptionUtils import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.security.access.prepost.PreAuthorize @@ -58,10 +58,14 @@ class AuthController { @Autowired PermissionService permissionService + SessionService sessionService + @Autowired AuthController(@Value('${services.deck.base-url:}') URL deckBaseUrl, - @Value('${services.deck.redirect-host-pattern:#{null}}') String redirectHostPattern) { + @Value('${services.deck.redirect-host-pattern:#{null}}') String redirectHostPattern, + @Autowired SessionService sessionService) { this.deckBaseUrl = deckBaseUrl + this.sessionService = sessionService if (redirectHostPattern) { this.redirectHostPattern = Pattern.compile(redirectHostPattern) @@ -117,6 +121,16 @@ class AuthController { permissionService.sync() } + /** + * On-demand endpoint to purge the session tokens cache + */ + @ApiOperation(value = "Delete session cache") + @RequestMapping(value = "/deleteSessionCache", method = RequestMethod.POST) + @PreAuthorize("@authController.isAdmin()") + void deleteSessionCache() { + sessionService.deleteSpringSessions() + } + @ApiOperation(value = "Redirect to Deck") @RequestMapping(value = "/redirect", method = RequestMethod.GET) void redirect(HttpServletResponse response, @RequestParam String to) { diff --git a/gate-web/src/main/java/com/netflix/spinnaker/gate/services/SessionService.java b/gate-web/src/main/java/com/netflix/spinnaker/gate/services/SessionService.java new file mode 100644 index 0000000000..cbe0d48358 --- /dev/null +++ b/gate-web/src/main/java/com/netflix/spinnaker/gate/services/SessionService.java @@ -0,0 +1,43 @@ +/* + * Copyright 2024 Wise PLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.services; + +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; + +@Component +public class SessionService { + + private final JedisPool jedisPool; + + public SessionService(@Autowired JedisPool jedisPool) { + this.jedisPool = jedisPool; + } + + public void deleteSpringSessions() { + try (Jedis jedis = jedisPool.getResource()) { + Set keys = jedis.keys("spring:session*"); + + if (!keys.isEmpty()) { + jedis.del(keys.toArray(new String[0])); + } + } + } +} diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/AuthControllerSpec.groovy b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/AuthControllerSpec.groovy index 3c6696d3cc..7714fd1f14 100644 --- a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/AuthControllerSpec.groovy +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/AuthControllerSpec.groovy @@ -17,6 +17,7 @@ package com.netflix.spinnaker.gate.controllers +import com.netflix.spinnaker.gate.services.SessionService import spock.lang.Specification import spock.lang.Unroll @@ -24,7 +25,7 @@ class AuthControllerSpec extends Specification { @Unroll def "should validate redirectUrl against deckBaseUrl or redirectHostPattern"() { given: - def autoController = new AuthController(deckBaseUrl, redirectHostPattern) + def autoController = new AuthController(deckBaseUrl, redirectHostPattern, null) expect: autoController.validDeckRedirect(to) == isValid @@ -38,4 +39,19 @@ class AuthControllerSpec extends Specification { new URL("http://localhost:9000") | "root.net" | "http://spinnaker.root.net:8000" || false new URL("http://localhost:9000") | ".*\\.root\\.net" | "http://spinnaker.root.net:8000" || true // redirectHostPattern supports regex } + + @Unroll + def "should delete session tokens cache"() { + given: + def sessionServiceMock = Mock(SessionService) + sessionServiceMock.deleteSpringSessions() >> null + + def authController = new AuthController(null, null, sessionServiceMock) + + when: + authController.deleteSessionCache() + + then: + 1 * sessionServiceMock.deleteSpringSessions() + } } diff --git a/gate-web/src/test/java/com/netflix/spinnaker/gate/service/SessionServiceTest.java b/gate-web/src/test/java/com/netflix/spinnaker/gate/service/SessionServiceTest.java new file mode 100644 index 0000000000..9ac3f5decf --- /dev/null +++ b/gate-web/src/test/java/com/netflix/spinnaker/gate/service/SessionServiceTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2024 Wise PLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.service; + +import com.netflix.spinnaker.gate.services.SessionService; +import com.netflix.spinnaker.kork.jedis.EmbeddedRedis; +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import redis.clients.jedis.Jedis; + +public class SessionServiceTest { + + private static EmbeddedRedis embeddedRedis; + + @BeforeAll + public static void setupSpec() { + embeddedRedis = EmbeddedRedis.embed(); + } + + @AfterAll + public static void tearDown() { + if (embeddedRedis != null) { + embeddedRedis.destroy(); + } + } + + @Test + public void shouldDeleteSpringSessions() { + // Given + Jedis jedis = embeddedRedis.getJedis(); + jedis.set("spring:session:session1", "session1-data"); + jedis.set("spring:session:session2", "session2-data"); + jedis.set("other:key", "other-data"); + + SessionService subject = new SessionService(embeddedRedis.getPool()); + + // When + subject.deleteSpringSessions(); + + // Then + Set springSessionKeys = jedis.keys("spring:session*"); + Set otherKeys = jedis.keys("other:key"); + + Assertions.assertTrue( + springSessionKeys.isEmpty(), "Spring session keys should have been deleted"); + Assertions.assertEquals(1, otherKeys.size(), "Other keys should remain"); + } +}