diff --git a/gate-web/gate-web.gradle b/gate-web/gate-web.gradle index faf9996d0b..9d91d88bad 100644 --- a/gate-web/gate-web.gradle +++ b/gate-web/gate-web.gradle @@ -29,6 +29,7 @@ dependencies { compile spinnaker.dependency("korkWeb") compile spinnaker.dependency("frigga") compile spinnaker.dependency('cglib') + compile('com.github.kstyrc:embedded-redis:0.6') compile('org.springframework.session:spring-session-data-redis:1.1.1.RELEASE') compile("org.springframework.cloud:spring-cloud-security:1.0.3.RELEASE") compile('org.opensaml:opensaml:2.6.4') diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/security/rolesprovider/UserRolesSyncer.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/security/rolesprovider/UserRolesSyncer.groovy index 655b225ce0..96fc9dee63 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/security/rolesprovider/UserRolesSyncer.groovy +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/security/rolesprovider/UserRolesSyncer.groovy @@ -24,13 +24,17 @@ import org.springframework.data.redis.core.RedisTemplate import org.springframework.scheduling.annotation.Scheduled import org.springframework.security.core.context.SecurityContext import org.springframework.session.ExpiringSession +import org.springframework.session.Session +import org.springframework.session.SessionRepository @Slf4j @Configuration class UserRolesSyncer { + @Autowired + RedisTemplate sessionRedisTemplate @Autowired - RedisTemplate redisTemplate + SessionRepository repository @Autowired UserRolesProvider userRolesProvider @@ -41,30 +45,40 @@ class UserRolesSyncer { */ @Scheduled(initialDelay = 10000L, fixedRate = 600000L) public void syncUserGroups() { - Map emailSessionKeyMap = [:] + Map emailSessionIdMap = [:] Map> emailCurrentGroupsMap = [:] - Set sessionKeys = redisTemplate.keys('*session:sessions*') + Set sessionKeys = sessionRedisTemplate.keys('*session:sessions*') + + Set sessionIds = sessionKeys.collect { String key -> + def toks = key.split(":") + toks[toks.length - 1] + } - sessionKeys.each { String key -> - def secCtx = redisTemplate.opsForHash().get(key, "sessionAttr:SPRING_SECURITY_CONTEXT") as SecurityContext - def principal = secCtx?.authentication?.principal - if (principal && principal instanceof User) { - emailSessionKeyMap[principal.email] = key - emailCurrentGroupsMap[principal.email] = principal.roles + sessionIds.each { String id -> + Session session = repository.getSession(id) + if (session) { // getSession(id) may return null if session is expired but not reaped + def secCtx = session.getAttribute("SPRING_SECURITY_CONTEXT") as SecurityContext + def principal = secCtx?.authentication?.principal + if (principal && principal instanceof User) { + emailSessionIdMap[principal.email] = id + emailCurrentGroupsMap[principal.email] = principal.roles + } } } - def newGroupsMap = userRolesProvider.multiLoadRoles(emailSessionKeyMap.keySet()) - def sessionKeysToDelete = [] + def newGroupsMap = userRolesProvider.multiLoadRoles(emailSessionIdMap.keySet()) + def sessionIdsToDelete = [] newGroupsMap.each { String email, Collection newGroups -> // cast for equals check to work List newList = newGroups as List List oldList = emailCurrentGroupsMap[email] as List if (oldList != newList) { - sessionKeysToDelete.add(emailSessionKeyMap[email]) + sessionIdsToDelete.add(emailSessionIdMap[email]) } } - redisTemplate.delete(sessionKeysToDelete) - log.info("Invalidated {} user sessions due to changed group memberships.", sessionKeysToDelete.size()) + + def keysToDelete = sessionIdsToDelete.collect { String id -> "spring:session:sessions:" + id } + sessionRedisTemplate.delete(keysToDelete) + log.info("Invalidated {} user sessions due to changed group memberships.", keysToDelete.size()) } } diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/redis/EmbeddedRedis.groovy b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/redis/EmbeddedRedis.groovy new file mode 100644 index 0000000000..658bd31d1b --- /dev/null +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/redis/EmbeddedRedis.groovy @@ -0,0 +1,77 @@ +/* + * Copyright 2016 Google, Inc. + * + * 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.redis + +import redis.clients.jedis.Jedis +import redis.clients.jedis.JedisPool +import redis.clients.util.Pool +import redis.embedded.RedisServer + +public class EmbeddedRedis { + + private final URI connection; + private final RedisServer redisServer; + + private Pool jedis; + + private EmbeddedRedis(int port) throws IOException, URISyntaxException { + this.connection = URI.create(String.format("redis://127.0.0.1:%d/0", port)); + this.redisServer = RedisServer + .builder() + .port(port) + .setting("bind 127.0.0.1") + .setting("appendonly no") + .setting("save \"\"") + .setting("databases 1") + .build(); + this.redisServer.start(); + } + + public void destroy() { + try { + this.redisServer.stop(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public int getPort() { + return redisServer.ports().get(0); + } + + public Pool getPool() { + if (jedis == null) { + jedis = new JedisPool(connection); + } + return jedis; + } + + public Jedis getJedis() { + return getPool().getResource(); + } + + public static EmbeddedRedis embed() { + try { + ServerSocket serverSocket = new ServerSocket(0); + int port = serverSocket.getLocalPort(); + serverSocket.close(); + return new EmbeddedRedis(port); + } catch (IOException | URISyntaxException e) { + throw new RuntimeException("Failed to create embedded Redis", e); + } + } +} \ No newline at end of file diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/security/rolesprovider/UserRolesSyncerSpec.groovy b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/security/rolesprovider/UserRolesSyncerSpec.groovy new file mode 100644 index 0000000000..2cb0eaad49 --- /dev/null +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/security/rolesprovider/UserRolesSyncerSpec.groovy @@ -0,0 +1,194 @@ +/* + * Copyright 2016 Google, Inc. + * + * 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.security.rolesprovider + +import com.netflix.spinnaker.gate.redis.EmbeddedRedis +import com.netflix.spinnaker.security.User +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.security.core.context.SecurityContextImpl +import org.springframework.security.oauth2.provider.OAuth2Authentication +import org.springframework.security.oauth2.provider.OAuth2Request +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken +import org.springframework.session.data.redis.RedisOperationsSessionRepository +import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.web.WebAppConfiguration +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Subject + +@WebAppConfiguration +@ContextConfiguration +class UserRolesSyncerSpec extends Specification { + + /** + * Port the EmbeddedRedis instance associated with this test is listening on. + */ + static Integer port + + @Shared + @AutoCleanup("destroy") + EmbeddedRedis embeddedRedis + + @Autowired + RedisTemplate sessionRedisTemplate + + @Autowired + RedisOperationsSessionRepository repository + + @Shared + @Subject + UserRolesSyncer userRolesSyncer + + def setupSpec() { + embeddedRedis = EmbeddedRedis.embed() + embeddedRedis.jedis.flushDB() + port = embeddedRedis.port + } + + def cleanup() { + embeddedRedis.jedis.flushDB() + } + + def createSpringSession(String email, List roles) { + def session = repository.createSession() + + User spinnakerUser = new User( + email: email, + firstName: "User", + lastName: "McUserFace", + allowedAccounts: ["AnonAllowedAccount"], + roles: roles, + ) + + PreAuthenticatedAuthenticationToken authentication = new PreAuthenticatedAuthenticationToken( + spinnakerUser, + null /* credentials */, + spinnakerUser.authorities + ) + + // impl copied from userInfoTokenServices + OAuth2Request storedRequest = new OAuth2Request(null, "fake_test_client_id", null, true /*approved*/, + null, null, null, null, null); + + def oauthentication = new OAuth2Authentication(storedRequest, authentication) + def secCtx = new SecurityContextImpl() + secCtx.setAuthentication(oauthentication) + session.setAttribute("SPRING_SECURITY_CONTEXT", secCtx) + + repository.save(session) + return session.getId() + } + + def "should invalidate the session"() { + given: "the session's groups are not up to date" + userRolesSyncer = new UserRolesSyncer(sessionRedisTemplate: sessionRedisTemplate, repository: repository) + def sessionId = createSpringSession("user@mcuserface.com", ["noob"]) + def rolesProvider = new UserRolesProvider() { + @Override + Collection loadRoles(String userEmail) { + return [] + } + + @Override + Map> multiLoadRoles(Collection userEmails) { + return ["user@mcuserface.com": ["real_user"]] + } + } + userRolesSyncer.userRolesProvider = rolesProvider + + when: "we sync the groups" + userRolesSyncer.syncUserGroups() + + then: "no sessions should be present" + repository.getSession(sessionId) == null + } + + def "should not invalidate the session"() { + given: "the session's groups are up to date" + userRolesSyncer = new UserRolesSyncer(sessionRedisTemplate: sessionRedisTemplate, repository: repository) + def sessionId = createSpringSession("user1@mcuserface.com", ["noob"]) + def rolesProvider = new UserRolesProvider() { + @Override + Collection loadRoles(String userEmail) { + return [] + } + + @Override + Map> multiLoadRoles(Collection userEmails) { + return ["user1@mcuserface.com": ["noob"]] + } + } + userRolesSyncer.userRolesProvider = rolesProvider + + when: "we sync the groups" + userRolesSyncer.syncUserGroups() + + then: "our session should be present" + repository.getSession(sessionId) != null + } + + def "should invalidate only bad sessions"() { + given: "the session's groups are up to date" + userRolesSyncer = new UserRolesSyncer(sessionRedisTemplate: sessionRedisTemplate, repository: repository) + def goodSessionId = createSpringSession("user2@mcuserface.com", ["noob", "real_user"]) + def badSessionId = createSpringSession("admin@mcuserface.com", ["real_user"]) + def rolesProvider = new UserRolesProvider() { + @Override + Collection loadRoles(String userEmail) { + return [] + } + + @Override + Map> multiLoadRoles(Collection userEmails) { + return [ + "user2@mcuserface.com": ["noob", "real_user"], + "admin@mcuserface.com": [], + ] + } + } + userRolesSyncer.userRolesProvider = rolesProvider + + when: "we sync the groups" + userRolesSyncer.syncUserGroups() + + then: "our session should be present" + repository.getSession(goodSessionId) != null + repository.getSession(badSessionId) == null + } + + @Configuration + @EnableRedisHttpSession + static class Config { + @Bean + public JedisConnectionFactory connectionFactory() { + URI redis = URI.create("redis://localhost:" + port.toString()) + def factory = new JedisConnectionFactory() + factory.hostName = redis.host + factory.port = redis.port + if (redis.userInfo) { + factory.password = redis.userInfo.split(":", 2)[1] + } + factory + } + } +}