From 2724b333b3c37424d7b06c1048934f63c1423018 Mon Sep 17 00:00:00 2001 From: Joris Kuipers Date: Fri, 2 Sep 2016 21:01:41 +0200 Subject: [PATCH] Spring security session registry (#473) * Spring Security Concurrent Session Integration #65 add SpringSessionBackedSessionRegistry * Spring Security Concurrent Session Integration #65 add documentation * Spring Security Concurrent Session Integration #65 support marking SessionInformations as expired before deleting the Session --- docs/build.gradle | 1 + docs/src/docs/asciidoc/index.adoc | 39 ++++- .../docs/security/SecurityConfiguration.java | 52 ++++++ .../docs/security/security-config.xml | 22 +++ spring-session/build.gradle | 3 +- ...SpringSessionBackedSessionInformation.java | 92 +++++++++++ .../SpringSessionBackedSessionRegistry.java | 113 +++++++++++++ ...pringSessionBackedSessionRegistryTest.java | 149 ++++++++++++++++++ 8 files changed, 469 insertions(+), 2 deletions(-) create mode 100644 docs/src/test/java/docs/security/SecurityConfiguration.java create mode 100644 docs/src/test/resources/docs/security/security-config.xml create mode 100644 spring-session/src/main/java/org/springframework/session/security/SpringSessionBackedSessionInformation.java create mode 100644 spring-session/src/main/java/org/springframework/session/security/SpringSessionBackedSessionRegistry.java create mode 100644 spring-session/src/test/java/org/springframework/session/security/SpringSessionBackedSessionRegistryTest.java diff --git a/docs/build.gradle b/docs/build.gradle index b40d9ac..3408de0 100644 --- a/docs/build.gradle +++ b/docs/build.gradle @@ -30,6 +30,7 @@ dependencies { "org.springframework:spring-websocket:${springVersion}", "org.springframework:spring-messaging:${springVersion}", "org.springframework:spring-jdbc:${springVersion}", + "org.springframework.security:spring-security-config:${springSecurityVersion}", "org.springframework.security:spring-security-web:${springSecurityVersion}", "org.springframework.security:spring-security-test:${springSecurityVersion}", 'junit:junit:4.11', diff --git a/docs/src/docs/asciidoc/index.adoc b/docs/src/docs/asciidoc/index.adoc index 77f5155..d3fc7f6 100644 --- a/docs/src/docs/asciidoc/index.adoc +++ b/docs/src/docs/asciidoc/index.adoc @@ -505,6 +505,43 @@ Before using WebSocket integration, you should be sure that you have < sessionRepository; + + @Override + protected void configure(HttpSecurity http) throws Exception { + http + // other config goes here... + .sessionManagement() + .maximumSessions(2) + .sessionRegistry(sessionRegistry()); + } + + @Bean + SpringSessionBackedSessionRegistry sessionRegistry() { + return new SpringSessionBackedSessionRegistry(this.sessionRepository); + } +} +// end::class[] diff --git a/docs/src/test/resources/docs/security/security-config.xml b/docs/src/test/resources/docs/security/security-config.xml new file mode 100644 index 0000000..3c664a2 --- /dev/null +++ b/docs/src/test/resources/docs/security/security-config.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spring-session/build.gradle b/spring-session/build.gradle index 91be2d4..394fbff 100644 --- a/spring-session/build.gradle +++ b/spring-session/build.gradle @@ -24,7 +24,8 @@ dependencies { "org.springframework:spring-context:$springVersion", "org.springframework:spring-web:$springVersion", "org.springframework:spring-messaging:$springVersion", - "org.springframework:spring-websocket:$springVersion" + "org.springframework:spring-websocket:$springVersion", + "org.springframework.security:spring-security-core:$springSecurityVersion" provided "javax.servlet:javax.servlet-api:$servletApiVersion" integrationTestCompile "redis.clients:jedis:$jedisVersion", "org.apache.commons:commons-pool2:2.2", diff --git a/spring-session/src/main/java/org/springframework/session/security/SpringSessionBackedSessionInformation.java b/spring-session/src/main/java/org/springframework/session/security/SpringSessionBackedSessionInformation.java new file mode 100644 index 0000000..e789d04 --- /dev/null +++ b/spring-session/src/main/java/org/springframework/session/security/SpringSessionBackedSessionInformation.java @@ -0,0 +1,92 @@ +/* + * Copyright 2014-2016 the original author or authors. + * + * 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 org.springframework.session.security; + +import java.util.Date; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.session.SessionInformation; + +import org.springframework.session.ExpiringSession; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.springframework.session.SessionRepository; + +/** + * Ensures that calling {@link #expireNow()} propagates to Spring Session, + * since this session information contains only derived data and is not the authoritative source. + * + * @author Joris Kuipers + * @since 1.3 + */ +class SpringSessionBackedSessionInformation extends SessionInformation { + + static final String EXPIRED_ATTR = SpringSessionBackedSessionInformation.class.getName() + ".EXPIRED"; + + private static final Log logger = LogFactory.getLog(SpringSessionBackedSessionInformation.class); + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + /** + * Tries to determine the principal's name from the given Session. + * + * @param session Spring Session session + * @return the principal's name, or empty String if it couldn't be determined + */ + private static String resolvePrincipal(Session session) { + String principalName = session.getAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME); + if (principalName != null) { + return principalName; + } + SecurityContext securityContext = session.getAttribute(SPRING_SECURITY_CONTEXT); + if (securityContext != null && securityContext.getAuthentication() != null) { + return securityContext.getAuthentication().getName(); + } + return ""; + } + + private final SessionRepository sessionRepository; + + SpringSessionBackedSessionInformation(ExpiringSession session, SessionRepository sessionRepository) { + super(resolvePrincipal(session), session.getId(), new Date(session.getLastAccessedTime())); + this.sessionRepository = sessionRepository; + if (Boolean.TRUE.equals(session.getAttribute(EXPIRED_ATTR))) { + super.expireNow(); + } + } + + @Override + public void expireNow() { + if (logger.isDebugEnabled()) { + logger.debug("Expiring session " + getSessionId() + " for user '" + getPrincipal() + + "', presumably because maximum allowed concurrent sessions was exceeded"); + } + super.expireNow(); + ExpiringSession session = this.sessionRepository.getSession(getSessionId()); + if (session != null) { + session.setAttribute(EXPIRED_ATTR, Boolean.TRUE); + this.sessionRepository.save(session); + } + else { + logger.info("Could not find Session with id " + getSessionId() + " to mark as expired"); + } + } + +} diff --git a/spring-session/src/main/java/org/springframework/session/security/SpringSessionBackedSessionRegistry.java b/spring-session/src/main/java/org/springframework/session/security/SpringSessionBackedSessionRegistry.java new file mode 100644 index 0000000..8f70e64 --- /dev/null +++ b/spring-session/src/main/java/org/springframework/session/security/SpringSessionBackedSessionRegistry.java @@ -0,0 +1,113 @@ +/* + * Copyright 2014-2016 the original author or authors. + * + * 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 org.springframework.session.security; + +import java.security.Principal; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.userdetails.UserDetails; + +import org.springframework.session.ExpiringSession; +import org.springframework.session.FindByIndexNameSessionRepository; + +import org.springframework.util.Assert; + +/** + * A {@link SessionRegistry} that retrieves session information from Spring Session, rather than maintaining it itself. + * This allows concurrent session management with Spring Security in a clustered environment. + *

+ * Relies on being able to derive the same String-based representation of the principal given to + * {@link #getAllSessions(Object, boolean)} as used by Spring Session in order to look up the user's sessions. + *

+ * Does not support {@link #getAllPrincipals()}, since that information is not available. + * + * @author Joris Kuipers + * @since 1.3 + */ +public class SpringSessionBackedSessionRegistry implements SessionRegistry { + + private final FindByIndexNameSessionRepository sessionRepository; + + public SpringSessionBackedSessionRegistry(FindByIndexNameSessionRepository sessionRepository) { + Assert.notNull(sessionRepository, "sessionRepository cannot be null"); + this.sessionRepository = sessionRepository; + } + + public List getAllPrincipals() { + throw new UnsupportedOperationException("SpringSessionBackedSessionRegistry does not support retrieving all principals, " + + "since Spring Session provides no way to obtain that information"); + } + + public List getAllSessions(Object principal, boolean includeExpiredSessions) { + Collection sessions = + this.sessionRepository.findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, name(principal)).values(); + List infos = new ArrayList(); + for (ExpiringSession session : sessions) { + if (includeExpiredSessions || !Boolean.TRUE.equals(session.getAttribute(SpringSessionBackedSessionInformation.EXPIRED_ATTR))) { + infos.add(new SpringSessionBackedSessionInformation(session, this.sessionRepository)); + } + } + return infos; + } + + public SessionInformation getSessionInformation(String sessionId) { + ExpiringSession session = this.sessionRepository.getSession(sessionId); + if (session != null) { + return new SpringSessionBackedSessionInformation(session, this.sessionRepository); + } + return null; + } + + /* + * This is a no-op, as we don't administer sessions ourselves. + */ + public void refreshLastRequest(String sessionId) { + } + + /* + * This is a no-op, as we don't administer sessions ourselves. + */ + public void registerNewSession(String sessionId, Object principal) { + } + + /* + * This is a no-op, as we don't administer sessions ourselves. + */ + public void removeSessionInformation(String sessionId) { + } + + /** + * Derives a String name for the given principal. + * + * @param principal as provided by Spring Security + * @return name of the principal, or its {@code toString()} representation if no name could be derived + */ + protected String name(Object principal) { + if (principal instanceof UserDetails) { + return ((UserDetails) principal).getUsername(); + } + if (principal instanceof Principal) { + return ((Principal) principal).getName(); + } + return principal.toString(); + } +} diff --git a/spring-session/src/test/java/org/springframework/session/security/SpringSessionBackedSessionRegistryTest.java b/spring-session/src/test/java/org/springframework/session/security/SpringSessionBackedSessionRegistryTest.java new file mode 100644 index 0000000..4c2ae52 --- /dev/null +++ b/spring-session/src/test/java/org/springframework/session/security/SpringSessionBackedSessionRegistryTest.java @@ -0,0 +1,149 @@ +/* + * Copyright 2014-2016 the original author or authors. + * + * 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 org.springframework.session.security; + +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.userdetails.User; +import org.springframework.session.ExpiringSession; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.MapSession; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.verify; +import static org.mockito.BDDMockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class SpringSessionBackedSessionRegistryTest { + + static final String SESSION_ID = "sessionId"; + static final String SESSION_ID2 = "otherSessionId"; + static final String USER_NAME = "userName"; + static final User PRINCIPAL = new User(USER_NAME, "password", Collections.emptyList()); + static final Date NOW = new Date(); + + @Mock + FindByIndexNameSessionRepository sessionRepository; + + @InjectMocks + SpringSessionBackedSessionRegistry sessionRegistry; + + @Test + public void sessionInformationForExistingSession() { + ExpiringSession session = createSession(SESSION_ID, USER_NAME, NOW.getTime()); + when(this.sessionRepository.getSession(SESSION_ID)).thenReturn(session); + + SessionInformation sessionInfo = this.sessionRegistry.getSessionInformation(SESSION_ID); + + assertThat(sessionInfo.getSessionId()).isEqualTo(SESSION_ID); + assertThat(sessionInfo.getLastRequest()).isEqualTo(NOW); + assertThat(sessionInfo.getPrincipal()).isEqualTo(USER_NAME); + assertThat(sessionInfo.isExpired()).isFalse(); + } + + @Test + public void sessionInformationForExpiredSession() { + ExpiringSession session = createSession(SESSION_ID, USER_NAME, NOW.getTime()); + session.setAttribute(SpringSessionBackedSessionInformation.EXPIRED_ATTR, Boolean.TRUE); + when(this.sessionRepository.getSession(SESSION_ID)).thenReturn(session); + + SessionInformation sessionInfo = this.sessionRegistry.getSessionInformation(SESSION_ID); + + assertThat(sessionInfo.getSessionId()).isEqualTo(SESSION_ID); + assertThat(sessionInfo.getLastRequest()).isEqualTo(NOW); + assertThat(sessionInfo.getPrincipal()).isEqualTo(USER_NAME); + assertThat(sessionInfo.isExpired()).isTrue(); + } + + @Test + public void noSessionInformationForMissingSession() { + assertThat(this.sessionRegistry.getSessionInformation("nonExistingSessionId")).isNull(); + } + + @Test + public void getAllSessions() { + setUpSessions(); + + List allSessionInfos = this.sessionRegistry.getAllSessions(PRINCIPAL, true); + + assertThat(allSessionInfos).extracting("sessionId").containsExactly(SESSION_ID, SESSION_ID2); + } + + @Test + public void getNonExpiredSessions() { + setUpSessions(); + + List nonExpiredSessionInfos = this.sessionRegistry.getAllSessions(PRINCIPAL, false); + + assertThat(nonExpiredSessionInfos).extracting("sessionId").containsExactly(SESSION_ID2); + } + + @Test + public void expireNow() { + ExpiringSession session = createSession(SESSION_ID, USER_NAME, NOW.getTime()); + when(this.sessionRepository.getSession(SESSION_ID)).thenReturn(session); + + SessionInformation sessionInfo = this.sessionRegistry.getSessionInformation(SESSION_ID); + assertThat(sessionInfo.isExpired()).isFalse(); + + sessionInfo.expireNow(); + + assertThat(sessionInfo.isExpired()).isTrue(); + ArgumentCaptor captor = ArgumentCaptor.forClass(ExpiringSession.class); + verify(this.sessionRepository).save(captor.capture()); + assertThat(captor.getValue().getAttribute(SpringSessionBackedSessionInformation.EXPIRED_ATTR)).isEqualTo(Boolean.TRUE); + } + + private ExpiringSession createSession(String sessionId, String userName, Long lastAccessed) { + MapSession session = new MapSession(sessionId); + session.setLastAccessedTime(lastAccessed); + Authentication authentication = mock(Authentication.class); + when(authentication.getName()).thenReturn(userName); + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(authentication); + session.setAttribute("SPRING_SECURITY_CONTEXT", securityContext); + return session; + } + + private void setUpSessions() { + ExpiringSession session1 = createSession(SESSION_ID, USER_NAME, NOW.getTime()); + session1.setAttribute(SpringSessionBackedSessionInformation.EXPIRED_ATTR, Boolean.TRUE); + ExpiringSession session2 = createSession(SESSION_ID2, USER_NAME, NOW.getTime()); + Map sessions = new LinkedHashMap(); + sessions.put(session1.getId(), session1); + sessions.put(session2.getId(), session2); + when(this.sessionRepository.findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, USER_NAME)).thenReturn(sessions); + } + +}